月別アーカイブ: 2019年2月

PyObjC Background Thread

サムネイル画像を作るバックグラウンド処理。

Objective-C で NSThread および GCD で非同期処理した結果を UI に反映する処理を書いてみた – 凹みTips

こんなアホみたく簡単にバックグラウンドスレッドが動かせるのかい!
performSelectorInBackground:withObject:
を使うだけ、GCD は Python なので関係ない。

それから GUI の常識、関数を抜けるまで画像は表示されない。
普通に書くとすべての画像の読み込みが終わってから一気に表示される。
順次表示するには for 文の中で一つ読み終わる毎に関数を抜ける必要がある。

EZ-NET: UI 関連の機能はメインスレッドで実行すること : Objective-C プログラミング

バックグラウンドスレッドの中で
performSelectorOnMainThread:withObject:waitUntilDone:
を使い UniilDone を True にすればメインスレッドの関数を抜けることができる。

Objective-c スゲェ。
みんな大好き yield を使おうと思ったけど必要ないぞ。

残念なのは NSCollectionView.content が NSArray だった。
python の配列のような可変長ではないので丸ごと配列を入れ替えすることに。

#!/usr/bin/env python3

import objc, os, re
from AppKit import *

PATH = '/Users/sasakima-nao/Pictures/nae'

class ItemView(NSView):
    def init(self):
        objc.super(ItemView, self).init()
        self.image = None
        return self

    def drawRect_(self, rect):
        self.image.drawInRect_(rect)

class CollectionViewItem(NSCollectionViewItem):
    def init(self):
        objc.super(CollectionViewItem, self).init()
        self.setView_(ItemView.alloc().initWithFrame_(NSMakeRect(0, 0, 100, 100)))
        return self

    def setRepresentedObject_(self, rep):
        objc.super(CollectionViewItem, self).setRepresentedObject_(rep)
        if rep != None:
            self.view().image = rep['pic']

class ComipoliCollectionView(NSCollectionView):
    def init(self):
        objc.super(ComipoliCollectionView, self).init()
        self.pixs = []
        self.setItemPrototype_(CollectionViewItem.new())
        self.performSelectorInBackground_withObject_(self.readImage_, PATH)
        return self

    def readImage_(self, path):
        l = os.listdir(path)
        for s in l:
            if re.search(r'\.(jpg|jpeg|png|gif)$', s, re.I):
                f = '{0}/{1}'.format(PATH, s)
                image = NSImage.alloc().initWithContentsOfFile_(f)
                self.performSelectorOnMainThread_withObject_waitUntilDone_(self.addImage_, image, True)

    def addImage_(self, image):
        self.pixs.append({'pic': image})
        self.setContent_(self.pixs)

class ComipoliWindow(NSWindow):
    def init(self):
        frame = NSMakeRect(100, 400, 400, 600)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        self.setTitle_('thumbnail')
        self.setDelegate_(self)
        # view
        self.canvas = ComipoliCollectionView.new()
        self.canvas.setFrameSize_(frame.size)
        self.contentView().addSubview_(self.canvas)
        return self

    def windowDidResize_(self, sender):
        aw = self.contentView().frame().size.width
        ah = self.contentView().frame().size.height
        size = NSMakeSize(aw, ah)
        self.canvas.setFrameSize_(size)

class ComipoliMenu(NSMenu):
    def init(self):
        objc.super(ComipoliMenu, self).init()
        item_app  = NSMenuItem.new()
        self.addItem_(item_app)
        menu_app = NSMenu.new()
        item_app.setSubmenu_(menu_app)
        item_quit = NSMenuItem.new()
        item_quit.initWithTitle_action_keyEquivalent_('Quit', 'terminate:', 'q')
        menu_app.addItem_(item_quit)
        return self

NSApplication.sharedApplication()
window = ComipoliWindow.new()
window.makeKeyAndOrderFront_(window)
NSApp.setMainMenu_(ComipoliMenu.new())
NSApp.activateIgnoringOtherApps_(True)
NSApp.run()

前回のに追加。

ウインドウが表示された後に順次画像が表示されていくのが確認できる。

PyObjC NSCollectionView

JXA で後回しにし続けていた画像サムネイル表示をそろそろ作りたい。

NSCollectionView – AppKit | Apple Developer Documentation

