Hatena::ブログ(Diary)

shouhの日記

2016-11-13

Python の GUI フレームワーク Tkinter, wxPython, Kivy の比較

Tkinter, wxPython, Kivy と GUI フレームワークを3つほど使ってみたので、整理も兼ねてちょいと比較という観点でまとめてみた。

比較


ライブラリ新しさ デザイン 更新 学習の難易度 複雑なGUIを組めるか
kivy 新しい かっこいい 活発 難しい 可能?
wxPython 古い ややださい 停止 普通 ある程度は可能
Tkinter 超古い 超ださい 活発? 簡単 きついかも

kivy は後発のモダンGUI フレームワーク。デザインがカッコイイのと、スマホ向けにも対応しているのが特徴だけど、まだ枯れてないこともあり、日本語情報もなかったりして学習難度が高い。

Tkinter は Python 標準付属のブツ。標準で使えるのは有り難いし、標準なので 3.x でもサポートされているので安心だし、シンプルな機能性と豊富な情報源により学習難度も低い。しかしデザインのダサさに不満が大きいのと(だからこそ他の GUI フレームワークが活発化した)、シンプルゆえにあまり凝った GUI を書くのが難しい。

wxPython は kivy と Tkinter の中間みたいなブツ。Tkinter よりも学習難度は高いが、多少は凝ったことができる(有名な wxPython 製として BitTorrentGoogle Drive など)。ただデザインは依然としてダサイみたい。

所感

WindowsPython で簡単な GUI 使いたいなーという私の所感。

kivy は日本語情報が乏しくて辛すぎる。挫折した。

wxPython は普通に使えそう。だけどちょっと機能が複雑で学習が辛いのと、ライブラリインストールが必要なのがマイナス。もうちょっと楽したいかなあ。デザインは別に気にならない。Windowsクラシックみたいな感じ。

Tkinter は三者の中では一番楽。標準で入ってるし、機能シンプルで学習コストも割と低い。デザインは気にならない。Windowsクラシックみたいな感じ。ただスクロール周りなどが若干不便なのは何とかしたいところ。

参考

インクリメンタルサーチでリストボックスの表示内容を変える、みたいな GUI を作ってみることで試用した記事があります。

Python の GUI フレームワーク Tkinter を使ってみたけど良い感じ in Windows7 and Python2.7

KivywxPython に続き、Tkinter も使ってみた。

f:id:shouh:20161113191954j:image

前回と同じく、上画像のような(インクリメンタルサーチ結果をリストボックスに反映する)GUI を作ってみた。なお、コードは最後。


Good

Bad

GUI の挙動が微妙に不便。これに尽きる。

詳細は下記するが、普段使ってる GUI と同じ感覚で使ってると「あれ、これはできないんだ……」ってのが割とある。それをできるようにするためには小細工やらオプション指定やらが必要で、英語サイトを読むことになる(日本語サイトではこの細かいレベルの情報が見つからない)。まあ一度解決してしまえば、あとはおまじないなり関数化するなりして使い回せばいいのだが。

Bad例: スクロールバーの取り扱いが面倒

たとえば ListBox にスクロールバーを付けたい場合、Scrollbar なるクラスのインスタンスをつくって、あれこれ設定して、ListBox のインスタンスと関連付ける……みたいな面倒くさい手順が必要。

ここは ListBox をつくるだけで自動的にスクロールバーもついて欲しいところ。

Bad例: Entry(一行入力ボックス)が Ctrl+A(全選択) や Ctrl+Z(UNDO) を実装していない

デフォルトのキー動作が色々とおかしい。

Ctrl + A は全選択が普通なのになぜかカーソル先頭移動だし、Ctrl + D でなぜか一文字消去(BackSpace) だし、Ctrl + Z は元に戻すのが普通なのに効かないし……と、よくわからない動作となっていて不便。

これに対処するには、キーバインド(このキーを押したらこの関数を呼べ)を自分で定義する必要がある。たとえば Ctrl + A のキーを押したら全選択を行う関数を呼べ、という感じで。ちなみに、この前選択を行う関数とやらも自分で作り込む必要がある(といって Entry のメソッドが色々あるのでそれを上手く組み合わせればいい)。

Bad例: スクロールに Wheel Redirect が効かない

Wheel Redirect とは「アクティブでないウィンドウに対してホイールスクロールする」機能であり、かざぐるマウス等のフリーソフトで実現できる。

これが Tkinter の GUI には効かない。スクロールしたいなら、いちいちアクティブにしてからスクロールしなきゃいけない。地味に不便。

