vim line
[drlaunch] / src / xdg / IconTheme.py
1 """
2 Complete implementation of the XDG Icon Spec Version 0.8
3 http://standards.freedesktop.org/icon-theme-spec/
4 """
5
6 import os, sys, time
7
8 from xdg.IniFile import *
9 from xdg.BaseDirectory import *
10 from xdg.Exceptions import *
11
12 import xdg.Config
13
14 class IconTheme(IniFile):
15     "Class to parse and validate IconThemes"
16     def __init__(self):
17         IniFile.__init__(self)
18
19     def __repr__(self):
20         return self.name
21
22     def parse(self, file):
23         IniFile.parse(self, file, ["Icon Theme", "KDE Icon Theme"])
24         self.dir = os.path.dirname(file)
25         (nil, self.name) = os.path.split(self.dir)
26
27     def getDir(self):
28         return self.dir
29
30     # Standard Keys
31     def getName(self):
32         return self.get('Name', locale=True)
33     def getComment(self):
34         return self.get('Comment', locale=True)
35     def getInherits(self):
36         return self.get('Inherits', list=True)
37     def getDirectories(self):
38         return self.get('Directories', list=True)
39     def getHidden(self):
40         return self.get('Hidden', type="boolean")
41     def getExample(self):
42         return self.get('Example')
43
44     # Per Directory Keys
45     def getSize(self, directory):
46         return self.get('Size', type="integer", group=directory)
47     def getContext(self, directory):
48         return self.get('Context', group=directory)
49     def getType(self, directory):
50         value = self.get('Type', group=directory)
51         if value:
52             return value
53         else:
54             return "Threshold"
55     def getMaxSize(self, directory):
56         value = self.get('MaxSize', type="integer", group=directory)
57         if value or value == 0:
58             return value
59         else:
60             return self.getSize(directory)
61     def getMinSize(self, directory):
62         value = self.get('MinSize', type="integer", group=directory)
63         if value or value == 0:
64             return value
65         else:
66             return self.getSize(directory)
67     def getThreshold(self, directory):
68         value = self.get('Threshold', type="integer", group=directory)
69         if value or value == 0:
70             return value
71         else:
72             return 2
73
74     # validation stuff
75     def checkExtras(self):
76         # header
77         if self.defaultGroup == "KDE Icon Theme":
78             self.warnings.append('[KDE Icon Theme]-Header is deprecated')
79
80         # file extension
81         if self.fileExtension == ".theme":
82             pass
83         elif self.fileExtension == ".desktop":
84             self.warnings.append('.desktop fileExtension is deprecated')
85         else:
86             self.warnings.append('Unknown File extension')
87
88         # Check required keys
89         # Name
90         try:
91             self.name = self.content[self.defaultGroup]["Name"]
92         except KeyError:
93             self.errors.append("Key 'Name' is missing")
94
95         # Comment
96         try:
97             self.comment = self.content[self.defaultGroup]["Comment"]
98         except KeyError:
99             self.errors.append("Key 'Comment' is missing")
100
101         # Directories
102         try:
103             self.directories = self.content[self.defaultGroup]["Directories"]
104         except KeyError:
105             self.errors.append("Key 'Directories' is missing")
106
107     def checkGroup(self, group):
108         # check if group header is valid
109         if group == self.defaultGroup:
110             pass
111         elif group in self.getDirectories():
112             try:
113                 self.type = self.content[group]["Type"]
114             except KeyError:
115                 self.type = "Threshold"
116             try:
117                 self.name = self.content[group]["Name"]
118             except KeyError:
119                 self.errors.append("Key 'Name' in Group '%s' is missing" % group)
120         elif not (re.match("^\[X-", group) and group.decode("utf-8", "ignore").encode("ascii", 'ignore') == group):
121             self.errors.append("Invalid Group name: %s" % group)
122
123     def checkKey(self, key, value, group):
124         # standard keys     
125         if group == self.defaultGroup:
126             if re.match("^Name"+xdg.Locale.regex+"$", key):
127                 pass
128             elif re.match("^Comment"+xdg.Locale.regex+"$", key):
129                 pass
130             elif key == "Inherits":
131                 self.checkValue(key, value, list=True)
132             elif key == "Directories":
133                 self.checkValue(key, value, list=True)
134             elif key == "Hidden":
135                 self.checkValue(key, value, type="boolean")
136             elif key == "Example":
137                 self.checkValue(key, value)
138             elif re.match("^X-[a-zA-Z0-9-]+", key):
139                 pass
140             else:
141                 self.errors.append("Invalid key: %s" % key)
142         elif group in self.getDirectories():
143             if key == "Size":
144                 self.checkValue(key, value, type="integer")
145             elif key == "Context":
146                 self.checkValue(key, value)
147             elif key == "Type":
148                 self.checkValue(key, value)
149                 if value not in ["Fixed", "Scalable", "Threshold"]:
150                     self.errors.append("Key 'Type' must be one out of 'Fixed','Scalable','Threshold', but is %s" % value)
151             elif key == "MaxSize":
152                 self.checkValue(key, value, type="integer")
153                 if self.type != "Scalable":
154                     self.errors.append("Key 'MaxSize' give, but Type is %s" % self.type)
155             elif key == "MinSize":
156                 self.checkValue(key, value, type="integer")
157                 if self.type != "Scalable":
158                     self.errors.append("Key 'MinSize' give, but Type is %s" % self.type)
159             elif key == "Threshold":
160                 self.checkValue(key, value, type="integer")
161                 if self.type != "Threshold":
162                     self.errors.append("Key 'Threshold' give, but Type is %s" % self.type)
163             elif re.match("^X-[a-zA-Z0-9-]+", key):
164                 pass
165             else:
166                 self.errors.append("Invalid key: %s" % key)
167
168
169 class IconData(IniFile):
170     "Class to parse and validate IconData Files"
171     def __init__(self):
172         IniFile.__init__(self)
173
174     def __repr__(self):
175         return self.getDisplayName()
176
177     def parse(self, file):
178         IniFile.parse(self, file, ["Icon Data"])
179
180     # Standard Keys
181     def getDisplayName(self):
182         return self.get('DisplayName', locale=True)
183     def getEmbeddedTextRectangle(self):
184         return self.get('EmbeddedTextRectangle', list=True)
185     def getAttachPoints(self):
186         return self.get('AttachPoints', type="point", list=True)
187
188     # validation stuff
189     def checkExtras(self):
190         # file extension
191         if self.fileExtension != ".icon":
192             self.warnings.append('Unknown File extension')
193
194     def checkGroup(self, group):
195         # check if group header is valid
196         if not (group == self.defaultGroup \
197         or (re.match("^\[X-", group) and group.encode("ascii", 'ignore') == group)):
198             self.errors.append("Invalid Group name: %s" % group.encode("ascii", "replace"))
199
200     def checkKey(self, key, value, group):
201         # standard keys     
202         if re.match("^DisplayName"+xdg.Locale.regex+"$", key):
203             pass
204         elif key == "EmbeddedTextRectangle":
205             self.checkValue(key, value, type="integer", list=True)
206         elif key == "AttachPoints":
207             self.checkValue(key, value, type="point", list=True)
208         elif re.match("^X-[a-zA-Z0-9-]+", key):
209             pass
210         else:
211             self.errors.append("Invalid key: %s" % key)
212
213
214
215 icondirs = []
216 for basedir in xdg_data_dirs:
217     icondirs.append(os.path.join(basedir, "icons"))
218     icondirs.append(os.path.join(basedir, "pixmaps"))
219 icondirs.append(os.path.expanduser("~/.icons"))
220
221 # just cache variables, they give a 10x speed improvement
222 themes = []
223 cache = dict()
224 dache = dict()
225 eache = dict()
226
227 def getIconPath(iconname, size = None, theme = None, extensions = ["png", "svg", "xpm"]):
228     global themes
229
230     if size == None:
231         size = xdg.Config.icon_size
232     if theme == None:
233         theme = xdg.Config.icon_theme
234
235     # if we have an absolute path, just return it
236     if os.path.isabs(iconname):
237         return iconname
238
239     # check if it has an extension and strip it
240     if os.path.splitext(iconname)[1][1:] in extensions:
241         iconname = os.path.splitext(iconname)[0]
242
243     # parse theme files
244     try:
245         if themes[0].name != theme:
246             themes = []
247             __addTheme(theme)
248     except IndexError:
249         __addTheme(theme)
250
251     # more caching (icon looked up in the last 5 seconds?)
252     tmp = "".join([iconname, str(size), theme, "".join(extensions)])
253     if eache.has_key(tmp):
254         if int(time.time() - eache[tmp][0]) >= xdg.Config.cache_time:
255             del eache[tmp]
256         else:
257             return eache[tmp][1]
258
259     for thme in themes:
260         icon = LookupIcon(iconname, size, thme, extensions)
261         if icon:
262             eache[tmp] = [time.time(), icon]
263             return icon
264
265     # cache stuff again (directories lookuped up in the last 5 seconds?)
266     for directory in icondirs:
267         if (not dache.has_key(directory) \
268             or (int(time.time() - dache[directory][1]) >= xdg.Config.cache_time \
269             and dache[directory][2] < os.path.getmtime(directory))) \
270             and os.path.isdir(directory):
271             dache[directory] = [os.listdir(directory), time.time(), os.path.getmtime(directory)]
272
273     for dir, values in dache.items():
274         for extension in extensions:
275             try:
276                 if iconname + "." + extension in values[0]:
277                     icon = os.path.join(dir, iconname + "." + extension)
278                     eache[tmp] = [time.time(), icon]
279                     return icon
280             except UnicodeDecodeError, e:
281                 if debug:
282                     raise e
283                 else:
284                     pass
285
286     # we haven't found anything? "hicolor" is our fallback
287     if theme != "hicolor":
288         icon = getIconPath(iconname, size, "hicolor")
289         eache[tmp] = [time.time(), icon]
290         return icon
291
292 def getIconData(path):
293     if os.path.isfile(path):
294         dirname = os.path.dirname(path)
295         basename = os.path.basename(path)
296         if os.path.isfile(os.path.join(dirname, basename + ".icon")):
297             data = IconData()
298             data.parse(os.path.join(dirname, basename + ".icon"))
299             return data
300
301 def __addTheme(theme):
302     for dir in icondirs:
303         if os.path.isfile(os.path.join(dir, theme, "index.theme")):
304             __parseTheme(os.path.join(dir,theme, "index.theme"))
305             break
306         elif os.path.isfile(os.path.join(dir, theme, "index.desktop")):
307             __parseTheme(os.path.join(dir,theme, "index.desktop"))
308             break
309     else:
310         if debug:
311             raise NoThemeError(theme)
312
313 def __parseTheme(file):
314     theme = IconTheme()
315     theme.parse(file)
316     themes.append(theme)
317     for subtheme in theme.getInherits():
318         __addTheme(subtheme)
319
320 def LookupIcon(iconname, size, theme, extensions):
321     # look for the cache
322     if not cache.has_key(theme.name):
323         cache[theme.name] = []
324         cache[theme.name].append(time.time() - (xdg.Config.cache_time + 1)) # [0] last time of lookup
325         cache[theme.name].append(0)               # [1] mtime
326         cache[theme.name].append(dict())          # [2] dir: [subdir, [items]]
327
328     # cache stuff (directory lookuped up the in the last 5 seconds?)
329     if int(time.time() - cache[theme.name][0]) >= xdg.Config.cache_time:
330         cache[theme.name][0] = time.time()
331         for subdir in theme.getDirectories():
332             for directory in icondirs:
333                 dir = os.path.join(directory,theme.name,subdir)
334                 if (not cache[theme.name][2].has_key(dir) \
335                 or cache[theme.name][1] < os.path.getmtime(os.path.join(directory,theme.name))) \
336                 and subdir != "" \
337                 and os.path.isdir(dir):
338                     cache[theme.name][2][dir] = [subdir, os.listdir(dir)]
339                     cache[theme.name][1] = os.path.getmtime(os.path.join(directory,theme.name))
340
341     for dir, values in cache[theme.name][2].items():
342         if DirectoryMatchesSize(values[0], size, theme):
343             for extension in extensions:
344                 if iconname + "." + extension in values[1]:
345                     return os.path.join(dir, iconname + "." + extension)
346
347     minimal_size = sys.maxint
348     closest_filename = ""
349     for dir, values in cache[theme.name][2].items():
350         distance = DirectorySizeDistance(values[0], size, theme)
351         if distance < minimal_size:
352             for extension in extensions:
353                 if iconname + "." + extension in values[1]:
354                     closest_filename = os.path.join(dir, iconname + "." + extension)
355                     minimal_size = distance
356
357     return closest_filename
358
359 def DirectoryMatchesSize(subdir, iconsize, theme):
360     Type = theme.getType(subdir)
361     Size = theme.getSize(subdir)
362     Threshold = theme.getThreshold(subdir)
363     MinSize = theme.getMinSize(subdir)
364     MaxSize = theme.getMaxSize(subdir)
365     if Type == "Fixed":
366         return Size == iconsize
367     elif Type == "Scaleable":
368         return MinSize <= iconsize <= MaxSize
369     elif Type == "Threshold":
370         return Size - Threshold <= iconsize <= Size + Threshold
371
372 def DirectorySizeDistance(subdir, iconsize, theme):
373     Type = theme.getType(subdir)
374     Size = theme.getSize(subdir)
375     Threshold = theme.getThreshold(subdir)
376     MinSize = theme.getMinSize(subdir)
377     MaxSize = theme.getMaxSize(subdir)
378     if Type == "Fixed":
379         return abs(Size - iconsize)
380     elif Type == "Scalable":
381         if iconsize < MinSize:
382             return MinSize - iconsize
383         elif iconsize > MaxSize:
384             return MaxSize - iconsize
385         return 0
386     elif Type == "Threshold":
387         if iconsize < Size - Threshold:
388             return MinSize - iconsize
389         elif iconsize > Size + Threshold:
390             return iconsize - MaxSize
391         return 0