どうやらコレを使えばイケるらしい。
色々遠回りしたけど上手くいったコードをいきなり。

#!/usr/bin/env python3

import objc, os, re
from AppKit import *

PATH = '/Users/sasakima-nao/Pictures/nae'

class ItemView(NSView):
    def init(self):
        objc.super(ItemView, self).init()
        self.image = None
        return self

    def drawRect_(self, rect):
        self.image.drawInRect_(rect)

class CollectionViewItem(NSCollectionViewItem):
    def init(self):
        objc.super(CollectionViewItem, self).init()
        self.setView_(ItemView.alloc().initWithFrame_(NSMakeRect(0, 0, 100, 100)))
        return self

    def setRepresentedObject_(self, rep):
        objc.super(CollectionViewItem, self).setRepresentedObject_(rep)
        if rep != None:
            self.view().image = rep['pic']

class ComipoliCollectionView(NSCollectionView):
    def init(self):
        objc.super(ComipoliCollectionView, self).init()
        a = []
        l = os.listdir(PATH)
        for s in l:
            if re.search(r'\.(jpg|png)$', s, re.I):
                f = '{0}/{1}'.format(PATH, s)
                image = NSImage.alloc().initWithContentsOfFile_(f)
                a.append({'pic': image})
        self.setItemPrototype_(CollectionViewItem.new())
        self.setContent_(a)
        return self


class ComipoliWindow(NSWindow):
    def init(self):
        frame = NSMakeRect(100, 400, 400, 300)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        self.setTitle_('thumbnail')
        self.setDelegate_(self)
        # view
        self.canvas = ComipoliCollectionView.new()
        self.canvas.setFrameSize_(frame.size)
        self.contentView().addSubview_(self.canvas)
        return self

    def windowDidResize_(self, sender):
        aw = self.contentView().frame().size.width
        ah = self.contentView().frame().size.height
        size = NSMakeSize(aw, ah)
        self.canvas.setFrameSize_(size)

class ComipoliMenu(NSMenu):
    def init(self):
        objc.super(ComipoliMenu, self).init()
        item_app  = NSMenuItem.new()
        self.addItem_(item_app)
        menu_app = NSMenu.new()
        item_app.setSubmenu_(menu_app)
        item_quit = NSMenuItem.new()
        item_quit.initWithTitle_action_keyEquivalent_('Quit', 'terminate:', 'q')
        menu_app.addItem_(item_quit)
        return self

NSApplication.sharedApplication()
window = ComipoliWindow.new()
window.makeKeyAndOrderFront_(window)
NSApp.setMainMenu_(ComipoliMenu.new())
NSApp.activateIgnoringOtherApps_(True)
NSApp.run()

NSCollectionView, NSCollectionViewItem, NSView が最小限必要。
表示したい NSImage は適当なキーで辞書にいれて配列に突っ込む。
辞書毎に setRepresentedObject_ が呼ばれるので取り出す。
他にも手段があるようだけど、一つの方法として。

後選択可能にしたりとかもやらないと。
画像や PDF のリサイズやバックグラウンド処理をやらないと。
苗ちゃんの画像が古いのは実はもうやっていないからだとか。

NSWindow file drop and etc

NSWindow へのファイルドロップを検索する。
NSFilenamesPboardType 指定ばかりだけどもう Deprecated だ。
てか、JXA 版から変換コピペしたらエラーになった。

Dragging Files

NSURLPboardType を現行は指定するようだ。

def windowDidExitFullScreen_(self, sender):
    self.toolbar.setVisible_(True)
    self.is_fullscreen = False

def wantsPeriodicDraggingUpdates_(self, sender):
    return True

def performDragOperation_(self, sender):
    pb = sender.draggingPasteboard()
    if pb.types().containsObject_(NSURLPboardType):
        url = NSURL.URLFromPasteboard_(pb)
        self.set_path(url.path())
    return True

def draggingEntered_(self, sender):
    return NSDragOperationCopy

です。

そんなことより。
今日はクソ寒い中バルキアのレイドに二回挑戦してどっちも逃げられてしまった!
いやそれは本当にどうでもよくて。

前回 NSPDFImageRep でいくとか書いたけど。
この方法では左右ページが同じページになってしまうと後で気が付いた。
同じ NSPDFImageRep を左ページ用にセットした後で描写するのだから当然だった。

