source: applications/UFOLauncher/trunk/UFOLauncher.py @ 547

Revision 547, 22.0 KB checked in by frederik, 4 years ago (diff)
typo l 272
Line 
1import os
2import time
3from tempfile import mkstemp
4
5from AppKit import *
6from Foundation import *
7from LaunchServices import LSCopyApplicationURLsForURL
8from PyObjCTools import NibClassBuilder, AppHelper
9
10import vanilla
11from vanilla.dialogs import message
12from defconAppKit.windows.baseWindow import BaseWindowController
13
14import ufo2fdk
15from ufo2fdk.fontInfoData import getAttrWithFallback
16from defcon import Font
17
18from OSFontBridge import OSFontBridge
19
20import objc
21objc.setVerbose(True)
22
23NibClassBuilder.extractClasses("MainMenu")
24
25
26# ------------
27# App Delegate
28# ------------
29
30
31class FontManagerAppDelegate(NSObject):
32
33    def applicationDidFinishLaunching_(self, notification):
34        if ufo2fdk.haveFDK():
35            FontManagerWindowController()
36        else:
37            message(messageText="Could not find the FDK.", informativeText="Please install the FDK and try again.")
38            NSApp().terminate()
39
40    def applicationShouldTerminateAfterLastWindowClosed_(self, app):
41        return True
42
43
44# -----------
45# UFO Manager
46# -----------
47
48class UFOFontManager(object):
49
50    def __init__(self, local=False):
51        self._ufoPath_fontID = dict()
52        self._fontID_otfFontPath = dict()
53        self._bridge = OSFontBridge.alloc().init()
54
55    def __del__(self):
56        self.removeAll()
57
58    def addFont(self, path, local=False):
59        return self._activate(path, local)
60
61    def removeFont(self, path):
62        self._deactivateFont(path)
63
64    def removeAll(self):
65        paths = self._ufoPath_fontID.keys()
66        for path in paths:
67            self._deactivateFont(path)
68
69    def getNameForFont(self, path):
70        _id = self._ufoPath_fontID[path]
71        return self._bridge.getFontNameFromContainer_(_id)
72
73    def getAllInstalledFont(self):
74        return NSFontManager.sharedFontManager().availableFonts()
75
76    def _activate(self, path, local):
77        fileHandle, tempPath = mkstemp()
78        os.close(fileHandle)
79        tempPath += ".otf"
80        ## first check if this installer has already installed an older version
81        # deactivate previous version
82        if path in self._ufoPath_fontID:
83            self._deactivateFont(path)
84        # compile OTF
85        succeded, help = self._compileFont(path, tempPath)
86        if not succeded:
87            return False,  help
88        # load OTF
89        fontID = self._bridge.activateFontFromPath_isLocal_(tempPath, local)
90        if fontID == 0:
91            return False, ("Failed to install OTF.", "The operating system rejected the font for an unknown reason.")
92        # cache data
93        self._ufoPath_fontID[path] = fontID
94        self._fontID_otfFontPath[fontID] = tempPath
95        # return
96        return True, None
97
98    def _deactivateFont(self, path):
99        fontID = self._ufoPath_fontID.get(path, None)
100        if fontID is None:
101            return
102        tempPath = self._fontID_otfFontPath[fontID]
103        self._bridge.deactivateFont_(fontID)
104        os.remove(tempPath)
105        del self._ufoPath_fontID[path]
106        del self._fontID_otfFontPath[fontID]
107
108    def _compileFont(self, path, otfPath):
109        font = Font(path)
110        # look for conflicting PS name in OS
111        psName = getAttrWithFallback(font.info, "postscriptFontName")
112        if psName in self.getAllInstalledFont():
113            # can also deactivate that font and install the new one, we have to ask the user
114            return False, ("Failed to install OTF.", "The naming data in this font conflicts with a font that is already installed. Adjust the naming data and try again.")
115        # compile the OTF
116        compiler = ufo2fdk.OTFCompiler()
117        report = compiler.compile(font, otfPath) # XXX remove overlap, autohint
118        # look for a fatal error
119        if "makeotfexe [fatal]" in report["makeotf"].lower():
120            return False, ("Failed to compile OTF.", "The FDK rejected the data in the font. Please adjust the font data and try again.")
121        return True, None
122
123
124ufoFontManager = UFOFontManager()
125
126
127# ---------
128# Interface
129# ---------
130
131class FontManagerWindowController(BaseWindowController):
132
133    def __init__(self):
134        # window
135
136        self.w = vanilla.Window((700, 400), minSize=(700, 100), textured=True, autosaveName="FontManagerMainWindow")
137        self.w.getNSWindow().setShowsToolbarButton_(False)
138
139        # toolbar
140
141        items = [
142            dict(itemIdentifier="activate",
143                label="Activate",
144                imageNamed="toolbarActivate",
145                callback=self.toolbarActivateCallback,
146                ),
147            dict(itemIdentifier="deactivate",
148                label="Deactivate",
149                imageNamed="toolbarDeactivate",
150                callback=self.toolbarDeactivateCallback,
151                ),
152            dict(itemIdentifier="update",
153                label="Update",
154                imageNamed="toolbarUpdate",
155                callback=self.toolbarUpdateCallback,
156                ),
157            dict(itemIdentifier=NSToolbarFlexibleSpaceItemIdentifier),
158            dict(itemIdentifier="externalEdit",
159                label="Edit",
160                view=ToolbarButton(callback=self.toolbarExternalEditCallback)
161            ),
162            dict(itemIdentifier="revealInFinder",
163                label="Reveal in Finder",
164                imageNamed="toolbarFinder",
165                callback=self.toolbarRevealInFinderCallback
166            )
167        ]
168        self.w.addToolbar(toolbarIdentifier="FontManagerToolbar", toolbarItems=items, addStandardItems=False)
169        toolbar = self.w.getNSWindow().toolbar()
170        toolbar.setAllowsUserCustomization_(False)
171
172        # font list
173
174        self._inDrop = False
175        self._modificationDateTrees = {}
176        self._activePaths = set()
177
178        dateFormatter = DateFormatterWithSafeNone.alloc().init()
179        dateFormatter.setDateStyle_(NSDateFormatterShortStyle)
180        dateFormatter.setTimeStyle_(NSDateFormatterShortStyle)
181
182        columnDescriptions = [
183            dict(title=" ", key="active", width=17, cell=vanilla.CheckBoxListCell(), editable=True),
184            dict(title=" ", key="status", width=17, cell=FontStatusIndicatorCell.alloc().init(), editable=False),
185            dict(title="Family Name", width=150, key="family", editable=False),
186            dict(title="Style Name", width=150, key="style", editable=False),
187            dict(title="Last Modified", width=150, key="modifiedDate", formatter=dateFormatter, editable=False),
188            dict(title="File Name", key="path", formatter=UFOPathFormatter.alloc().init(), editable=False)
189        ]
190        self.w.fontList = vanilla.List((0, 0, -0, -24), [],
191            columnDescriptions=columnDescriptions, showColumnTitles=True,
192            drawFocusRing=False, drawVerticalLines=True, enableDelete=True,
193            otherApplicationDropSettings=dict(type=NSFilenamesPboardType, operation=NSDragOperationCopy, callback=self.dropFontCallback),
194            editCallback=self.listEditCallback
195        )
196        scrollView = self.w.fontList.getNSScrollView()
197        scrollView.setBorderType_(NSNoBorder)
198        scrollView.setAutohidesScrollers_(False)
199        # set the column resizing
200        for column in self.w.fontList.getNSTableView().tableColumns()[2:]:
201            column.setResizingMask_(NSTableColumnUserResizingMask | NSTableColumnAutoresizingMask)
202
203        # bottom text
204
205        self.w.fontListCountTextBox = vanilla.TextBox((15, -19, -15, 14), "", sizeStyle="small")
206        self.updateFontListCountText()
207
208        # finalize
209
210        self.setUpBaseWindowBehavior()
211        self.w.open()
212        self.loadFontListFromDefaults()
213
214    # -----------------------
215    # Activation/Deactivation
216    # -----------------------
217
218    def toolbarActivateCallback(self, sender):
219        selection = self.w.fontList.getSelection()
220        toActivate = set()
221        for index in selection:
222            item = self.w.fontList[index]
223            if not item["active"]:
224                toActivate.add(item)
225        self._activateUFOs(toActivate)
226        # update the bottom text
227        self.updateFontListCountText()
228
229    def toolbarDeactivateCallback(self, sender):
230        selection = self.w.fontList.getSelection()
231        toDeactivate = set()
232        for index in selection:
233            item = self.w.fontList[index]
234            if item["active"]:
235                toDeactivate.add(item)
236        self._deactivateUFOs(toDeactivate)
237        # update the bottom text
238        self.updateFontListCountText()
239
240    def toolbarUpdateCallback(self, sender):
241        selection = self.w.fontList.getSelection()
242        toUpdate = set()
243        for index in selection:
244            item = self.w.fontList[index]
245            if item["status"] == "U":
246                toUpdate.add(item)
247        self._activateUFOs(toUpdate, progressMessage="Updating...")
248        # update the bottom text
249        self.updateFontListCountText()
250
251    def _activateUFOs(self, items, progressMessage="Activating..."):
252        errors = []
253        if items:
254            progress = self.startProgress(progressMessage, None)
255            for item in items:
256                path = item["path"]
257                print "activate:", path
258                self._activePaths.add(path)
259                succeded, message = ufoFontManager.addFont(path)
260                if not succeded:
261                    errors.append("%s: %s %s" % (os.path.basename(path), message[0], message[1]))
262                    item["active"] = False
263                elif not item["active"]:
264                    item["active"] = True
265                item["status"] = " "
266            progress.close()
267        if errors:
268            if len(errors) == 1:
269                message = "A font count not be activated."
270            else:
271                message = "Fonts could not be activated."
272            self.showMessage(message, "\n".join(errors))
273
274    def _deactivateUFOs(self, items):
275        if items:
276            progress = self.startProgress("Deactivating...", None)
277            for item in items:
278                path = item["path"]
279                self._deactivateUFOWithPath(path)
280                if item["active"]:
281                    item["active"] = False
282            progress.close()
283
284    def _deactivateUFOWithPath(self, path):
285        self._activePaths.remove(path)
286        ufoFontManager.removeFont(path)
287
288    # ---------------
289    # File Operations
290    # ---------------
291
292    def toolbarExternalEditCallback(self, sender):
293        workspace = NSWorkspace.sharedWorkspace()
294        selection = self.w.fontList.getSelection()
295        for index in selection:
296            item = self.w.fontList[index]
297            workspace.openFile_withApplication_(item["path"], sender.title())
298
299    def toolbarRevealInFinderCallback(self, sender):
300        workspace = NSWorkspace.sharedWorkspace()
301        selection = self.w.fontList.getSelection()
302        for index in selection:
303            item = self.w.fontList[index]
304            path = item["path"]
305            workspace.selectFile_inFileViewerRootedAtPath_(path, "")
306
307    # ---------
308    # Font List
309    # ---------
310
311    def loadFontListFromDefaults(self):
312        defaults = NSUserDefaults.standardUserDefaults()
313        paths = defaults.get("ufoPaths", [])
314        if paths:
315            progress = self.startProgress("Importing...", None)
316            items = self._wrapItems(paths)
317            self.w.fontList.set(items)
318            progress.close()
319
320    def _wrapItems(self, paths):
321        items = []
322        for path in paths:
323            active = False
324            status = " "
325            modDate = ""
326            # get the family name and style name
327            familyName, styleName = getFamilyStyleName(path)
328            # get the most recent modification date
329            if os.path.exists(path):
330                modTree = getModificationDateTreeForUFO(path)
331                self._modificationDateTrees[path] = modTree
332                modDate = max(modTree.values())
333                # create a NSDate for the mod date
334                s = time.strftime("%Y/%m/%d %H:%M:%S +0000", time.localtime(modDate))
335                modDate = NSDate.dateWithString_(s)
336            else:
337                status = "D"
338            # make the dict
339            d = dict(active=active, status=status, family=familyName, style=styleName, path=path, modifiedDate=modDate)
340            items.append(d)
341        return items
342
343    def listEditCallback(self, sender):
344        if self._inDrop:
345            return
346        # deleting a UFO
347        if len(sender) != len(self._modificationDateTrees):
348            remainingPaths = set([item["path"] for item in sender])
349            for path in self._modificationDateTrees.keys():
350                if path not in remainingPaths:
351                    del self._modificationDateTrees[path]
352                    if path in self._activePaths:
353                        self._deactivateUFOWithPath(path)
354        # editing the activity level
355        else:
356            toActivate = set()
357            toDeactivate = set()
358            for item in sender:
359                # activate
360                if item["active"]:
361                    if item["path"] not in self._activePaths:
362                        toActivate.add(item)
363                # deactivate
364                else:
365                    if item["path"] in self._activePaths:
366                        toDeactivate.add(item)
367            if toActivate:
368                self._activateUFOs(toActivate)
369            if toDeactivate:
370                self._deactivateUFOs(toDeactivate)
371        # update the bottom text
372        self.updateFontListCountText()
373
374    def dropFontCallback(self, sender, dropInfo):
375        isProposal = dropInfo["isProposal"]
376        # get a list of existng paths
377        existingPaths = [d["path"] for d in self.w.fontList.get()]
378        # filter the existing paths out of the proposed paths
379        paths = dropInfo["data"]
380        paths = [path for path in paths if path not in existingPaths]
381        # only include UFOs
382        paths = [path for path in paths if os.path.splitext(path)[-1].lower() == ".ufo"]
383        # no paths, return False
384        if not paths:
385            return False
386        # if it isn't a proposal, store.
387        if not isProposal:
388            # start a progress bar
389            progress = self.startProgress("Importing...", None)
390            # turn on the drop flag
391            self._inDrop = True
392            # combine the lists of items
393            items = self.w.fontList.get() + self._wrapItems(paths)
394            # sort
395            sortable = [(d["family"], d["style"], d["modifiedDate"], os.path.basename(d["path"]).lower(), d) for d in items]
396            sortable.sort()
397            # set into the interface
398            items = [i[-1] for i in sortable]
399            self.w.fontList.set(items)
400            # update the count data
401            self.updateFontListCountText()
402            # turn off the drop flag
403            self._inDrop = False
404            # close the progress bar
405            progress.close()
406        return True
407
408    def updateFontListCountText(self):
409        # counts
410        totalCount = 0
411        activeCount = 0
412        updateCount = 0
413        missingCount = 0
414        for item in self.w.fontList.get():
415            totalCount += 1
416            if item["active"]:
417                activeCount += 1
418            if item["status"] == "U":
419                updateCount += 1
420            if item["status"] == "D":
421                missingCount += 1
422        # text
423        text = "%d Fonts - %d Active - %d Need Update - %d Missing" % (totalCount, activeCount, updateCount, missingCount)
424        # attributes
425        shadow = NSShadow.alloc().init()
426        shadow.setShadowColor_(NSColor.colorWithCalibratedWhite_alpha_(1, .5))
427        shadow.setShadowOffset_((0, -1))
428        shadow.setShadowBlurRadius_(1)
429        paragraph = NSMutableParagraphStyle.alloc().init()
430        paragraph.setAlignment_(NSCenterTextAlignment)
431        listCountTextAttributes = {NSShadowAttributeName : shadow, NSParagraphStyleAttributeName : paragraph}
432        # attributed string
433        text = NSAttributedString.alloc().initWithString_attributes_(text, listCountTextAttributes)
434        # set
435        self.w.fontListCountTextBox.set(text)
436
437    def windowSelectCallback(self, sender):
438        for item in self.w.fontList.get():
439            if not os.path.exists(item["path"]):
440                item["status"] = "D"
441            else:
442                newModTree = getModificationDateTreeForUFO(item["path"])
443                newModDate = getLatestDateFromTree(newModTree)
444                if item["modifiedDate"] is None or not newModDate.isEqualToDate_(item["modifiedDate"]):
445                    # only pop up the U if the font is active
446                    if item["active"]:
447                        item["status"] = "U"
448                    familyName, styleName = getFamilyStyleName(item["path"])
449                    item["family"] = familyName
450                    item["style"] = styleName
451                    item["modifiedDate"] = newModDate
452                else:
453                    item["status"] = " "
454        # update the bottom text
455        self.updateFontListCountText()
456
457    def windowCloseCallback(self, sender):
458        super(FontManagerWindowController, self).windowCloseCallback(sender)
459        ufoPaths = list()
460        for item in self.w.fontList.get():
461            ufoPaths.append(item["path"])
462        defaultsFromFile = NSUserDefaults.standardUserDefaults()
463        defaultsFromFile.setObject_forKey_(ufoPaths, "ufoPaths")
464        ufoFontManager.removeAll()
465
466
467# Edit Button
468
469class ToolbarButton(NSButton):
470
471    def __new__(cls, *arg, **kwargs):
472        self = cls.alloc().initWithFrame_(((0, 0), (32, 32)))
473        return self
474
475    def __init__(self, callback):
476        self.setImage_(NSImage.imageNamed_("toolbarEdit"))
477        self.setBordered_(False)
478        self.setTitle_("")
479        self._callback = callback
480        self._makeMenu()
481
482    def _makeMenu(self):
483        # make the proxy cell
484        self.popUpCell = NSPopUpButtonCell.alloc().initTextCell_pullsDown_("", True)
485        self.popUpCell.setUsesItemFromMenu_(False)
486        self.popUpCell.addItemWithTitle_("Edit with...")
487        menu = self.popUpCell.menu()
488        # add items to the menu
489        bundle = NSBundle.mainBundle()
490        dummyPath = os.path.join(bundle.resourcePath(), "dummy.ufo")
491        ## get the default app
492        workspace = NSWorkspace.sharedWorkspace()
493        succeded, appPath, ext = workspace.getInfoForFile_application_type_(dummyPath, None, None)
494        name = os.path.basename(appPath).split(".app")[0]
495        image = workspace.iconForFile_(appPath)
496        image.setSize_((16, 16))
497        menuItem = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(name, "action:", "")
498        menuItem.setImage_(image)
499        menuItem.setTarget_(self)
500        ## add a separator
501        menu.addItem_(menuItem)
502        menu.addItem_(NSMenuItem.separatorItem())
503        ## get a list of UFO aware apps
504        url  = NSURL.fileURLWithPath_(dummyPath)
505        apps = LSCopyApplicationURLsForURL(url, 0xFFFFFFFF) # kLSRolesAll
506        apps.reverse()
507        ## get icons for the apps and make the menu items
508        appList = set()
509        for app in apps:
510            appPath = app.path()
511            name = os.path.basename(appPath).split(".app")[0]
512            if name not in appList:
513                image = workspace.iconForFile_(appPath)
514                image.setSize_((16, 16))
515                appList.add(name)
516                menuItem =  NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(name, "action:", "")
517                menuItem.setImage_(image)
518                menuItem.setTarget_(self)
519                menu.addItem_(menuItem)
520        return self
521
522    def action_(self, sender):
523        self._callback(sender)
524
525    def dealloc(self):
526        del self.popUpCell
527        del self._callback
528        super(ToolbarButton, self).dealloc()
529
530    def mouseDown_(self, event):
531        self.popUpCell.performClickWithFrame_inView_(self.bounds(), self)
532
533
534# Path Formatter (for use in the list)
535
536class UFOPathFormatter(NSFormatter):
537
538    def stringForObjectValue_(self, obj):
539        if obj is None or isinstance(obj, NSNull):
540            return ""
541        return os.path.basename(obj)
542
543    def objectValueForString_(self, string):
544        return string
545
546
547# Date Formatter (for use in the list)
548
549class DateFormatterWithSafeNone(NSDateFormatter):
550
551    def stringForObjectValue_(self, obj):
552        if obj is None or obj == "":
553            return ""
554        return super(DateFormatterWithSafeNone, self).stringForObjectValue_(obj)
555
556
557# Status Cell
558
559statusCellImages = {
560    "U" : NSImage.imageNamed_("statusCellUpdate"),
561    "D" : NSImage.imageNamed_("statusCellMissing"),
562}
563
564class FontStatusIndicatorCell(NSActionCell):
565
566    def drawWithFrame_inView_(self, frame, view):
567        value = self.objectValue()
568        if value is None or value == " ":
569            return
570        image = statusCellImages[value]
571        image.setFlipped_(True)
572        x, y = frame.origin
573        x += 1
574        y += 1
575        image.drawAtPoint_fromRect_operation_fraction_((x, y), ((0, 0), (15, 15)), NSCompositeSourceOver, 1.0)
576
577
578# -------
579# Helpers
580# -------
581
582def getFamilyStyleName(path):
583    try:
584        font = Font(path)
585        familyName = getAttrWithFallback(font.info, "openTypeNamePreferredFamilyName")
586        styleName = getAttrWithFallback(font.info, "openTypeNamePreferredSubfamilyName")
587        if familyName is None:
588            familyName = ""
589        if styleName is None:
590            styleName  = ""
591    except:
592        familyName = styleName = ""
593    return familyName, styleName
594
595def getLatestDateFromTree(tree):
596    d = max(tree.values())
597    s = time.strftime("%Y/%m/%d %H:%M:%S +0000", time.gmtime(d))
598    return NSDate.dateWithString_(s)
599
600def getModificationDateTreeForUFO(path, resultTree=None):
601    """
602    Builds a flat tree of path : mod time recursively
603    for all files in a folder (and thus a UFO).
604    """
605    if resultTree is None:
606        resultTree = {}
607    for fileName in os.listdir(path):
608        if fileName.startswith("."):
609            continue
610        fullPath = os.path.join(path, fileName)
611        if os.path.isdir(fullPath):
612            getModificationDateTreeForUFO(fullPath, resultTree)
613        else:
614            resultTree[fullPath] = os.stat(fullPath).st_mtime
615    return resultTree
616
617
618# ----------
619# App Helper
620# ----------
621
622if __name__ == "__main__":
623    AppHelper.runEventLoop()
Note: See TracBrowser for help on using the repository browser.