1 /**
2  * Lookup of icon themes and icons.
3  *
4  * Note: All found icons are just paths. They are not verified to be valid images.
5  *
6  * Authors:
7  *  $(LINK2 https://github.com/FreeSlave, Roman Chistokhodov)
8  * Copyright:
9  *  Roman Chistokhodov, 2015-2016
10  * License:
11  *  $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0).
12  * See_Also:
13  *  $(LINK2 http://standards.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html, Icon Theme Specification)
14  */
15 
16 module icontheme.lookup;
17 
18 import icontheme.file;
19 
20 package {
21     import std.file;
22     import std.path;
23     import std.range;
24     import std.traits;
25     import std.typecons;
26 }
27 
28 @trusted bool isDirNothrow(string dir) nothrow
29 {
30     bool ok;
31     collectException(dir.isDir(), ok);
32     return ok;
33 }
34 
35 @trusted bool isFileNothrow(string file) nothrow
36 {
37     bool ok;
38     collectException(file.isFile(), ok);
39     return ok;
40 }
41 
42 @trusted InputRange!DirEntry dirEntriesNothrow(string path, SpanMode mode) nothrow
43 {
44     try {
45         return inputRangeObject(dirEntries(path, mode));
46     } catch(Exception e) {
47         return inputRangeObject(DirEntry[].init);
48     }
49 }
50 
51 /**
52  * Default icon extensions. This array includes .png and .xpm.
53  * PNG is recommended format.
54  * XPM is kept for backward compatibility.
55  *
56  * Note: Icon Theme Specificiation also lists .svg as possible format,
57  * but it's less common to have SVG support for applications,
58  * hence this format is defined as optional by specificiation.
59  * If your application has proper support for SVG images,
60  * array should include it in the first place as the most preferred format
61  * because SVG images are scalable.
62  */
63 enum defaultIconExtensions = [".png", ".xpm"];
64 
65 /**
66  * Convenient constant for the default icon theme name.
67  */
68 enum defaultGenericIconTheme = "hicolor";
69 
70 ///
71 deprecated("use defaultGenericIconTheme") alias defaultFallbackIconTheme = defaultGenericIconTheme;
72 
73 /**
74  * Find all icon themes in searchIconDirs.
75  * Note:
76  *  You may want to skip icon themes duplicates if there're different versions of the index.theme file for the same theme.
77  * Returns:
78  *  Range of paths to index.theme files represented icon themes.
79  * Params:
80  *  searchIconDirs = base icon directories to search icon themes.
81  * See_Also: $(D icontheme.paths.baseIconDirs)
82  */
83 auto iconThemePaths(Range)(Range searchIconDirs)
84 if(is(ElementType!Range : string))
85 {
86     return searchIconDirs.map!(function(iconDir) {
87             return iconDir.dirEntriesNothrow(SpanMode.shallow)
88                 .map!(p => buildPath(p, "index.theme")).cache()
89                 .filter!(isFileNothrow);
90         }).joiner;
91 }
92 
93 ///
94 version(iconthemeFileTest) unittest
95 {
96     auto paths = iconThemePaths(["test"]).array;
97     assert(paths.length == 3);
98     assert(paths.canFind(buildPath("test", "NewTango", "index.theme")));
99     assert(paths.canFind(buildPath("test", "Tango", "index.theme")));
100     assert(paths.canFind(buildPath("test", "hicolor", "index.theme")));
101 }
102 
103 /**
104  * Lookup index.theme files by theme name.
105  * Params:
106  *  themeName = Theme name (as the base name of theme subdirectory).
107  *  searchIconDirs = Base icon directories to search icon themes.
108  * Returns:
109  *  Range of paths to index.theme file corresponding to the given theme.
110  * Note:
111  *  Usually you want to use the only first found file.
112  * See_Also: $(D icontheme.paths.baseIconDirs), $(D findIconTheme)
113  */
114 auto lookupIconTheme(Range)(string themeName, Range searchIconDirs)
115 if(is(ElementType!Range : string))
116 {
117     return searchIconDirs
118         .map!(dir => buildPath(dir, themeName, "index.theme")).cache()
119         .filter!(isFileNothrow);
120 }
121 
122 /**
123  * Find index.theme file by theme name.
124  * Returns:
125  *  Path to the first found index.theme file or null string if not found.
126  * Params:
127  *  themeName = Theme name (as the base name of theme subdirectory).
128  *  searchIconDirs = Base icon directories to search icon themes.
129  * Returns:
130  *  Path to the first found index.theme file corresponding to the given theme.
131  * See_Also: $(D icontheme.paths.baseIconDirs), $(D lookupIconTheme)
132  */
133 auto findIconTheme(Range)(string themeName, Range searchIconDirs)
134 {
135     auto paths = lookupIconTheme(themeName, searchIconDirs);
136     if (paths.empty) {
137         return null;
138     } else {
139         return paths.front;
140     }
141 }
142 
143 /**
144  * Find index.theme file for given theme and create instance of $(D icontheme.file.IconThemeFile). The first found file will be used.
145  * Returns: $(D icontheme.file.IconThemeFile) object read from the first found index.theme file corresponding to given theme or null if none were found.
146  * Params:
147  *  themeName = Theme name (as the base name of theme subdirectory).
148  *  searchIconDirs = Base icon directories to search icon themes.
149  *  options = options for $(D icontheme.file.IconThemeFile) reading.
150  * Throws:
151  *  $(B ErrnoException) if file could not be opened.
152  *  $(B IniLikeException) if error occured while reading the file.
153  * See_Also: $(D findIconTheme), $(D icontheme.paths.baseIconDirs)
154  */
155 IconThemeFile openIconTheme(Range)(string themeName,
156                                          Range searchIconDirs,
157                                          IconThemeFile.IconThemeReadOptions options = IconThemeFile.IconThemeReadOptions.init)
158 {
159     auto path = findIconTheme(themeName, searchIconDirs);
160     return path.empty ? null : new IconThemeFile(to!string(path), options);
161 }
162 
163 ///
164 version(iconthemeFileTest) unittest
165 {
166     auto tango = openIconTheme("Tango", ["test"]);
167     assert(tango);
168     assert(tango.displayName() == "Tango");
169 
170     auto hicolor = openIconTheme("hicolor", ["test"]);
171     assert(hicolor);
172     assert(hicolor.displayName() == "Hicolor");
173 
174     assert(openIconTheme("Nonexistent", ["test"]) is null);
175 }
176 
177 /**
178  * Result of icon lookup.
179  */
180 struct IconSearchResult(IconTheme) if (is(IconTheme : const(IconThemeFile)))
181 {
182     /**
183      * File path of found icon.
184      */
185     string filePath;
186     /**
187      * Subdirectory the found icon belongs to.
188      */
189     IconSubDir subdir;
190     /**
191      * $(D icontheme.file.IconThemeFile) the found icon belongs to.
192      */
193     Rebindable!IconTheme iconTheme;
194 }
195 
196 /**
197  * Lookup icon alternatives in icon themes. It uses icon theme cache wherever it's loaded. If searched icon is found in some icon theme all subsequent themes are ignored.
198  *
199  * This function may require many $(B stat) calls, so beware. Use subdirFilter to filter icons by $(D icontheme.file.IconSubDir) properties (e.g. by size or context) to decrease the number of searchable items and allocations. Loading $(D icontheme.cache.IconThemeCache) may also descrease the number of stats.
200  *
201  * Params:
202  *  iconName = Icon name.
203  *  iconThemes = Icon themes to search icon in.
204  *  searchIconDirs = Case icon directories.
205  *  extensions = Possible file extensions of needed icon file, in order of preference.
206  *  sink = Output range accepting $(D IconSearchResult)s.
207  *  reverse = Iterate over icon theme sub-directories in reverse way.
208  *      Usually directories with larger icon size are listed the last,
209  *      so this parameter may speed up the search when looking for the largest icon.
210  * Note: Specification says that extension must be ".png", ".xpm" or ".svg", though SVG is not required to be supported. Some icon themes also contain .svgz images.
211  * Example:
212 ----------
213 lookupIcon!(subdir => subdir.context == "Places" && subdir.size >= 32)(
214     "folder", iconThemes, baseIconDirs(), [".png", ".xpm"],
215     delegate void (IconSearchResult!IconThemeFile item) {
216         writefln("Icon file: %s. Context: %s. Size: %s. Theme: %s", item.filePath, item.subdir.context, item.subdir.size, item.iconTheme.displayName);
217     });
218 ----------
219  * See_Also: $(D icontheme.paths.baseIconDirs), $(D lookupNonThemedIcon)
220  */
221 void lookupIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts, OutputRange)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions, OutputRange sink, Flag!"reverse" reverse = No.reverse)
222 if (isInputRange!(IconThemes) && isForwardRange!(BaseDirs) && isForwardRange!(Exts) &&
223     is(ElementType!IconThemes : const(IconThemeFile)) && is(ElementType!BaseDirs : string) &&
224     is(ElementType!Exts : string) && isOutputRange!(OutputRange, IconSearchResult!(ElementType!IconThemes)))
225 {
226     bool onExtensions(string themeBaseDir, IconSubDir subdir, ElementType!IconThemes iconTheme)
227     {
228         string subdirPath = buildPath(themeBaseDir, subdir.name);
229         if (!subdirPath.isDirNothrow) {
230             return false;
231         }
232         bool found;
233         foreach(extension; extensions) {
234             string path = buildPath(subdirPath, iconName ~ extension);
235             if (path.isFileNothrow) {
236                 found = true;
237                 put(sink, IconSearchResult!(ElementType!IconThemes)(path, subdir, iconTheme));
238             }
239         }
240         return found;
241     }
242 
243     foreach(iconTheme; iconThemes) {
244         if (iconTheme is null || iconTheme.internalName().length == 0) {
245             continue;
246         }
247 
248         string[] themeBaseDirs = searchIconDirs.map!(dir => buildPath(dir, iconTheme.internalName())).filter!(isDirNothrow).array;
249 
250         bool found;
251 
252         auto bySubdir = choose(reverse, iconTheme.bySubdir().retro(), iconTheme.bySubdir());
253         foreach(subdir; bySubdir) {
254             if (!subdirFilter(subdir)) {
255                 continue;
256             }
257             foreach(themeBaseDir; themeBaseDirs) {
258                 if (iconTheme.cache !is null && themeBaseDir == iconTheme.cache.fileName.dirName) {
259                     if (iconTheme.cache.containsIcon(iconName, subdir.name)) {
260                         found = onExtensions(themeBaseDir, subdir, iconTheme) || found;
261                     }
262                 } else {
263                     found = onExtensions(themeBaseDir, subdir, iconTheme) || found;
264                 }
265             }
266         }
267         if (found) {
268             return;
269         }
270     }
271 }
272 
273 /**
274  * Iterate over all icons in icon themes.
275  * iconThemes is usually the range of the main theme and themes it inherits from.
276  * Note: Usually if some icon was found in icon theme, it should be ignored in all subsequent themes, including sizes not presented in former theme.
277  * Use subdirFilter to filter icons by $(D icontheme.file.IconSubDir) thus decreasing the number of searchable items and allocations.
278  * Returns: Range of $(D IconSearchResult).
279  * Params:
280  *  iconThemes = Icon themes to search icon in.
281  *  searchIconDirs = Base icon directories.
282  *  extensions = possible file extensions for icon files.
283  * Example:
284 -------------
285 foreach(item; lookupThemeIcons!(subdir => subdir.context == "MimeTypes" && subdir.size >= 32)(iconThemes, baseIconDirs(), [".png", ".xpm"]))
286 {
287     writefln("Icon file: %s. Context: %s. Size: %s", item.filePath, item.subdir.context, item.subdir.size);
288 }
289 -------------
290  * See_Also: $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D openBaseThemes)
291  */
292 
293 auto lookupThemeIcons(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions)
294 if (is(ElementType!IconThemes : const(IconThemeFile)) && is(ElementType!BaseDirs : string) && is (ElementType!Exts : string))
295 {
296     return iconThemes.filter!(iconTheme => iconTheme !is null).map!(
297         iconTheme => iconTheme.bySubdir().filter!(subdirFilter).map!(
298             subdir => searchIconDirs.map!(
299                 basePath => buildPath(basePath, iconTheme.internalName(), subdir.name)
300             ).map!(
301                 subdirPath => subdirPath.dirEntriesNothrow(SpanMode.shallow).filter!(
302                     filePath => filePath.isFileNothrow && extensions.canFind(filePath.extension)
303                 ).map!(filePath => IconSearchResult!(ElementType!IconThemes)(filePath, subdir, iconTheme))
304             ).joiner
305         ).joiner
306     ).joiner;
307 }
308 
309 /**
310  * Iterate over all icons out of icon themes.
311  * Returns: Range of found icon file paths.
312  * Params:
313  *  searchIconDirs = Base icon directories.
314  *  extensions = Possible file extensions for icon files.
315  * See_Also:
316  *  $(D lookupNonThemedIcon), $(D icontheme.paths.baseIconDirs)
317  */
318 auto lookupNonThemedIcons(BaseDirs, Exts)(BaseDirs searchIconDirs, Exts extensions)
319 if (isInputRange!(BaseDirs) && isForwardRange!(Exts) &&
320     is(ElementType!BaseDirs : string) && is(ElementType!Exts : string))
321 {
322     return searchIconDirs.map!(basePath => basePath.dirEntriesNothrow(SpanMode.shallow).filter!(
323         filePath => filePath.isFileNothrow && extensions.canFind(filePath.extension)
324     )).joiner;
325 }
326 
327 deprecated alias lookupFallbackIcons = lookupNonThemedIcons;
328 
329 /**
330  * Lookup icon alternatives beyond the icon themes. May be used as fallback lookup, if $(D lookupIcon) returned empty range.
331  * Returns: The range of found icon file paths.
332  * Example:
333 ----------
334 auto result = lookupNonThemedIcon("folder", baseIconDirs(), [".png", ".xpm"]);
335 ----------
336  * See_Also: $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D lookupNonThemedIcons)
337  */
338 auto lookupNonThemedIcon(BaseDirs, Exts)(string iconName, BaseDirs searchIconDirs, Exts extensions)
339 if (isInputRange!(BaseDirs) && isForwardRange!(Exts) &&
340     is(ElementType!BaseDirs : string) && is(ElementType!Exts : string))
341 {
342     return searchIconDirs.map!(basePath =>
343         extensions
344             .map!(extension => buildPath(basePath, iconName ~ extension)).cache()
345             .filter!(isFileNothrow)
346     ).joiner;
347 }
348 
349 deprecated alias lookupFallbackIcon = lookupNonThemedIcon;
350 
351 /**
352  * Find icon outside of icon themes. The first found is returned.
353  * See_Also: $(D lookupNonThemedIcon), $(D icontheme.paths.baseIconDirs)
354  */
355 string findNonThemedIcon(BaseDirs, Exts)(string iconName, BaseDirs searchIconDirs, Exts extensions)
356 {
357     auto r = lookupNonThemedIcon(iconName, searchIconDirs, extensions);
358     if (r.empty) {
359         return null;
360     } else {
361         return r.front;
362     }
363 }
364 
365 ///
366 version(iconthemeFileTest) unittest
367 {
368     assert(findNonThemedIcon("pidgin", ["test"], defaultIconExtensions) == buildPath("test", "pidgin.png"));
369     assert(findNonThemedIcon("nonexistent", ["test"], defaultIconExtensions).empty);
370 }
371 
372 deprecated alias findFallbackIcon = findNonThemedIcon;
373 
374 /**
375  * Find icon of the closest size. It uses icon theme cache wherever possible. The first perfect match is used. It searches only for icons in themes.
376  * Params:
377  *  iconName = Name of icon to search as defined by Icon Theme Specification (i.e. without path and extension parts).
378  *  desiredSize = Preferred icon size to get.
379  *  iconThemes = Range of $(D icontheme.file.IconThemeFile) objects.
380  *  searchIconDirs = Base icon directories.
381  *  extensions = Allowed file extensions.
382  * Returns: $(D IconSearchResult). filePath will be empty if icon is not found.
383  * Note: If icon of some size was found in the icon theme, this algorithm does not check following themes, even if they contain icons with closer size. Therefore the icon found in the more preferred theme always has presedence over icons from other themes.
384  * See_Also: $(D findClosestIcon), $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D iconSizeDistance)
385  */
386 auto findClosestThemedIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, uint desiredSize, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions)
387 {
388     uint minDistance = uint.max;
389     IconSearchResult!(ElementType!IconThemes) closest;
390 
391     lookupIcon!(delegate bool(const(IconSubDir) subdir) {
392         return minDistance != 0 && subdirFilter(subdir) && iconSizeDistance(subdir, desiredSize) <= minDistance;
393     })(iconName, iconThemes, searchIconDirs, extensions, delegate void(IconSearchResult!(ElementType!IconThemes) t) {
394         uint distance = iconSizeDistance(t.subdir, desiredSize);
395         if (distance < minDistance) {
396             minDistance = distance;
397             closest = t;
398         }
399     });
400     return closest;
401 }
402 
403 ///
404 version(iconthemeFileTest) unittest
405 {
406     auto baseDirs = ["test"];
407     auto iconThemes = [openIconTheme("Tango", baseDirs), openIconTheme("hicolor", baseDirs)];
408 
409     //exact match
410     auto found = findClosestThemedIcon("folder", 32, iconThemes, baseDirs);
411     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "folder.png"));
412     assert(found.subdir.size == 32);
413     assert(found.subdir.context == "Places");
414     assert(found.iconTheme.internalName == "Tango");
415 
416     found = findClosestThemedIcon("folder", 24, iconThemes, baseDirs);
417     assert(found.filePath == buildPath("test", "Tango", "24x24/devices", "folder.png"));
418     assert(found.subdir.size == 24);
419     assert(found.subdir.context == "Devices");
420 
421     // with subdir filter
422     found = findClosestThemedIcon!(subdir => subdir.context == "Places")("folder", 32, iconThemes, baseDirs);
423     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "folder.png"));
424     assert(found.subdir.size == 32);
425 
426     // non-exact match
427     found = findClosestThemedIcon!(subdir => subdir.context == "Places")("folder", 24, iconThemes, baseDirs);
428     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "folder.png"));
429     assert(found.subdir.size == 32);
430 
431     // no match, wrong subdir
432     found = findClosestThemedIcon!(subdir => subdir.context == "MimeTypes")("folder", 32, iconThemes, baseDirs);
433     assert(found.filePath.empty);
434 
435     //hicolor has exact match, but Tango is more preferred.
436     found = findClosestThemedIcon("folder", 64, iconThemes, baseDirs);
437     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "folder.png"));
438     assert(found.subdir.size == 32);
439 
440     //find xpm
441     found = findClosestThemedIcon("folder", 32, iconThemes, baseDirs, [".xpm"]);
442     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "folder.xpm"));
443     assert(found.subdir.size == 32);
444 
445     //find big png, not exact match
446     found = findClosestThemedIcon("folder", 200, iconThemes, baseDirs);
447     assert(found.filePath == buildPath("test", "Tango", "128x128/places", "folder.png"));
448     assert(found.subdir.size == 128);
449 
450     //svg is closer
451     found = findClosestThemedIcon("folder", 200, iconThemes, baseDirs, [".png", ".svg"]);
452     assert(found.filePath == buildPath("test", "Tango", "scalable/places", "folder.svg"));
453     assert(found.subdir.type == IconSubDir.Type.Scalable);
454 
455     // exact match in hicolor
456     found = findClosestThemedIcon("text-plain", 48, iconThemes, baseDirs);
457     assert(found.filePath == buildPath("test", "hicolor", "48x48/mimetypes", "text-plain.png"));
458     assert(found.subdir.size == 48);
459     assert(found.subdir.context == "MimeTypes");
460     assert(found.iconTheme.internalName == "hicolor");
461 
462     // with subdir filter
463     found = findClosestThemedIcon!(subdir => subdir.context == "MimeTypes")("text-plain", 48, iconThemes, baseDirs);
464     assert(found.filePath == buildPath("test", "hicolor", "48x48/mimetypes", "text-plain.png"));
465     assert(found.subdir.size == 48);
466 
467     // no match
468     found = findClosestThemedIcon!(subdir => subdir.context == "Actions")("text-plain", 48, iconThemes, baseDirs);
469     assert(found.filePath.empty);
470 }
471 
472 /**
473  * ditto, but with predefined extensions.
474  * See_Also: $(D defaultIconExtensions)
475  */
476 auto findClosestThemedIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs)(string iconName, uint size, IconThemes iconThemes, BaseDirs searchIconDirs)
477 {
478     return findClosestThemedIcon!subdirFilter(iconName, size, iconThemes, searchIconDirs, defaultIconExtensions);
479 }
480 
481 /**
482  * Find icon of the closest size. It uses icon theme cache wherever possible. The first perfect match is used.
483  * This is similar to $(D findClosestThemedIcon), but returns file path only and allows to search for non-themed icons.
484  * Params:
485  *  iconName = Name of icon to search as defined by Icon Theme Specification (i.e. without path and extension parts).
486  *  desiredSize = Preferred icon size to get.
487  *  iconThemes = Range of $(D icontheme.file.IconThemeFile) objects.
488  *  searchIconDirs = Base icon directories.
489  *  extensions = Allowed file extensions.
490  *  allowNonThemed = Allow searching for non-themed icon if could not find icon in themes (non-themed icon can be any size).
491  * Returns: Icon file path or empty string if not found.
492  * Note: If icon of some size was found in the icon theme, this algorithm does not check following themes, even if they contain icons with closer size. Therefore the icon found in the more preferred theme always has presedence over icons from other themes.
493  * See_Also: $(D findClosestThemedIcon), $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D findNonThemedIcon), $(D iconSizeDistance)
494  */
495 string findClosestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, uint desiredSize, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions, Flag!"allowNonThemed" allowNonThemed = Yes.allowNonThemed)
496 {
497     string closest = findClosestThemedIcon!subdirFilter(iconName, desiredSize, iconThemes, searchIconDirs, extensions).filePath;
498     if (closest.empty && allowNonThemed) {
499         return findNonThemedIcon(iconName, searchIconDirs, extensions);
500     } else {
501         return closest;
502     }
503 }
504 
505 ///
506 version(iconthemeFileTest) unittest
507 {
508     auto baseDirs = ["test"];
509     auto iconThemes = [openIconTheme("Tango", baseDirs), openIconTheme("hicolor", baseDirs)];
510 
511     string found;
512 
513     //exact match
514     found = findClosestIcon("folder", 32, iconThemes, baseDirs);
515     assert(found == buildPath("test", "Tango", "32x32/places", "folder.png"));
516 
517     // with subdir filter
518     found = findClosestIcon!(subdir => subdir.context == "Places")("folder", 32, iconThemes, baseDirs);
519     assert(found == buildPath("test", "Tango", "32x32/places", "folder.png"));
520 
521     // not exact match
522     found = findClosestIcon!(subdir => subdir.context == "Places")("folder", 24, iconThemes, baseDirs);
523     assert(found == buildPath("test", "Tango", "32x32/places", "folder.png"));
524 
525     // no match, wrong subdir
526     found = findClosestIcon!(subdir => subdir.context == "MimeTypes")("folder", 32, iconThemes, baseDirs);
527     assert(found.empty);
528 
529     //hicolor has exact match, but Tango is more preferred.
530     found = findClosestIcon("folder", 64, iconThemes, baseDirs);
531     assert(found == buildPath("test", "Tango", "32x32/places", "folder.png"));
532 
533     //find xpm
534     found = findClosestIcon("folder", 32, iconThemes, baseDirs, [".xpm"]);
535     assert(found == buildPath("test", "Tango", "32x32/places", "folder.xpm"));
536 
537     //lookup non-themed
538     found = findClosestIcon("pidgin", 96, iconThemes, baseDirs);
539     assert(found == buildPath("test", "pidgin.png"));
540 
541     //don't lookup non-themed
542     found = findClosestIcon("pidgin", 96, iconThemes, baseDirs, defaultIconExtensions, No.allowNonThemed);
543     assert(found.empty);
544 }
545 
546 deprecated string findClosestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, uint desiredSize, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions, Flag!"allowFallbackIcon" allowFallback)
547 {
548     return findClosestIcon!subdirFilter(iconName, desiredSize, iconThemes, searchIconDirs, extensions, cast(Flag!"allowNonThemed")allowFallback);
549 }
550 
551 /**
552  * ditto, but with predefined extensions and non-themed icons allowed.
553  * See_Also: $(D defaultIconExtensions)
554  */
555 string findClosestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs)(string iconName, uint size, IconThemes iconThemes, BaseDirs searchIconDirs)
556 {
557     return findClosestIcon!subdirFilter(iconName, size, iconThemes, searchIconDirs, defaultIconExtensions);
558 }
559 
560 /**
561  * Find icon of the largest size. It uses icon theme cache wherever possible.
562  * Params:
563  *  iconName = Name of icon to search as defined by Icon Theme Specification (i.e. without path and extension parts).
564  *  iconThemes = Range of $(D icontheme.file.IconThemeFile) objects.
565  *  searchIconDirs = Base icon directories.
566  *  extensions = Allowed file extensions.
567  * Returns: $(D IconSearchResult). filePath will be empty if icon is not found.
568  * Note: If icon of some size was found in the icon theme, this algorithm does not check following themes, even if they contain icons with larger size. Therefore the icon found in the most preferred theme always has presedence over icons from other themes.
569  * See_Also: $(D findLargestIcon), $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D findNonThemedIcon)
570  */
571 auto findLargestThemedIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions)
572 {
573     uint max = 0;
574     IconSearchResult!(ElementType!IconThemes) largest;
575 
576     lookupIcon!(delegate bool(const(IconSubDir) subdir) {
577         return subdirFilter(subdir) && subdir.size() >= max;
578     })(iconName, iconThemes, searchIconDirs, extensions, delegate void(IconSearchResult!(ElementType!IconThemes) t) {
579         if (t.subdir.size() > max) {
580             max = t.subdir.size();
581             largest = t;
582         }
583     }, Yes.reverse);
584 
585     return largest;
586 }
587 
588 ///
589 version(iconthemeFileTest) unittest
590 {
591     auto baseDirs = ["test"];
592     auto iconThemes = [openIconTheme("Tango", baseDirs), openIconTheme("hicolor", baseDirs)];
593 
594     auto found = findLargestThemedIcon("folder", iconThemes, baseDirs);
595     assert(found.filePath == buildPath("test", "Tango", "128x128/places", "folder.png"));
596     assert(found.subdir.size == 128);
597     assert(found.subdir.context == "Places");
598     assert(found.iconTheme.internalName == "Tango");
599 
600     found = findLargestThemedIcon!(subdir => subdir.context == "Places")("folder", iconThemes, baseDirs);
601     assert(found.filePath == buildPath("test", "Tango", "128x128/places", "folder.png"));
602 
603     found = findLargestThemedIcon!(subdir => subdir.context == "Actions")("folder", iconThemes, baseDirs);
604     assert(found.filePath.empty);
605 
606     found = findLargestThemedIcon("desktop", iconThemes, baseDirs);
607     assert(found.filePath == buildPath("test", "Tango", "32x32/places", "desktop.png"));
608     assert(found.subdir.size == 32);
609 
610     found = findLargestThemedIcon("desktop", iconThemes, baseDirs, [".svg", ".png"]);
611     assert(found.filePath == buildPath("test", "Tango", "scalable/places", "desktop.svg"));
612     assert(found.subdir.type == IconSubDir.Type.Scalable);
613 }
614 
615 /**
616  * ditto, but with predefined extensions.
617  * See_Also: $(D defaultIconExtensions)
618  */
619 auto findLargestThemedIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs)
620 {
621     return findLargestThemedIcon!subdirFilter(iconName, iconThemes, searchIconDirs, defaultIconExtensions);
622 }
623 
624 /**
625  * Find icon of the largest size. It uses icon theme cache wherever possible.
626  * This is similar to $(D findLargestThemedIcon), but returns file path only and allows to search for non-themed icons.
627  * Params:
628  *  iconName = Name of icon to search as defined by Icon Theme Specification (i.e. without path and extension parts).
629  *  iconThemes = Range of $(D icontheme.file.IconThemeFile) objects.
630  *  searchIconDirs = Base icon directories.
631  *  extensions = Allowed file extensions.
632  *  allowNonThemed = Allow searching for non-themed fallback if could not find icon in themes.
633  * Returns: Icon file path or empty string if not found.
634  * Note: If icon of some size was found in the icon theme, this algorithm does not check following themes, even if they contain icons with larger size. Therefore the icon found in the most preferred theme always has presedence over icons from other themes.
635  * See_Also: $(D findLargestThemedIcon), $(D icontheme.paths.baseIconDirs), $(D lookupIcon), $(D findNonThemedIcon)
636  */
637 string findLargestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions, Flag!"allowNonThemed" allowNonThemed = Yes.allowNonThemed)
638 {
639     string largest = findLargestThemedIcon!subdirFilter(iconName, iconThemes, searchIconDirs, extensions).filePath;
640 
641     if (largest.empty && allowNonThemed) {
642         return findNonThemedIcon(iconName, searchIconDirs, extensions);
643     } else {
644         return largest;
645     }
646 }
647 
648 ///
649 version(iconthemeFileTest) unittest
650 {
651     auto baseDirs = ["test"];
652     auto iconThemes = [openIconTheme("Tango", baseDirs), openIconTheme("hicolor", baseDirs)];
653 
654     string found;
655 
656     found = findLargestIcon("folder", iconThemes, baseDirs);
657     assert(found == buildPath("test", "Tango", "128x128/places", "folder.png"));
658 
659     found = findLargestIcon!(subdir => subdir.context == "Places")("folder", iconThemes, baseDirs);
660     assert(found == buildPath("test", "Tango", "128x128/places", "folder.png"));
661 
662     found = findLargestIcon!(subdir => subdir.context == "Actions")("folder", iconThemes, baseDirs);
663     assert(found.empty);
664 
665     found = findLargestIcon("desktop", iconThemes, baseDirs);
666     assert(found == buildPath("test", "Tango", "32x32/places", "desktop.png"));
667 
668     found = findLargestIcon("desktop", iconThemes, baseDirs, [".svg", ".png"]);
669     assert(found == buildPath("test", "Tango", "scalable/places", "desktop.svg"));
670 
671     //lookup non-themed
672     found = findLargestIcon("pidgin", iconThemes, baseDirs);
673     assert(found == buildPath("test", "pidgin.png"));
674 
675     //don't lookup non-themed
676     found = findLargestIcon("pidgin", iconThemes, baseDirs, defaultIconExtensions, No.allowNonThemed);
677     assert(found.empty);
678 }
679 
680 deprecated string findLargestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs, Exts)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs, Exts extensions, Flag!"allowFallbackIcon" allowFallback)
681 {
682     return findLargestIcon!subdirFilter(iconName, iconThemes, searchIconDirs, extensions, cast(Flag!"allowNonThemed")allowFallback);
683 }
684 
685 /**
686  * ditto, but with predefined extensions and non-themed icons allowed.
687  * See_Also: $(D defaultIconExtensions)
688  */
689 string findLargestIcon(alias subdirFilter = (a => true), IconThemes, BaseDirs)(string iconName, IconThemes iconThemes, BaseDirs searchIconDirs)
690 {
691     return findLargestIcon!subdirFilter(iconName, iconThemes, searchIconDirs, defaultIconExtensions);
692 }
693 
694 /**
695  * Distance between desired size and minimum or maximum size value supported by icon theme subdirectory.
696  */
697 @nogc @safe uint iconSizeDistance(in IconSubDir subdir, uint matchSize) nothrow pure
698 {
699     const uint size = subdir.size();
700     const uint minSize = subdir.minSize();
701     const uint maxSize = subdir.maxSize();
702     const uint threshold = subdir.threshold();
703 
704     final switch(subdir.type()) {
705         case IconSubDir.Type.Fixed:
706         {
707             if (size > matchSize) {
708                 return size - matchSize;
709             } else if (size < matchSize) {
710                 return matchSize - size;
711             } else {
712                 return 0;
713             }
714         }
715         case IconSubDir.Type.Scalable:
716         {
717             if (matchSize < minSize) {
718                 return minSize - matchSize;
719             } else if (matchSize > maxSize) {
720                 return matchSize - maxSize;
721             } else {
722                 return 0;
723             }
724         }
725         case IconSubDir.Type.Threshold:
726         {
727             if (matchSize < size - threshold) {
728                 return (size - threshold) - matchSize;
729             } else if (matchSize > size + threshold) {
730                 return matchSize - (size + threshold);
731             } else {
732                 return 0;
733             }
734         }
735     }
736 }
737 
738 ///
739 unittest
740 {
741     auto fixed = IconSubDir(32, IconSubDir.Type.Fixed);
742     assert(iconSizeDistance(fixed, fixed.size()) == 0);
743     assert(iconSizeDistance(fixed, 30) == 2);
744     assert(iconSizeDistance(fixed, 35) == 3);
745 
746     auto threshold = IconSubDir(32, IconSubDir.Type.Threshold, "", 0, 0, 5);
747     assert(iconSizeDistance(threshold, threshold.size()) == 0);
748     assert(iconSizeDistance(threshold, threshold.size() - threshold.threshold()) == 0);
749     assert(iconSizeDistance(threshold, threshold.size() + threshold.threshold()) == 0);
750     assert(iconSizeDistance(threshold, 26) == 1);
751     assert(iconSizeDistance(threshold, 39) == 2);
752 
753     auto scalable = IconSubDir(32, IconSubDir.Type.Scalable, "", 24, 48);
754     assert(iconSizeDistance(scalable, scalable.size()) == 0);
755     assert(iconSizeDistance(scalable, scalable.minSize()) == 0);
756     assert(iconSizeDistance(scalable, scalable.maxSize()) == 0);
757     assert(iconSizeDistance(scalable, 20) == 4);
758     assert(iconSizeDistance(scalable, 50) == 2);
759 }
760 
761 /**
762  * Check if matchSize belongs to subdir's size range.
763  */
764 @nogc @safe bool matchIconSize(in IconSubDir subdir, uint matchSize) nothrow pure
765 {
766     const uint size = subdir.size();
767     const uint minSize = subdir.minSize();
768     const uint maxSize = subdir.maxSize();
769     const uint threshold = subdir.threshold();
770 
771     final switch(subdir.type()) {
772         case IconSubDir.Type.Fixed:
773             return size == matchSize;
774         case IconSubDir.Type.Threshold:
775             return matchSize <= (size + threshold) && matchSize >= (size - threshold);
776         case IconSubDir.Type.Scalable:
777             return matchSize >= minSize && matchSize <= maxSize;
778     }
779 }
780 
781 ///
782 unittest
783 {
784     auto fixed = IconSubDir(32, IconSubDir.Type.Fixed);
785     assert(matchIconSize(fixed, fixed.size()));
786     assert(!matchIconSize(fixed, fixed.size() - 2));
787 
788     auto threshold = IconSubDir(32, IconSubDir.Type.Threshold, "", 0, 0, 5);
789     assert(matchIconSize(threshold, threshold.size() + threshold.threshold()));
790     assert(matchIconSize(threshold, threshold.size() - threshold.threshold()));
791     assert(!matchIconSize(threshold, threshold.size() + threshold.threshold() + 1));
792     assert(!matchIconSize(threshold, threshold.size() - threshold.threshold() - 1));
793 
794     auto scalable = IconSubDir(32, IconSubDir.Type.Scalable, "", 24, 48);
795     assert(matchIconSize(scalable, scalable.minSize()));
796     assert(matchIconSize(scalable, scalable.maxSize()));
797     assert(!matchIconSize(scalable, scalable.minSize() - 1));
798     assert(!matchIconSize(scalable, scalable.maxSize() + 1));
799 }
800 
801 /**
802  * Find icon closest to the given size among given alternatives.
803  * Params:
804  *  alternatives = Range of $(D IconSearchResult)s, usually returned by $(D lookupIcon).
805  *  matchSize = Desired size of icon.
806  */
807 string matchBestIcon(Range)(Range alternatives, uint matchSize)
808 {
809     uint minDistance = uint.max;
810     string closest;
811 
812     foreach(t; alternatives) {
813         auto path = t[0];
814         auto subdir = t[1];
815         uint distance = iconSizeDistance(subdir, matchSize);
816         if (distance < minDistance) {
817             minDistance = distance;
818             closest = path;
819         }
820         if (minDistance == 0) {
821             return closest;
822         }
823     }
824 
825     return closest;
826 }
827 
828 private void openBaseThemesHelper(Range)(ref IconThemeFile[] themes, IconThemeFile iconTheme,
829                                       Range searchIconDirs,
830                                       IconThemeFile.IconThemeReadOptions options)
831 {
832     foreach(name; iconTheme.inherits()) {
833         if (!themes.canFind!(function(theme, name) {
834             return theme.internalName == name;
835         })(name)) {
836             try {
837                 IconThemeFile f = openIconTheme(name, searchIconDirs, options);
838                 if (f) {
839                     themes ~= f;
840                     openBaseThemesHelper(themes, f, searchIconDirs, options);
841                 }
842             } catch(Exception e) {
843 
844             }
845         }
846     }
847 }
848 
849 /**
850  * Recursively find all themes the given theme is inherited from.
851  * Params:
852  *  iconTheme = Original icon theme to search for its base themes. It's NOT included in the resulting array. Must be not null.
853  *  searchIconDirs = Base icon directories to search icon themes.
854  *  genericThemeName = Name of icon theme which is loaded the last even if it's not specified in inheritance tree.
855  *      Pass empty string to avoid it. It's NOT loaded twice if some theme in inheritance tree has it as base theme.
856  *      Usually you don't need to change this parameter since $(D hicolor) is required to be used by specification.
857  *  options = Options for $(D icontheme.file.IconThemeFile) reading.
858  * Returns:
859  *  Array of unique $(D icontheme.file.IconThemeFile) objects represented base themes.
860  * See_Also:
861  *  $(D openThemeFamily)
862  */
863 IconThemeFile[] openBaseThemes(Range)(IconThemeFile iconTheme,
864                                       Range searchIconDirs,
865                                       string genericThemeName = defaultGenericIconTheme,
866                                       IconThemeFile.IconThemeReadOptions options = IconThemeFile.IconThemeReadOptions.init)
867 if(isForwardRange!Range && is(ElementType!Range : string))
868 {
869     IconThemeFile[] themes;
870     openBaseThemesHelper(themes, iconTheme, searchIconDirs, options);
871 
872     if (genericThemeName.length) {
873         auto genericFound = themes.filter!(theme => theme !is null).find!(theme => theme.internalName == genericThemeName);
874         if (genericFound.empty) {
875             IconThemeFile genericTheme;
876             collectException(openIconTheme(genericThemeName, searchIconDirs, options), genericTheme);
877             if (genericTheme) {
878                 themes ~= genericTheme;
879             }
880         }
881     }
882 
883     return themes;
884 }
885 
886 ///
887 version(iconthemeFileTest) unittest
888 {
889     auto tango = openIconTheme("NewTango", ["test"]);
890     auto baseThemes = openBaseThemes(tango, ["test"]);
891 
892     assert(baseThemes.length == 2);
893     assert(baseThemes[0].internalName() == "Tango");
894     assert(baseThemes[1].internalName() == "hicolor");
895 
896     baseThemes = openBaseThemes(tango, ["test"], null);
897     assert(baseThemes.length == 1);
898     assert(baseThemes[0].internalName() == "Tango");
899 }
900 
901 /**
902  * Recursively find all themes the given theme is inherited from.
903  * Params:
904  *  iconTheme = Original icon theme to search for its base themes. Included as first element in the resulting array. Must be not null.
905  *  searchIconDirs = Base icon directories to search icon themes.
906  *  genericThemeName = Name of icon theme which is loaded the last even if it's not specified in inheritance tree.
907  *      Pass empty string to avoid it. It's NOT loaded twice if some theme in inheritance tree has it as base theme.
908  *      Usually you don't need to change this parameter since $(D hicolor) is required to be used by specification.
909  *  options = Options for $(D icontheme.file.IconThemeFile) reading.
910  * Returns:
911  *  Array of unique $(D icontheme.file.IconThemeFile) objects that represent the provided icon theme and its base themes.
912  * See_Also:
913  *  $(D openBaseThemes)
914  */
915 IconThemeFile[] openThemeFamily(Range)(IconThemeFile iconTheme,
916                                       Range searchIconDirs,
917                                       string genericThemeName = defaultGenericIconTheme,
918                                       IconThemeFile.IconThemeReadOptions options = IconThemeFile.IconThemeReadOptions.init)
919 if(isForwardRange!Range && is(ElementType!Range : string))
920 {
921     IconThemeFile[] toReturn;
922     toReturn ~= iconTheme;
923     toReturn ~= openBaseThemes(iconTheme, searchIconDirs, genericThemeName, options);
924     return toReturn;
925 }
926 
927 /**
928  * ditto, but firstly loads the given icon theme by name. Returns an empty array if theme specified by $(D iconThemeName) could not be loaded.
929  */
930 IconThemeFile[] openThemeFamily(Range)(string iconThemeName,
931                                       Range searchIconDirs,
932                                       string genericThemeName = defaultGenericIconTheme,
933                                       IconThemeFile.IconThemeReadOptions options = IconThemeFile.IconThemeReadOptions.init)
934 if(isForwardRange!Range && is(ElementType!Range : string))
935 {
936     auto iconTheme = openIconTheme(iconThemeName, searchIconDirs, options);
937     if (iconTheme) {
938         return openThemeFamily(iconTheme, searchIconDirs, genericThemeName, options);
939     }
940     return typeof(return).init;
941 }
942 
943 ///
944 version(iconthemeFileTest) unittest
945 {
946     auto iconThemes = openThemeFamily("NewTango", ["test"]);
947 
948     assert(iconThemes.length == 3);
949     assert(iconThemes[0].internalName() == "NewTango");
950     assert(iconThemes[1].internalName() == "Tango");
951     assert(iconThemes[2].internalName() == "hicolor");
952 
953     iconThemes = openThemeFamily("NewTango", ["test"], null);
954     assert(iconThemes.length == 2);
955     assert(iconThemes[0].internalName() == "NewTango");
956     assert(iconThemes[1].internalName() == "Tango");
957 }