CGAffineTransformMake という Matrix を自分で作成する関数を見つけた。
結局 Matrix で描写するはめに、勉強しておいてよかった。

def drawRect_(self, rect):
    NSColor.blackColor().set()
    NSRectFill(rect)
    NSColor.whiteColor().set()
    if self.first_page != None:
        if self.is_pdf:
            ctx = NSGraphicsContext.currentContext().CGContext()
            r = CGPDFPageGetBoxRect(self.first_page, kCGPDFMediaBox)
            if r.size.width - r.size.height > 0 or not self.spread:
                CGContextSaveGState(ctx)
                if rect.size.width * r.size.height > rect.size.height * r.size.width:
                    n = rect.size.height / r.size.height
                    matrix = CGAffineTransformMake(n, 0, 0, n, rect.size.width / 2 - r.size.width * n / 2, 0)
                else:
                    n = rect.size.width / r.size.width
                    matrix = CGAffineTransformMake(n, 0, 0, n, n, rect.size.height / 2 - r.size.height / 2)
                CGContextConcatCTM(ctx, matrix)
                NSColor.whiteColor().set()
                CGContextFillRect(ctx, r)
                CGContextClipToRect(ctx, r)
                CGContextDrawPDFPage(ctx, self.first_page)
                CGContextRestoreGState(ctx)

見開きは省略。

NSUserDefaults でウインドウサイズを記憶させるのだが。
Toolbar をヘッダーバーにしているせいか縦位置と高さがズレる。
plist を見ながら補正値を入れた、こんなんでいいのだろうか。

def windowWillClose_(self, sender):
    defaults = NSUserDefaults.alloc().initWithSuiteName_('com.sasakima.comipoli')
    defaults.setFloat_forKey_(self.contentView().frame().size.width, 'width')
    defaults.setFloat_forKey_(self.contentView().frame().size.height - 22, 'height')
    defaults.setFloat_forKey_(self.frame().origin.x, 'x')
    defaults.setFloat_forKey_(self.frame().origin.y + 38, 'y')
    defaults.setBool_forKey_(self.canvas.l_to_r, 'LtoR')
    defaults.synchronize()

強引な。

数値順(自然順とも)ソートの他に Finder は大文字小文字の区別をしない。
以下をそのまま使ったらいい感じ。

sorting Pythonには、自然言語の文字列のための関数が組み込まれていますか? – CODE Q&A 問題解決

左右ページ切り替えを comipoli オリジナルと同様に。
PyObjC 版はメニューを丸ごと入れ替えることにしてみた。

def onLRButtonClicked_(self, button):
    if self.canvas.l_to_r:
        self.canvas.l_to_r = False
        self.toolbar.lr_button.setTitle_('L<-R')
        menu = NSApp.mainMenu().itemAtIndex_(2).submenu()
        menu.itemAtIndex_(0).setKeyEquivalent_(NSLeftArrowFunctionKey)
        menu.itemAtIndex_(1).setKeyEquivalent_(NSLeftArrowFunctionKey)
        menu.itemAtIndex_(2).setKeyEquivalent_(NSRightArrowFunctionKey)
        menu.itemAtIndex_(3).setKeyEquivalent_(NSRightArrowFunctionKey)
        menu.itemAtIndex_(5).setKeyEquivalent_(NSRightArrowFunctionKey)
        menu.itemAtIndex_(6).setKeyEquivalent_(NSLeftArrowFunctionKey)
    else:
        self.canvas.l_to_r = True
        self.toolbar.lr_button.setTitle_('L->R')
        menu = NSApp.mainMenu().itemAtIndex_(2).submenu()
        menu.itemAtIndex_(0).setKeyEquivalent_(NSRightArrowFunctionKey)
        menu.itemAtIndex_(1).setKeyEquivalent_(NSRightArrowFunctionKey)
        menu.itemAtIndex_(2).setKeyEquivalent_(NSLeftArrowFunctionKey)
        menu.itemAtIndex_(3).setKeyEquivalent_(NSLeftArrowFunctionKey)
        menu.itemAtIndex_(5).setKeyEquivalent_(NSLeftArrowFunctionKey)
        menu.itemAtIndex_(6).setKeyEquivalent_(NSRightArrowFunctionKey)
    if len(self.archive) != 0:
        self.canvas.display()

