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