clipboard_filter @ tkinter

「clipboard_filter」なんて機能は tkinter にはないよ、念のため。

リストボックスがアレだにも通じるんだけれど、「Text」「Entry」なども、「柔軟性の確保」のためなのかあるいは単に旧式だからなのかのどちらかによって、いわゆる「いやん、使いにきー」なテキストボックスを作れる。

入力チェック的な部分についてもちょっと前にこいつに追記しといたんだけれど、そもそもさ、「カットアンドペーストだのの基本機能」の実現が、なんともデフォルトでひじょーに中途半端というか、あまりにも最小限過ぎるわけよね。アンドゥなんか高尚すぎて、やってくれてるはずがない、のレベル、だって、右クリックメニューでの操作すら内蔵されてないんだもの。

「Pythonバッチにお飾りGUI」に追記したのは、tkinter.Entry にバリデータを追加して喜ぶ例ね。ただこれ、たとえば数値を入れさせたい Entry に対して「引用符を入力しようとするなんて万死に値する」として入れようとした入力全部を拒絶しちゃうわけよ。だから例えば「時刻入力させたい Entry」を実現したくてバリデーションで「時刻文字列を構成する文字のみ許容」と書いてしまうと、どこぞのメモからクリップボードにコピーした

1 "00:19:32.3"

というテキストを、二重引用符が含まれているという理由で、Ctrl-v でペーストすると入力が全クリアされてしまう、てことが起こる。

ここまではまぁ「そうなのか」ってことなのだが、ここからはちょっと宗教論争じみてきそうなのよね。一応『「引用符を入力しようとするなんて万死に値する」、なんて言うなんて、そんな、ひどい』対応はしてみることは出来る。ただ、それってちょっと危険な気もするんだよね。少なくともユーザがちゃんとした考えでもって自分が何をしているのか理解の上でクリップボードにテキストをコピーしているものに対して、「ほぉほぉ、きっとチミの入力した引用符は魔が差したんだよね、勝手に独断で引用符削除しとくねーっ」って、これ、「いつでも正しくていつでも安全なのか」って言われると、違うと思わない? 危険となるにはかなりの偶然が必要だけれども、たとえば核ミサイル発射のためのコマンド入力テキストボックスがあるとする。ここにどこぞの WEB ページから意図せずクリップボードにコピーされてしまった(きっと無意識の操作)テキストから「核ミサイル発射のためのコマンド入力テキストボックスが許容する文字セットだけにフィルタして」偶然「発射」コマンドになる、なんてことは…、まぁ可能性はゼロではない、と。

ちょっと最小限ではなくワタシが実際に使っている本物の例なのでごちゃごちゃしててすまんが、たとえばこんな感じね:

ffchopreview.py で使ってるやつ
  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 class _MyEntry(tkinter.Entry):
 22     def __init__(
 23             self,
 24             root,
 25             validcharsfun,
 26             clipboard_filter_fun,
 27             *args, **kwargs):
 28         #
 29         tkinter.Entry.__init__(self, root, *args, **kwargs)
 30         #
 31         def _validator(*args):
 32             P, d = args
 33             if d == "1":  # insert
 34                 return validcharsfun(P)
 35             return True
 36         #
 37         # %d = Type of action (1=insert, 0=delete, -1 for others)
 38         # %i = index of char string to be inserted/deleted, or -1
 39         # %P = value of the entry if the edit is allowed
 40         # %s = value of entry prior to editing
 41         # %S = the text string being inserted or deleted, if any
 42         # %v = the type of validation that is currently set
 43         # %V = the type of validation that triggered the callback
 44         #      (key, focusin, focusout, forced)
 45         # %W = the tk name of the widget
 46         self.config(validate="key")
 47         self.config(validatecommand=(root.register(_validator), '%P', '%d'))
 48 
 49         def _clipboard_filter(*args):
 50             nc = clipboard_filter_fun(self.clipboard_get())
 51             self.clipboard_clear()
 52             self.clipboard_append(nc)
 53         self.bind("<Control-v>", _clipboard_filter)
 54 
 55 
 56 class _MyNumEntry(_MyEntry):
 57     def __init__(
 58             self, root,
 59             validcharsfun,
 60             minval,
 61             convfuns,
 62             incrvals,  # (Up, Control-Up, Shift-Up)
 63             *args, **kwargs):
 64 
 65         _MyEntry.__init__(
 66             self, root,
 67             validcharsfun,
 68             lambda txt: re.sub(r"[^0-9.:,eE+-]", "", txt),  # for float, time.
 69             *args, **kwargs)
 70         self._minval = minval
 71         self._fromstrfun, self._tostrfun = convfuns
 72         self._incrvals = incrvals
 73         self.bind("<Up>", lambda *args: self._up(0, *args))
 74         self.bind("<Control-Up>", lambda *args: self._up(1, *args))
 75         self.bind("<Shift-Up>", lambda *args: self._up(2, *args))
 76         self.bind("<Down>", lambda *args: self._dn(0, *args))
 77         self.bind("<Control-Down>", lambda *args: self._dn(1, *args))
 78         self.bind("<Shift-Down>", lambda *args: self._dn(2, *args))
 79 
 80     def _up(self, m, *args):
 81         delta = self._incrvals[m]
 82         try:
 83             cur = self.get()
 84             rndi = len(("%g" % delta).partition(".")[-1])
 85             cur = round(self._fromstrfun(cur), rndi)
 86             cur += delta
 87             cur = max(self._minval, cur)
 88             # FIXME: idealy, i want to set via textvariable...
 89             self.delete(0, "end")
 90             self.insert(0, self._tostrfun(cur))
 91         except ValueError:
 92             pass
 93 
 94     def _dn(self, m, *args):
 95         delta = self._incrvals[m]
 96         try:
 97             cur = self.get()
 98             rndi = len(("%g" % delta).partition(".")[-1])
 99             cur = round(self._fromstrfun(cur), rndi)