アホだ。

えっと、後何かあったかな?
連休が終わったのでここからは遅いかも。

comipoli_pyobjc_2.tar.gz

ここまでのバックアップ。

PyObjC Core Graphics

macOS 版 comipoli も PDF 対応にしたい。

NSPDFImageRep – AppKit | Apple Developer Documentation

こんなものがあったのか!
しかも NSImage からの継承。
ということは NSImage のメソッドもそのまま使えるってことだ。

elif is_pdf and re.search(r'\.pdf$', path, re.I):
    self.status = 3
    self.namelist.clear()
    data = NSData.dataWithContentsOfFile_(path)
    self.pdf = NSPDFImageRep.alloc().initWithData_(data)

######

elif self.status == 3:
    self.pdf.setCurrentPage_(num)
    return self.pdf

を前々回のソースに追記するだけで本当にイケた!
ただ cairo と Poppler の時と同じように背景は白で塗りつぶす必要があった。

しかし、画像だけの PDF だとトンデモなく表示が重いんですけど。。。。。
テキストだけの PDF なら余裕なんだけど。
筆者の mac は最新機とはいえ Air だし、pro なら。。。。。

もしかして Core Graphics を直で使ったら軽くなるかな?
と思ったので試すことに。
PyObjC の CoreGraphics は以下に入っていた
/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/site-packages/Quartz

PDF Document Creation, Viewing, and Transforming

上記を PyObjC で簡単に使ってみる。
PATH は自前で持っている PDF に書き換えてください。

#!/usr/bin/env python3

import objc
from AppKit import *
from Quartz.CoreGraphics import *

#PATH = '/Users/sasakima-nao/Documents/ProgrammingWithObjectiveC.pdf'
PATH = '/Users/sasakima-nao/Documents/big_picture.pdf'

class ComipoliView(NSView):
    def init(self):
        objc.super(ComipoliView, self).init()
        # PDF Document
        url = NSURL.fileURLWithPath_isDirectory_(PATH, False)
        self.pdfdoc = CGPDFDocumentCreateWithURL(url)
        self.max_page = CGPDFDocumentGetNumberOfPages(self.pdfdoc)
        self.page_number = 1
        return self

    def drawRect_(self, rect):
        NSColor.blackColor().set()
        NSRectFill(rect)
        # CGContext
        ctx = NSGraphicsContext.currentContext().CGContext()
        page = CGPDFDocumentGetPage(self.pdfdoc, self.page_number)
        #
        # Draw (x1)
        #CGContextDrawPDFPage(ctx, page)
        #
        # Resize Draw
        CGContextSaveGState(ctx)
        # start
        m = CGPDFPageGetDrawingTransform(page, kCGPDFMediaBox, rect, 0, True)
        CGContextConcatCTM(ctx, m)
        # background color is white
        cgrect = CGPDFPageGetBoxRect(page, kCGPDFMediaBox)
        NSColor.whiteColor().set()
        CGContextFillRect(ctx, cgrect)
        # clip
        CGContextClipToRect(ctx, cgrect)
        CGContextDrawPDFPage(ctx, page)
        # end
        CGContextRestoreGState(ctx)

class ComipoliWindow(NSWindow):
    def init(self):
        frame = NSMakeRect(100, 400, 900, 600)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        self.setTitle_('draw PDF')
        self.setDelegate_(self)
        # view
        self.canvas = ComipoliView.new()
        self.canvas.setFrameSize_(frame.size)
        self.contentView().addSubview_(self.canvas)
        return self

    def nextPage(self):
        if self.canvas.max_page > self.canvas.page_number:
            self.canvas.page_number += 1
            self.canvas.display()
        if self.canvas.max_page == self.canvas.page_number:
            self.setTitle_('end')

    def windowDidResize_(self, sender):
        aw = self.contentView().frame().size.width
        ah = self.contentView().frame().size.height
        size = NSMakeSize(aw, ah)
        self.canvas.setFrameSize_(size)

