1 /** 2 * This module provides class for reading and accessing icon theme descriptions. 3 * 4 * Information about icon themes is stored in special files named index.theme and located in icon theme directory. 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.file; 17 18 package 19 { 20 import std.algorithm; 21 import std.array; 22 import std.conv; 23 import std.exception; 24 import std.path; 25 import std.range; 26 import std.string; 27 import std.traits; 28 import std.typecons; 29 } 30 31 import icontheme.cache; 32 33 public import inilike.file; 34 import inilike.common; 35 36 /** 37 * Adapter of $(D inilike.file.IniLikeGroup) for easy access to icon subdirectory properties. 38 */ 39 struct IconSubDir 40 { 41 ///The type of icon sizes for the icons in the directory. 42 enum Type { 43 ///Icons can be used if the size differs at some threshold from the desired size. 44 Threshold, 45 ///Icons can be used if the size does not differ from desired. 46 Fixed, 47 ///Icons are scalable without visible quality loss. 48 Scalable 49 } 50 51 @safe this(const(IniLikeGroup) group) nothrow { 52 collectException(group.escapedValue("Size").to!uint, _size); 53 collectException(group.escapedValue("MinSize").to!uint, _minSize); 54 collectException(group.escapedValue("MaxSize").to!uint, _maxSize); 55 56 if (_minSize == 0) { 57 _minSize = _size; 58 } 59 60 if (_maxSize == 0) { 61 _maxSize = _size; 62 } 63 64 collectException(group.escapedValue("Threshold").to!uint, _threshold); 65 if (_threshold == 0) { 66 _threshold = 2; 67 } 68 69 _type = Type.Threshold; 70 71 string t = group.escapedValue("Type"); 72 if (t.length) { 73 if (t == "Fixed") { 74 _type = Type.Fixed; 75 } else if (t == "Scalable") { 76 _type = Type.Scalable; 77 } 78 } 79 80 _context = group.escapedValue("Context"); 81 _name = group.groupName(); 82 } 83 84 @safe this(uint size, Type type = Type.Threshold, string context = null, uint minSize = 0, uint maxSize = 0, uint threshold = 2) nothrow pure 85 { 86 _size = size; 87 _context = context; 88 _type = type; 89 _minSize = minSize ? minSize : size; 90 _maxSize = maxSize ? maxSize : size; 91 _threshold = threshold; 92 } 93 94 /** 95 * The name of section in icon theme file and relative path to icons. 96 */ 97 @nogc @safe string name() const nothrow pure { 98 return _name; 99 } 100 101 /** 102 * Nominal size of the icons in this directory. 103 * Returns: The value associated with "Size" key converted to an unsigned integer, or 0 if the value is not present or not a number. 104 */ 105 @nogc @safe uint size() const nothrow pure { 106 return _size; 107 } 108 109 /** 110 * The context the icon is normally used in. 111 * Returns: The value associated with "Context" key. 112 */ 113 @nogc @safe string context() const nothrow pure { 114 return _context; 115 } 116 117 /** 118 * The type of icon sizes for the icons in this directory. 119 * Returns: The value associated with "Type" key or Type.Threshold if not specified. 120 */ 121 @nogc @safe Type type() const nothrow pure { 122 return _type; 123 } 124 125 /** 126 * The maximum size that the icons in this directory can be scaled to. Defaults to the value of Size if not present. 127 * Returns: The value associated with "MaxSize" key converted to an unsigned integer, or size() if the value is not present or not a number. 128 * See_Also: $(D size), $(D minSize) 129 */ 130 @nogc @safe uint maxSize() const nothrow pure { 131 return _maxSize; 132 } 133 134 /** 135 * The minimum size that the icons in this directory can be scaled to. Defaults to the value of Size if not present. 136 * Returns: The value associated with "MinSize" key converted to an unsigned integer, or size() if the value is not present or not a number. 137 * See_Also: $(D size), $(D maxSize) 138 */ 139 @nogc @safe uint minSize() const nothrow pure { 140 return _minSize; 141 } 142 143 /** 144 * The icons in this directory can be used if the size differ at most this much from the desired size. Defaults to 2 if not present. 145 * Returns: The value associated with "Threshold" key, or 2 if the value is not present or not a number. 146 */ 147 @nogc @safe uint threshold() const nothrow pure { 148 return _threshold; 149 } 150 private: 151 uint _size; 152 uint _minSize; 153 uint _maxSize; 154 uint _threshold; 155 Type _type; 156 string _context; 157 string _name; 158 } 159 160 final class IconThemeGroup : IniLikeGroup 161 { 162 protected @nogc @safe this() nothrow { 163 super("Icon Theme"); 164 } 165 166 /** 167 * Short name of the icon theme, used in e.g. lists when selecting themes. 168 * Returns: The value associated with "Name" key. 169 * See_Also: $(D IconThemeFile.internalName), $(D localizedDisplayName) 170 */ 171 @safe string displayName() const nothrow pure { 172 return unescapedValue("Name"); 173 } 174 /** 175 * Set "Name" to name escaping the value if needed. 176 */ 177 @safe string displayName(string name) { 178 return setUnescapedValue("Name", name); 179 } 180 181 ///Returns: Localized name of icon theme. 182 @safe string localizedDisplayName(string locale) const nothrow pure { 183 return unescapedValue("Name", locale); 184 } 185 186 /** 187 * Longer string describing the theme. 188 * Returns: The value associated with "Comment" key. 189 */ 190 @safe string comment() const nothrow pure { 191 return unescapedValue("Comment"); 192 } 193 /** 194 * Set "Comment" to commentary escaping the value if needed. 195 */ 196 @safe string comment(string commentary) { 197 return setUnescapedValue("Comment", commentary); 198 } 199 200 ///Returns: Localized comment. 201 @safe string localizedComment(string locale) const nothrow pure { 202 return unescapedValue("Comment", locale); 203 } 204 205 /** 206 * Whether to hide the theme in a theme selection user interface. 207 * Returns: The value associated with "Hidden" key converted to bool using $(D inilike.common.isTrue). 208 */ 209 @nogc @safe bool hidden() const nothrow pure { 210 return isTrue(escapedValue("Hidden")); 211 } 212 ///setter 213 @safe bool hidden(bool hide) { 214 setEscapedValue("Hidden", boolToString(hide)); 215 return hide; 216 } 217 218 /** 219 * The name of an icon that should be used as an example of how this theme looks. 220 * Returns: The value associated with "Example" key. 221 */ 222 @safe string example() const nothrow pure { 223 return unescapedValue("Example"); 224 } 225 /** 226 * Set "Example" to example escaping the value if needed. 227 */ 228 @safe string example(string example) { 229 return setUnescapedValue("Example", example); 230 } 231 232 /** 233 * List of subdirectories for this theme. 234 * Returns: The range of values associated with "Directories" key. 235 */ 236 @safe auto directories() const { 237 return IconThemeFile.splitValues(unescapedValue("Directories")); 238 } 239 ///setter 240 string directories(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 241 return setUnescapedValue("Directories", IconThemeFile.joinValues(values)); 242 } 243 244 /** 245 * Names of themes that this theme inherits from. 246 * Returns: The range of values associated with "Inherits" key. 247 * Note: It does NOT automatically adds hicolor theme if it's missing. 248 */ 249 @safe auto inherits() const { 250 return IconThemeFile.splitValues(unescapedValue("Inherits")); 251 } 252 ///setter 253 string inherits(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 254 return setUnescapedValue("Inherits", IconThemeFile.joinValues(values)); 255 } 256 257 protected: 258 @trusted override void validateKey(string key, string value) const { 259 if (!isValidDesktopFileKey(key)) { 260 throw new IniLikeEntryException("key is invalid", groupName(), key, value); 261 } 262 } 263 } 264 265 /** 266 * Class representation of index.theme file containing an icon theme description. 267 */ 268 final class IconThemeFile : IniLikeFile 269 { 270 /** 271 * Policy about reading extension groups (those start with 'X-'). 272 */ 273 enum ExtensionGroupPolicy : ubyte { 274 skip, ///Don't save extension groups. 275 preserve ///Save extension groups. 276 } 277 278 /** 279 * Policy about reading groups with names which meaning is unknown, i.e. it's not extension nor relative directory path. 280 */ 281 enum UnknownGroupPolicy : ubyte { 282 skip, ///Don't save unknown groups. 283 preserve, ///Save unknown groups. 284 throwError ///Throw error when unknown group is encountered. 285 } 286 287 ///Options to manage icon theme file reading 288 static struct IconThemeReadOptions 289 { 290 ///Base $(D inilike.file.IniLikeFile.ReadOptions). 291 IniLikeFile.ReadOptions baseOptions = IniLikeFile.ReadOptions(IniLikeFile.DuplicateGroupPolicy.skip); 292 293 alias baseOptions this; 294 295 /** 296 * Set policy about unknown groups. By default they are skipped without errors. 297 * Note that all groups still need to be preserved if desktop file must be rewritten. 298 */ 299 UnknownGroupPolicy unknownGroupPolicy = UnknownGroupPolicy.skip; 300 301 /** 302 * Set policy about extension groups. By default they are all preserved. 303 * Set it to skip if you're not willing to support any extensions in your applications. 304 * Note that all groups still need to be preserved if desktop file must be rewritten. 305 */ 306 ExtensionGroupPolicy extensionGroupPolicy = ExtensionGroupPolicy.preserve; 307 308 ///Setting parameters in any order, leaving not mentioned ones in default state. 309 @nogc @safe this(Args...)(Args args) nothrow pure { 310 foreach(arg; args) { 311 alias Unqual!(typeof(arg)) ArgType; 312 static if (is(ArgType == IniLikeFile.ReadOptions)) { 313 baseOptions = arg; 314 } else static if (is(ArgType == UnknownGroupPolicy)) { 315 unknownGroupPolicy = arg; 316 } else static if (is(ArgType == ExtensionGroupPolicy)) { 317 extensionGroupPolicy = arg; 318 } else { 319 baseOptions.assign(arg); 320 } 321 } 322 } 323 324 /// 325 unittest 326 { 327 IconThemeReadOptions options; 328 329 options = IconThemeReadOptions( 330 ExtensionGroupPolicy.skip, 331 UnknownGroupPolicy.preserve, 332 DuplicateKeyPolicy.skip, 333 DuplicateGroupPolicy.preserve, 334 No.preserveComments 335 ); 336 337 assert(options.unknownGroupPolicy == UnknownGroupPolicy.preserve); 338 assert(options.extensionGroupPolicy == ExtensionGroupPolicy.skip); 339 assert(options.duplicateGroupPolicy == DuplicateGroupPolicy.preserve); 340 assert(options.duplicateKeyPolicy == DuplicateKeyPolicy.skip); 341 assert(!options.preserveComments); 342 } 343 } 344 345 /// 346 unittest 347 { 348 string contents = 349 `[Icon Theme] 350 Name=Theme 351 [X-SomeGroup] 352 Key=Value`; 353 354 alias IconThemeFile.IconThemeReadOptions IconThemeReadOptions; 355 356 auto iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(ExtensionGroupPolicy.skip)); 357 assert(iconTheme.group("X-SomeGroup") is null); 358 359 contents = 360 `[Icon Theme] 361 Name=Theme 362 [/invalid group] 363 $=StrangeKey`; 364 365 iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.preserve, IniLikeGroup.InvalidKeyPolicy.save)); 366 assert(iconTheme.group("/invalid group") !is null); 367 assert(iconTheme.group("/invalid group").escapedValue("$") == "StrangeKey"); 368 369 contents = 370 `[X-SomeGroup] 371 Key=Value`; 372 373 auto thrown = collectException!IniLikeReadException(new IconThemeFile(iniLikeStringReader(contents))); 374 assert(thrown !is null); 375 assert(thrown.lineNumber == 0); 376 377 contents = 378 `[Icon Theme] 379 Valid=Key 380 $=Invalid`; 381 382 assertThrown(new IconThemeFile(iniLikeStringReader(contents))); 383 assertNotThrown(new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(IniLikeGroup.InvalidKeyPolicy.skip))); 384 385 contents = 386 `[Icon Theme] 387 Name=Name 388 [/invalidpath] 389 Key=Value`; 390 391 assertThrown(new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.throwError))); 392 assertNotThrown(iconTheme = new IconThemeFile(iniLikeStringReader(contents), IconThemeReadOptions(UnknownGroupPolicy.preserve))); 393 assert(iconTheme.cachePath().empty); 394 assert(iconTheme.group("/invalidpath") !is null); 395 } 396 397 protected: 398 @trusted static bool isDirectoryName(string groupName) 399 { 400 return groupName.pathSplitter.all!isValidFilename; 401 } 402 403 @trusted override IniLikeGroup createGroupByName(string groupName) { 404 if (groupName == "Icon Theme") { 405 _iconTheme = new IconThemeGroup(); 406 return _iconTheme; 407 } else if (groupName.startsWith("X-")) { 408 if (_options.extensionGroupPolicy == ExtensionGroupPolicy.skip) { 409 return null; 410 } else { 411 return createEmptyGroup(groupName); 412 } 413 } else if (isDirectoryName(groupName)) { 414 return createEmptyGroup(groupName); 415 } else { 416 final switch(_options.unknownGroupPolicy) { 417 case UnknownGroupPolicy.skip: 418 return null; 419 case UnknownGroupPolicy.preserve: 420 return createEmptyGroup(groupName); 421 case UnknownGroupPolicy.throwError: 422 throw new IniLikeException("Invalid group name: '" ~ groupName ~ "'. Must be valid relative path or start with 'X-'"); 423 } 424 } 425 } 426 public: 427 /** 428 * Reads icon theme from file. 429 * Throws: 430 * $(B ErrnoException) if file could not be opened. 431 * $(D inilike.file.IniLikeReadException) if error occured while reading the file. 432 */ 433 @trusted this(string fileName, IconThemeReadOptions options = IconThemeReadOptions.init) { 434 this(iniLikeFileReader(fileName), options, fileName); 435 } 436 437 /** 438 * Reads icon theme file from range of IniLikeReader, e.g. acquired from iniLikeFileReader or iniLikeStringReader. 439 * Throws: 440 * $(D inilike.file.IniLikeReadException) if error occured while parsing. 441 */ 442 this(IniLikeReader)(IniLikeReader reader, IconThemeReadOptions options = IconThemeReadOptions.init, string fileName = null) 443 { 444 _options = options; 445 super(reader, fileName, options.baseOptions); 446 enforce(_iconTheme !is null, new IniLikeReadException("No \"Icon Theme\" group", 0)); 447 } 448 449 ///ditto 450 this(IniLikeReader)(IniLikeReader reader, string fileName, IconThemeReadOptions options = IconThemeReadOptions.init) 451 { 452 this(reader, options, fileName); 453 } 454 455 /** 456 * Constructs IconThemeFile with empty "Icon Theme" group. 457 */ 458 @safe this() { 459 super(); 460 _iconTheme = new IconThemeGroup(); 461 } 462 463 /// 464 unittest 465 { 466 auto itf = new IconThemeFile(); 467 assert(itf.iconTheme()); 468 assert(itf.directories().empty); 469 } 470 471 /** 472 * Removes group by name. This function will not remove "Icon Theme" group. 473 */ 474 @safe override bool removeGroup(string groupName) nothrow { 475 if (groupName != "Icon Theme") { 476 return super.removeGroup(groupName); 477 } 478 return false; 479 } 480 481 /** 482 * The name of the subdirectory index.theme was loaded from. 483 * See_Also: $(D IconThemeGroup.displayName) 484 */ 485 @trusted string internalName() const { 486 return fileName().absolutePath().dirName().baseName(); 487 } 488 489 /** 490 * Some keys can have multiple values, separated by comma. This function helps to parse such kind of strings into the range. 491 * Returns: The range of multiple nonempty values. 492 * See_Also: $(D joinValues) 493 */ 494 @trusted static auto splitValues(string values) { 495 return std.algorithm.splitter(values, ',').filter!(s => s.length != 0); 496 } 497 498 /// 499 unittest 500 { 501 assert(equal(IconThemeFile.splitValues("16x16/actions,16x16/animations,16x16/apps"), ["16x16/actions", "16x16/animations", "16x16/apps"])); 502 assert(IconThemeFile.splitValues(",").empty); 503 assert(IconThemeFile.splitValues("").empty); 504 } 505 506 /** 507 * Join range of multiple values into a string using comma as separator. 508 * If range is empty, then the empty string is returned. 509 * See_Also: $(D splitValues) 510 */ 511 static string joinValues(Range)(Range values) if (isInputRange!Range && isSomeString!(ElementType!Range)) { 512 auto result = values.filter!( s => s.length != 0 ).joiner(","); 513 if (result.empty) { 514 return string.init; 515 } else { 516 return text(result); 517 } 518 } 519 520 /// 521 unittest 522 { 523 assert(equal(IconThemeFile.joinValues(["16x16/actions", "16x16/animations", "16x16/apps"]), "16x16/actions,16x16/animations,16x16/apps")); 524 assert(IconThemeFile.joinValues([""]).empty); 525 } 526 527 /** 528 * Iterating over subdirectories of icon theme. 529 * See_Also: $(D IconThemeGroup.directories) 530 */ 531 @trusted auto bySubdir() const { 532 return directories().filter!(dir => group(dir) !is null).map!(dir => IconSubDir(group(dir))).array; 533 } 534 535 /** 536 * Icon Theme group in underlying file. 537 * Returns: Instance of "Icon Theme" group. 538 * Note: Usually you don't need to call this function since you can rely on alias this. 539 */ 540 @nogc @safe inout(IconThemeGroup) iconTheme() nothrow inout { 541 return _iconTheme; 542 } 543 544 /** 545 * This alias allows to call functions related to "Icon Theme" group without need to call iconTheme explicitly. 546 */ 547 alias iconTheme this; 548 549 /** 550 * Try to load icon cache. Loaded icon cache will be used on icon lookup. 551 * Returns: Loaded $(D icontheme.cache.IconThemeCache) object or null, if cache does not exist or invalid or outdated. 552 * Note: This function expects that icon theme has fileName. 553 * See_Also: $(D icontheme.cache.IconThemeCache), $(D icontheme.lookup.lookupIcon), $(D cache), $(D unloadCache), $(D cachePath) 554 */ 555 @trusted auto tryLoadCache(Flag!"allowOutdated" allowOutdated = Flag!"allowOutdated".no) nothrow 556 { 557 string path = cachePath(); 558 559 bool isOutdated = true; 560 collectException(IconThemeCache.isOutdated(path), isOutdated); 561 562 if (isOutdated && !allowOutdated) { 563 return null; 564 } 565 566 IconThemeCache myCache; 567 collectException(new IconThemeCache(path), myCache); 568 569 if (myCache !is null) { 570 _cache = myCache; 571 } 572 return myCache; 573 } 574 575 /** 576 * Unset loaded cache. 577 */ 578 @nogc @safe void unloadCache() nothrow { 579 _cache = null; 580 } 581 582 /** 583 * Set cache object. 584 * See_Also: $(D tryLoadCache) 585 */ 586 @nogc @safe IconThemeCache cache(IconThemeCache setCache) nothrow { 587 _cache = setCache; 588 return _cache; 589 } 590 591 /** 592 * The object of loaded cache. 593 * Returns: $(D icontheme.cache.IconThemeCache) object loaded via tryLoadCache or set by cache property. 594 */ 595 @nogc @safe inout(IconThemeCache) cache() inout nothrow { 596 return _cache; 597 } 598 599 /** 600 * Path of icon theme cache file. 601 * Returns: Path to icon-theme.cache of corresponding cache file. 602 * Note: This function expects that icon theme has fileName. This function does not check if the cache file exists. 603 */ 604 @trusted string cachePath() const nothrow { 605 auto f = fileName(); 606 if (f.length) { 607 return buildPath(fileName().dirName, "icon-theme.cache"); 608 } else { 609 return null; 610 } 611 } 612 613 private: 614 IconThemeReadOptions _options; 615 IconThemeGroup _iconTheme; 616 IconThemeCache _cache; 617 } 618 619 /// 620 unittest 621 { 622 string contents = 623 `# First comment 624 [Icon Theme] 625 Name=Hicolor 626 Name[ru]=Стандартная тема 627 Comment=Fallback icon theme 628 Comment[ru]=Резервная тема 629 Hidden=true 630 Directories=16x16/actions,32x32/animations,scalable/emblems 631 Example=folder 632 Inherits=gnome,hicolor 633 634 [16x16/actions] 635 Size=16 636 Context=Actions 637 Type=Threshold 638 639 [32x32/animations] 640 Size=32 641 Context=Animations 642 Type=Fixed 643 644 [scalable/emblems] 645 Context=Emblems 646 Size=64 647 MinSize=8 648 MaxSize=512 649 Type=Scalable 650 651 # Will be saved. 652 [X-NoName] 653 Key=Value`; 654 655 string path = buildPath(".", "test", "Tango", "index.theme"); 656 657 auto iconTheme = new IconThemeFile(iniLikeStringReader(contents), path); 658 assert(equal(iconTheme.leadingComments(), ["# First comment"])); 659 assert(iconTheme.displayName() == "Hicolor"); 660 assert(iconTheme.localizedDisplayName("ru") == "Стандартная тема"); 661 assert(iconTheme.comment() == "Fallback icon theme"); 662 assert(iconTheme.localizedComment("ru") == "Резервная тема"); 663 assert(iconTheme.hidden()); 664 assert(equal(iconTheme.directories(), ["16x16/actions", "32x32/animations", "scalable/emblems"])); 665 assert(equal(iconTheme.inherits(), ["gnome", "hicolor"])); 666 assert(iconTheme.internalName() == "Tango"); 667 assert(iconTheme.example() == "folder"); 668 assert(iconTheme.group("X-NoName") !is null); 669 670 iconTheme.removeGroup("Icon Theme"); 671 assert(iconTheme.group("Icon Theme") !is null); 672 673 assert(iconTheme.cachePath() == buildPath(".", "test", "Tango", "icon-theme.cache")); 674 675 assert(equal(iconTheme.bySubdir().map!(subdir => tuple(subdir.name(), subdir.size(), subdir.minSize(), subdir.maxSize(), subdir.context(), subdir.type() )), 676 [tuple("16x16/actions", 16, 16, 16, "Actions", IconSubDir.Type.Threshold), 677 tuple("32x32/animations", 32, 32, 32, "Animations", IconSubDir.Type.Fixed), 678 tuple("scalable/emblems", 64, 8, 512, "Emblems", IconSubDir.Type.Scalable)])); 679 680 version(iconthemeFileTest) 681 { 682 string cachePath = iconTheme.cachePath(); 683 assert(cachePath.exists); 684 685 auto cache = new IconThemeCache(cachePath); 686 687 assert(iconTheme.cache is null); 688 iconTheme.cache = cache; 689 assert(iconTheme.cache is cache); 690 iconTheme.unloadCache(); 691 assert(iconTheme.cache is null); 692 693 assert(iconTheme.tryLoadCache(Flag!"allowOutdated".yes)); 694 } 695 696 iconTheme.removeGroup("scalable/emblems"); 697 assert(iconTheme.group("scalable/emblems") is null); 698 699 auto itf = new IconThemeFile(); 700 itf.displayName = "Oxygen"; 701 itf.comment = "Oxygen theme"; 702 itf.hidden = true; 703 itf.directories = ["actions", "places"]; 704 itf.inherits = ["locolor", "hicolor"]; 705 assert(itf.displayName() == "Oxygen"); 706 assert(itf.comment() == "Oxygen theme"); 707 assert(itf.hidden()); 708 assert(equal(itf.directories(), ["actions", "places"])); 709 assert(equal(itf.inherits(), ["locolor", "hicolor"])); 710 }