世界一使いにくいリストボックスを作れちゃうぜやったね@tkinter

「柔軟性の対価」てやつなのな。

「普通のリストボックス」の以下に至るまでに一時間以上徘徊させられた:

python2.x は「動くふり」してるだけで実際は正しく動作しない、すまん。
 1 try:
 2     import Tkinter as tkinter  # python 2.x, but sorry, I'm not testing it.
 3     import ttk
 4     from Tkinter import E, W, N, S  # for sticky
 5     def _askopenfilename(master):
 6         import tkFileDialog
 7         return tkFileDialog.askopenfilename()
 8     from tkSimpleDialog import Dialog
 9     from ScrolledText import ScrolledText
10 except ImportError:
11     import tkinter
12     from tkinter import ttk
13     from tkinter import E, W, N, S  # for sticky
14     def _askopenfilename(master):
15         from tkinter.filedialog import askopenfilenames
16         return askopenfilenames(multiple=0)
17     from tkinter.simpledialog import Dialog
18     from tkinter.scrolledtext import ScrolledText
19 
20 
21 def _setup_listboxframe(master, *args, **kwargs):
22     fr = tkinter.Frame(master)
23     lb = tkinter.Listbox(fr, *args, **kwargs)
24     lb.pack(side=tkinter.LEFT, fill=tkinter.Y)
25     sc = tkinter.Scrollbar(fr)
26     sc.pack(side=tkinter.RIGHT, fill=tkinter.Y)
27     lb.config(yscrollcommand=sc.set)
28     sc.config(command=lb.yview, takefocus=1)
29     sc.bind("<Up>", lambda *args: lb.yview_scroll(-1, "unit"))
30     sc.bind("<Prior>", lambda *args: lb.yview_scroll(-1, "page"))
31     sc.bind("<Down>", lambda *args: lb.yview_scroll(1, "unit"))
32     sc.bind("<Next>", lambda *args: lb.yview_scroll(1, "page"))
33     # マウスホールにもバインドできるよ。ネットで探してね。
34     return fr, lb
35 
36 #
37 import random
38 root = tkinter.Tk()
39 lbitems = ["%d" % random.randint(0, i**2) for i in range(1, 30)]
40 lbitemsvar = tkinter.StringVar(value=lbitems)
41 f, l = _setup_listboxframe(root, listvariable=lbitemsvar)
42 f.pack()
43 
44 root.mainloop()

苦労の跡は ffchopreview.py の Rev82→84

知らないと、悶絶ポイントがコードの短さに比して異様に多くて、以下の通り:

  1. Listbox 自身はスクロールバーを内蔵してない
  2. Scrollbar は TAB でフォーカスを受け取らないことが出来る(takefocus=0)
  3. Scrollbar は TAB でフォーカスを受け取っても見栄えがほとんど変わらず識別困難
  4. TAB でのフォーカス移動は何もしなければ配置順
  5. (Windows 版以外で未確認だが)Scrollbar にはキーボードでの操作がバインドされていない…、のか? ほんとに? なぜに? ほんとなの? なんで、なんで、ねぇなんでなの

1.は…まぁ分離されてるからこそ一個のスクロールバーで複数コントロールを同時に動かす、てなことが簡単にできるってことなので、理解してしまえば「このやろー」と思わなくなる。とはいえ、これ、「キー操作でスクロール出来る/出来ない」以前の問題として、スクロールバーがない場合、選択状態のアイテムが現在可視のビューポート外の場合に「シンプルにユーザを迷子にさせる名人」となるだけで存分に「世界一使い勝手の悪いリストボックス」化する。

2.がなにげに「混乱の元」で、これ、「受け取ることが出来る(takefocus=1)」が実はデフォルトの振る舞いで、3.以降のポイントに気付かないと「takefocus で解決…しない、なんだよっ」となる。

4.は伝わらんと思うが、ネットで検索するとどういうわけか以下が必ず例として書かれてるんである:

以下相当のコード、であって、そのものではない
1     fr = tkinter.Frame(master)
2     sc = tkinter.Scrollbar(fr)
3     sc.pack(side=tkinter.RIGHT, fill=tkinter.Y)
4     lb = tkinter.Listbox(fr, *args, **kwargs)
5     lb.pack()
6     lb.config(yscrollcommand=sc.set)
7     sc.config(command=lb.yview, takefocus=1)