class ComipoliMenu(NSMenu):
    def init(self):
        objc.super(ComipoliMenu, self).init()
        item_app  = NSMenuItem.new()
        self.addItem_(item_app)
        menu_app = NSMenu.new()
        item_app.setSubmenu_(menu_app)
        item_next = NSMenuItem.new()
        item_next.initWithTitle_action_keyEquivalent_('NextPage', 'nextPage:', 'n')
        item_next.setTarget_(self)
        menu_app.addItem_(item_next)
        item_quit = NSMenuItem.new()
        item_quit.initWithTitle_action_keyEquivalent_('Quit', 'terminate:', 'q')
        menu_app.addItem_(item_quit)
        return self

    def nextPage_(self, sender):
        NSApp.keyWindow().nextPage()

NSApplication.sharedApplication()
window = ComipoliWindow.new()
window.makeKeyAndOrderFront_(window)
NSApp.setMainMenu_(ComipoliMenu.new())
NSApp.activateIgnoringOtherApps_(True)
NSApp.run()

command+N で次ページ

って、ほとんど変わらないヤン!
結局この作業を NSPDFImageRep は内部でやっているだけなんだろう。
軽くしたきゃ cbz に変換してくれで済ませよう。

そんなことより。
PyObjC ってこんなことまでできるのかい!
書いた筆者が驚いた、Xcode マジでいらね。

NSWindow “New Window”

NSWindow をもう一つ作成するメニューを付ける。
実行すると一瞬表示されて即座に消えて無くなる。
NSApp.windows プロパティの数は増えていない、何故だ?

backingType – Cocoa API解説(macOS/iOS)

全然関係ないページだけどこの Swift コードは何?
作った Window は自前の配列とかで保持しなきゃいけないのかよ。

NSApp は作成した Window を保持してくれないってことみたい。
GtkApplicationWiondow と違い引数に app 指定が無いからそりゃそうかと。
つまり、ガベージコレクションで破棄されてしまうってことだ。

class ComipoliWindow(NSWindow):

    # Avoid garbage collection
    avoid_gc = []

    def init(self):
        frame = NSMakeRect(100, 400, 800, 480)
        objc.super(ComipoliWindow, self).initWithContentRect_styleMask_backing_defer_(
            frame,
            NSTitledWindowMask |
            NSClosableWindowMask |
            NSResizableWindowMask |
            NSMiniaturizableWindowMask,
            NSBackingStoreBuffered,
            False)
        # etc...
        return self

    @classmethod
    def new_window(self):
        window = ComipoliWindow.new()
        self.avoid_gc.append(window)
        window.makeKeyAndOrderFront_(window)
        return window

グローバル変数にしたくないからこんな classmethod を作ってみた。
大量にウインドウが作られるのものではないならアプリ終了まで保持でいい。

applicationDidFinishLaunching で Window が作れないって検索で見かける。
Objective-c, Swift も参照カウンタだから同じなんだろう。
初心者コードでは NSApp.run の前にウインドウを作るから破棄されないだけ。

後、NSMenu に View を定義すると勝手に ‘Show Tab Bar’ が挿入される。
‘Enter Full Screen’ も入るけど command+control+F は使えない。

iOSな日々: macOS Sierra非互換:「表示」メニューに「タブバーを表示」項目が自動的に追加される(非表示化対応)

なんて迷惑な仕様なんだよ。
‘Enter Full Screen’ は自前で command+control+F を定義すると消える。

#!/usr/bin/env python3

from AppKit import *
from comipoli_window import ComipoliWindow
from comipoli_menu import ComipoliMenu

class AppDelegate(NSObject):
    def applicationDidFinishLaunching_(self, notification):
        ComipoliWindow.new_window()

def main():
    NSApplication.sharedApplication()
    NSApp.setMainMenu_(ComipoliMenu.new())
    NSApp.setDelegate_(AppDelegate.new())
    NSApp.activateIgnoringOtherApps_(True)
    # Hide Auto Tab Bar
    NSWindow.setAllowsAutomaticWindowTabbing_(False)
    # run
    NSApp.run()

色々なんとかなった。

しかし、AppKit は GTK+ より日本語情報が多いのはいいんだけーが。
みんな Xcode の使い方ばかり勉強して肝心のコードがほとんど無いよな。

統合開発環境ってコードだけでアプリが作れる人が使って初めて便利だと思うもの。
であるはずなんだけど、これではプログラミングが面白いと思う人なんて増えないよ。