100             cur -= delta
101             cur = max(self._minval, cur)
102             # FIXME: idealy, i want to set via textvariable...
103             self.delete(0, "end")
104             self.insert(0, self._tostrfun(cur))
105         except ValueError:
106             pass
107 
108 
109 class _MyTSEntry(_MyNumEntry):
110     @staticmethod
111     def _vch(s):
112         return (re.match(r"^[\d:.]+$", s) is not None and
113                 not re.search(r"([:.]{2,}|[:.][:.])", s) and
114                 not re.search(r":\d{3,}", s) and
115                 not re.search(r":[6-9]\d", s) and
116                 s.count(":") <= 2 and s.count(".") <= 1 and
117                 not re.search(r"\..*:", s))
118 
119     def __init__(self, root, *args, **kwargs):
120         _MyNumEntry.__init__(
121             self, root,
122             _MyTSEntry._vch,
123             0,
124             (parse_time, _ts_to_tss),
125             (0.1, 1, 0.01),
126             *args, **kwargs)
127 
128 
129 class _MyFloatEntry(_MyNumEntry):
130     @staticmethod
131     def _vch(s):
132         # should we allow exponential form and negative values?
133         # i dont think so, in this application.
134         return (re.match(r"^[\d.]+$", s) is not None and
135                 s.count(".") <= 1)
136 
137     def __init__(
138             self,
139             root,
140             minval,
141             fmt,
142             incrvals,
143             *args, **kwargs):
144         _MyNumEntry.__init__(
145             self, root,
146             _MyTSEntry._vch,
147             minval,
148             (float, fmt.format),
149             incrvals,
150             *args, **kwargs)

注意: parse_time と _ts_to_tss があるので上記引用した部分だけだと動かんね、実際のものがみたければ ffchopreview.py 参照。それと例によって(?)Python 2.7 対応はフリだけ。実際は正しく動いてないか動くか確認してないかどちらか。


2020-11-02追記:
「不正入力文字が入ってるとクリアしちゃうのよ」問題について、なんというかこれぞ「バッドノウハウの極み」ではあるものの、かなり強引な措置は見つけた。相当ヒドくてムゴくてやべーやり口なので、覚悟の上で参考にすべし。これね。

動かしてみた結果から言えることは、どうやら「bind」の振る舞い(もしくはイベントの伝播の仕様)からは、元から内蔵されている「Ctrl-vへバインディングされた何かしら」は、「オレサマが新たに追加したハンドラ」のあとで動いているということになるね。どうなってるのかな、オリジナルが Entry に対してハンドラが登録されているのであれば、「オレサマの bind したもの」は先頭に挿入されてることになる。そういうことかね?

どっちかだと思うんだよね、このやや偏執狂的な異論を理解の上でこの例のようにテキストを詐称するか、もしくは「新たな入力を受け入れずに元の内容を維持する」。なんで不正入力文字が入ってるとクリアしちゃうのよ、ちぅかクリアするんならアンドゥできろよ、てことな。(クリアしちゃわないバリデータの書き方は…出来ない気がするんだけどどうだろう?)

まぁどういう作業の仕方をするかによるんだよねぇ。この tkinter アプリの外に例えば「時刻のリスト」がなんらか管理されてるとして、それを emacs で保守してる、とかの場合さ、たとえばその「時刻のリスト」が書かれているテキストはたとえば json 形式だったりして:

 1 {
 2     "inputs": [
 3         {
 4             "orig": "input1.mkv",
 5             "trim": ["00:07:04.9", "00:07:16.074"],
 6             "target_time": [
 7                 ["00:07:07.7", "00:07:11.9", 0],
 8                 ["00:07:11.9", "00:07:12.5", [1, "", "volume=0.0025"]],
 9                 ["00:07:15.0", "00:07:16.074", [1, "", "volume=0.0025"]]
10             ]
11         }
12         , {
13             "orig": "input2.mkv",
14             "trim": ["00:07:16.074", "00:12:53.3"],
15             "target_time": [
16                 ["00:07:57.0", "00:07:57.5", [1, "", "volume=0.0025"]],
17                 ["00:07:58.6", "00:07:59.5", [1, "", "volume=0.0025"]]
18             ]
19         }
20         , {
21             "orig": "input2.mkv",
22             "trim": ["00:12:53.3", "00:19:32.3"],
23             "target_time": [
24             ]
25         }
26     ]
27 }

ここの時刻部分を GUI にクリップボード経由でもっていきたい、って場合の話ね。たとえばコマンドラインの CUI に持ってくことも考えた場合、割合として引用符まで込みでクリップボードにコピーしたい場合の方が多いんだよね、ワタシの作業のスタイルの性質上。だからこれで引用符を入れてしまっただけで「全クリア」がもう、ストレスがたまっちゃってたまっちゃって。こりゃぁもう「核ミサイルの件」は気にせずフィルタしちまえよ、と思った、て話。