間違い探し。じっくり目を凝らせば、ワタシの上でのコードでは「リストボックス→スクロールバー」の順に pack している。ゆえに TAB キーでのフォーカス移動は「リストボックス本体→スクロールバー」となる。けれどもこの出回りまくっているサンプルは「スクロールバー→リストボックス本体」となる、当たり前。どちらが先にフォーカスを受け取るべきかについての議論はありそうだが、なんにしても「スクロールバー→リストボックス本体」が多くのユーザの最初の直感に反することは事実である。ので、3.の「フォーカスってるか識別困難」と相まって、「うまくいかねーっっ」と叫ぶハメになるわけだ。

して最後の「キーバインド」の問題。正直これで悩んでみてはじめて、如何に無意識的に正しい設計の恩恵にあずかってたのか実感したよ。リストボックスにくっつくスクロールバーにユーザが暗黙に求めることって、実は「スクロール出来ること」そのものではないのよね。「リストボックスのスクロール」そのものに求めるものと実は決定的に一つ違っていて、「リストボックスのスクロール」は選択セルも同時に操作することを期待し、スクロールバーの場合は「選択はそのままに」ビューだけ移動したいのだ。だから「マウスでスクロールバーまでマウスポインタを移動して…」なんてことをするんでなくキー操作で操作するユーザにとっては、「ビューだけ移動」するために「あえて TAB キーでスクロールバーにフォーカスを移動」するのである。だからね…せっかく「takefocus=1」でフォーカスを得てもマウスでしか操作できないなんてので、「世界一使いにくいリストボックス」にさらに拍車をかけるハメになるのであった…。

まぁ…tkinter (というか tcl/tk) なんて、「仕方なく使うもの」だし、「いかんせん旧式で色々奇特」なのは許容して使うべきもんだとは思う。けど、ちょっとなんかなぁと思った。キーボードにバインドしてないのは何か重大な設計決断だったりすんのかね?


2020-10-29追記:
最初の投稿時点でそこまで書くか、あるいはこうして追記で書くか、もしくは「続編」として新規投稿にするか、と迷った内容、な追記。ほんとはね、「世界一使いにくい」というからには、まだある

まず、これはほとんど振る舞いのバグではないかと思うのだが、以下の操作で「不愉快なことになる」:

  1. 何かどれかのアイテムをとにかく選択状態にする。
  2. リストボックスからフォーカスを外す(特にキーボードショートカットでの何か操作の場合が顕著)。
  3. フォーカスを TAB 移動などでリストボックスに戻す。

後述の selectmode にも少し依存するものの、おおむね「なんでそーなる」ってことになる。まず 1. で選択していた状態、これが「選択とカーソル」状態だったとする。見た目としては、選択状態としての青背景に、カーソルがいることを示す点線のボーダーだね。これが、戻ってくると体感としておよそ 87.361222398756675543% くらいの確率でカーソルが全く別のアイテムを指している。つまり選択とカーソルが分離した状態になる。無論 selectmode=BROWSE など、セレクションとカーソルが別扱いの場合の振る舞いではあるが、いずれにしてもユーザとしては「なんでだよ」と叫ぶ以外の行動を思いつかない。

selectmode に関しては、シンプルに「思慮不足」の類の設計不全だろう。というよりは「selectmode=BROWSEがそーなので使えない」がゆえに、「selectmode=EXTENDED」の機能不全が輪をかける、という構造。何言ってるかわからんと思う。けど、実際やってみるといいと思うよ。「selectmode=EXTENDED」は、Windows のエクスプローラの振る舞いと「近い」だけで致命的に一つ機能がかけていて、使用を躊躇するのに十分だ。なにかというと、「選択を変更せずにカーソルだけ移動する」ことが、キー操作だけでは出来ない。エクスプローラの場合これは「Ctrl」を押したままカーソルキー操作をすればいい。この振る舞いは、単一選択を実現してくれる「selectmode=BROWSE」の方が近い動きをする、というか、選択とカーソル移動が分離している望みの振る舞いなので、単一選択でいいなら「selectmode=BROWSEでいい」といいたいのに、てこと。結局どの selectmode も癇癪の種を抱えていて、大層ストレスがたまる。

どうにも tcl/tk、tkinter の設計者は「キー操作」を失念してるんじゃないのか、って思う。CUI 主体の Unix 育ちのくせに、いったい何をやってるんだか…。

というわけで、スクロールバー問題の解消をすれば「世界で二番目に使いにくいリストボックス」が作れるぜ、やったね、ってなる、の巻。