Bad例: Tab キー移動を併用すると ListBox から選択した値を取得できない

ListBox から選択した値を取るには curselections() メソッドを使えばいい。(idx, value) のタプルが取れる。しかし、以下のような変な挙動がある。

  • Tab キーで ListBox からフォーカスを移すと、選択状態も消えるらしく空タプルが返る
  • マウスでフォーカスを移すと、選択状態が消えたり消えなかったりする
    • 選択状態が残ってる時でも (3, ) のように value が取れない

これを解決するには、ListBox 生成時に exportselection=0 というオプション指定をすればいい(参考: https://bytes.com/topic/python/answers/20254-tkinter-listbox-looses-selection-tab )

Bad例: ×ボタンで終了できない

解決にちょっと苦労した。

答えは root.protocol('WM_DELETE_WINDOW', self.quit) みたいな感じで、WM_DELETE_WINDOW というウィンドウメッセージ(?)に終了用関数(ここでは quit )をバインドしてやればいい、とのこと。

レイアウトについて(位置編)

そういえばレイアウトについて言及してなかった。ちょっと話変えて言及します。

GUI 部品のレイアウトは、

  1. Frame という土台みたいなのを作る
  2. GUI 部品の pack() メソッドを呼び出すことで設定する
    • もっというと side というパラメータに top, bottom, right,left のいずれかを指定する

という感じで配置していく。以下、例を示してみる(Frameは既に配置してるとします)。

例1: 下方向にずらり

self.button1.pack({'side':'top'})
self.button2.pack({'side':'top'})
self.button3.pack({'side':'top'})

↓どう配置される?

[button1]
[button2]
[button3]

例2: 横方向にずらり

self.button1.pack({'side':'left'})
self.button2.pack({'side':'left'})
self.button3.pack({'side':'left'})

↓どう配置される?

[button1][button2][button3]

例3: クイズその1

self.button1.pack({'side':'top'})
self.button2.pack({'side':'bottom'})
self.button3.pack({'side':'top'})
self.button4.pack({'side':'bottom'})

↓どう配置される?

[button1]
[button3]
[button4]
[button2]

例3はちょっとわかりにくいが、先に配置した部品をまたがない範囲で配置ってことになるのだと思う。例3でいえば、button3 は top に配置しようとしているが、既に button1 と button2 が配置されているので、その間の top(つまりは上方)に配置されようとする。button4 も同様で、button1, 2, 3 の間の bottom、もっというと button3 と button2 の間に配置されようとする。

例4: クイズその2

self.button1.pack({'side':'top'})
self.button2.pack({'side':'bottom'})
self.button3.pack({'side':'right'})
self.button4.pack({'side':'left'})

↓どう配置される?

    [button1]
[button4][button3]
    [button2]

……とまあ、こんな感じ。この挙動を見れば感づくかもしれないが、Grid 状に並べることはできない。並べたいなら、Frame を複数作って対応する。


[frame1]
[frame2]

↓ frame1 に button1, 2 を、
↓ frame2 に button3, 4 を配置する

[button1][button2]
[button3][button3]

レイアウトについて(サイズ編) ※ぶっちゃけまだ理解してません

部品の位置は pick メソッドの side パラメータに top/bottom/left/right で配置するってのはわかった。じゃあ各部品のサイズはどうなる?

これも pick メソッドを使う。今度は side パラメータではなく fill パラメータ

self.button1.pack({'side':'top', 'fill':'x'})
self.button2.pack({'side':'top', 'fill':'y'})
self.button2.pack({'side':'top', 'fill':'both'})

みたいな感じで、x方向にfill(めいっぱい伸ばす)するのか、y方向にするのか、はたまた both(両方向)なのか……を指定する、みたいなのだが。

色々試してみてもいまいちよーわからんかった。本記事のコードもぶっちゃけテキトーというか、「これ指定したら上手くいったわ」って感じでやってます(苦笑)。もっと勉強しないとね。。。

レイアウトについて: 絶対座標指定はできないの?

できないみたいです。調べてみても見当たらなかった(あるいは読解力が無いだけかもしれませんが)。

コード

レイアウト言及おしまい。最後にコード。

# -*- coding: utf-8 -*-

from Tkinter import *

class ListBoxSelector:
    def __init__(self, tkinter_listbox):
        self._lb = tkinter_listbox
        self._update()

    def _update(self):
        selection = self._lb.curselection()
        self._idx = -1
        self._value = ''

        if len(selection)!=0:
            self._idx = int(selection[0])
            self._value = self._lb.get(self._idx)

    def _get_len(self):
        return self._lb.size()

    def _set_idx(self, idx):
        self._lb.selection_clear(0, self._get_len())
        self._lb.selection_set(idx)
        self._lb.see(idx)
        self._update()

    def set_initial_pos(self):
        self._set_idx(-1)

    def down(self):
        new_idx = self._idx + 1
        if new_idx >= self._get_len():
            return
        self._set_idx(new_idx)

    def up(self):
        new_idx = self._idx - 1
        if new_idx < 0:
            return
        self._set_idx(new_idx)

    def get(self):
        self._update()
        return self._idx, self._value

class Application(Frame):
    def __init__(self, root=None):
        self.root=root

        self._on_enter = None
        self._on_text = None

        self._result_selector = None
        self._search_func = self._default_search_func
        self._contents_org = [] # incremental search 用に元リストを保持.

        # 'X' button quitting.
        root.protocol('WM_DELETE_WINDOW', self.quit)

    def _default_focus(self):
        self.querybox.focus()

    def _default_search_func(self, strlist, query):
        if len(query)==0:
            return strlist
        if query[0]==' ':
            return strlist

        ret = []
        for line in strlist:
            if line.find(query)!=-1:
                ret.append(line)
        return ret

    def _default_reflect_func(self):
        items = self._contents_org
        query = self.querybox.get()
        print 'len items:%d' % len(items)

        new_items = self._search_func(items, query)
        self.set_contents(new_items, update_org=False)

        # 検索結果が変わるはずなんでいったんクリア.
        self._result_selector.set_initial_pos()

    # public
    # ------

    def create(self):
        Frame.__init__(self, root)
        self.pack()

        # widget support
        # --------------
        self.querybox_sv = StringVar()
        def on_text(name, index, mode, sv=self.querybox_sv):
            text = sv.get()
            self._on_text(text)
            self._default_reflect_func()
        self.querybox_sv.trace('w', on_text)

        # widget def
        # ----------
        self.querybox = Entry(master=self.root, textvariable=self.querybox_sv)
        self.searchresult = Listbox(master=self.root,
                                    exportselection=0)
        self.yscroll = Scrollbar(master=self.root, orient=VERTICAL)

        # packing
        # -------
        self.querybox.pack({'side':'top', 'fill':'x'})
        self.yscroll.pack({'side':'right', 'fill':'y'})
        self.searchresult.pack({'side':'top', 'fill':'x'})

        # listbox
        # -------
        # scrollbar relationship
        self.yscroll.config(command=self.searchresult.yview)
        self.searchresult.config(yscrollcommand=self.yscroll.set)
        # status controller relationship
        self._result_selector = ListBoxSelector(self.searchresult)

        # binds
        # -----
        def on_enter(ev):
            idx, value = self._result_selector.get()
            self._on_enter(idx, value)

        def on_key(ev):
            k = ev.keycode
            if k==38: # up=38
                self._result_selector.up()
                return 'break'
            if k==40: # down=40
                self._result_selector.down()
                return 'break'

        def selectall(ev):
            self.querybox.select_range(0, END)
            return 'break'

        def do_nothing(*args, **kwargs):
            return 'break'

        self.querybox.bind('<Return>', on_enter)
        self.querybox.bind('<Control-a>', selectall)
        self.querybox.bind('<Control-d>', do_nothing)
        self.querybox.bind('<Control-/>', do_nothing)
        self.querybox.bind('<KeyPress>', on_key)

        # rest preperation
        # ----------------
        self._default_focus()

    def set_on_enter(self, on_enter):
        """ on_enter(idx, value) """
        self._on_enter = on_enter

    def set_on_text(self, on_text):
        """ on_text(new_string) """
        self._on_text = on_text

    def set_contents(self, contents, update_org=True):
        """ @param contens A string list.
        Must be called after create(). """
        if update_org:
            import copy
            self._contents_org = copy.deepcopy(contents)

        sr = self.searchresult
        size = sr.size()
        sr.delete(0, size)

        for elm in contents:
            sr.insert(END, elm)

root = Tk()
root.title('いんくりめんたるさーち')
root.geometry('320x100+0+0')

app = Application(root=root)
def on_text(new_stirng):
    print 'changed!: new=[%s]' % new_stirng
app.set_on_text(on_text)
def on_enter(idx, value):
    print '%d, %s' % (idx, value)
app.set_on_enter(on_enter)
app.create()
app.set_contents(['data%d'%elm for elm in range(32)])

app.mainloop()
root.destroy()