Coverage for graphica/gui/binding.py: 99.2%
97 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""画面の欄と設定の辞書のキーの対応(バインディング)を表で持ち、集める・戻す・信号を止める・つなぐを表から行う。
3表の順が戻す順になる。戻す順は値に効く(スピンボックスの範囲による丸め、途中の例外でどこまで入ったか)ので、
4行を並べ替えるときは戻したあとの欄の値が変わらないことを確かめる。
5"""
6from dataclasses import dataclass
7from typing import Any, Callable
10@dataclass(frozen=True)
11class Binding:
12 key: str
13 # 持ち主からの属性の道筋("ui.x_min_spinbox")。None は欄を持たず持ち主の属性に持つもの(フォントと色)
14 widget: str | None
15 # 欄から値を読む。None は戻すだけの欄(変更は専用の処理が受ける)
16 read: Callable[[Any, Any], Any] | None
17 write: Callable[[Any, Any, Any], None]
18 # 変更の信号の名前。None ならここではつながない
19 signal: str | None = None
20 # 信号につなぐ持ち主のメソッドの道筋("property_panel.on_x")か WATCH。つないだ順に呼ばれる
21 slots: tuple[Any, ...] = ()
22 # 保存元から値を取り出す。None ならキーで引く
23 load: Callable[[Any], Any] | None = None
24 # 打ち込んだ文字は確定(Enter・フォーカスを外す)のときだけ知らせる(connect_typed_text)
25 typed: bool = False
28# slots に置くと、Binder.connect に渡した watch(signal, widget) でつなぐ
29WATCH = object()
32def text(key, widget, slots):
33 return Binding(key, widget, lambda _o, w: w.text(), lambda _o, w, v: w.setText(v), 'textChanged', slots)
36def typed_text(key, widget, slots):
37 """1文字ごとに知らせると重い欄(大きなデータの描き直し)。slots は引数なしで呼ぶ。"""
38 return Binding(key, widget, lambda _o, w: w.text(), lambda _o, w, v: w.setText(v), 'textChanged', slots,
39 typed=True)
42def connect_typed_text(line_edit, slot):
43 """打ち込みは確定のときに1回だけ slot を呼ぶ。プログラムからの setText(isModified が偽)はすぐ呼ぶ。
45 editingFinished は何も変えずにフォーカスを外しても出るので、打ち込んだ(isModified)ときだけ呼ぶ。
46 """
47 def on_text_changed(*_args):
48 if not line_edit.isModified():
49 slot()
51 def on_editing_finished():
52 if line_edit.isModified():
53 line_edit.setModified(False)
54 slot()
56 line_edit.textChanged.connect(on_text_changed)
57 line_edit.editingFinished.connect(on_editing_finished)
60def check(key, widget, slots, signal='stateChanged'):
61 return Binding(key, widget, lambda _o, w: w.isChecked(), lambda _o, w, v: w.setChecked(v), signal, slots)
64def number(key, widget, slots):
65 return Binding(key, widget, lambda _o, w: w.value(), lambda _o, w, v: w.setValue(v), 'valueChanged', slots)
68def index(key, widget, slots):
69 return Binding(key, widget, lambda _o, w: w.currentIndex(), lambda _o, w, v: w.setCurrentIndex(v),
70 'currentIndexChanged', slots)
73def shown_text(key, widget, slots):
74 return Binding(key, widget, lambda _o, w: w.currentText(), lambda _o, w, v: w.setCurrentText(v),
75 'currentTextChanged', slots)
78def item_data(key, widget, slots):
79 """選択肢に持たせたデータ。見つからない値は先頭の選択肢にする。"""
80 def write(_owner, combo, value):
81 found = combo.findData(value)
82 combo.setCurrentIndex(found if found != -1 else 0)
83 return Binding(key, widget, lambda _o, w: w.currentData(), write, 'currentIndexChanged', slots)
86def found_text(key, widget, slots):
87 """選択肢の文字。見つからない値は先頭の選択肢にする。"""
88 def write(_owner, combo, value):
89 found = combo.findText(value)
90 combo.setCurrentIndex(found if found != -1 else 0)
91 return Binding(key, widget, lambda _o, w: w.currentText(), write, 'currentTextChanged', slots)
94def choice(key, widget, choices, slots):
95 """選択肢の番号と choices の値の対応。見つからない値は先頭の選択肢にする。"""
96 def write(_owner, combo, value):
97 combo.setCurrentIndex(choices.index(value) if value in choices else 0)
98 return Binding(key, widget, lambda _o, w: choices[w.currentIndex()], write, 'currentIndexChanged', slots)
101def resolve(owner, path):
102 obj = owner
103 for part in path.split('.'):
104 obj = getattr(obj, part)
105 return obj
108class Binder:
109 """持ち主(PlotterApp)と表から、集める・戻す・止める・つなぐを行う。欄は組み立ての途中で差し替わるので毎回引く。"""
111 def __init__(self, owner, bindings, also_blocked=()):
112 self._owner = owner
113 self._bindings = tuple(bindings)
114 # 表に無いが、戻す間は信号を止める欄(フォントと色のボタン)
115 self._also_blocked = tuple(also_blocked)
117 @property
118 def bindings(self):
119 return self._bindings
121 def _widget(self, binding):
122 return None if binding.widget is None else resolve(self._owner, binding.widget)
124 def gather(self):
125 return {b.key: b.read(self._owner, self._widget(b)) for b in self._bindings}
127 def binding_for(self, widget):
128 """欄から値を読む行のうち、その欄のもの。無ければ None。"""
129 for binding in self._bindings:
130 if binding.read is not None and binding.widget is not None and self._widget(binding) is widget:
131 return binding
132 return None
134 def read(self, binding):
135 return binding.read(self._owner, self._widget(binding))
137 def restore(self, source, lookup):
138 """source の値を表の順に戻す(行に load が無ければ lookup(source, key) で引く)。
139 例外はそのまま上げる(そこまでに入れた欄はそのまま残る)。"""
140 for binding in self._bindings:
141 value = binding.load(source) if binding.load is not None else lookup(source, binding.key)
142 binding.write(self._owner, self._widget(binding), value)
144 def block_signals(self, block):
145 for binding in self._bindings:
146 if binding.widget is not None:
147 self._widget(binding).blockSignals(block)
148 for path in self._also_blocked:
149 resolve(self._owner, path).blockSignals(block)
151 def connect(self, watch=None):
152 for binding in self._bindings:
153 if binding.signal is None:
154 continue
155 widget = self._widget(binding)
156 if binding.typed:
157 for slot in binding.slots:
158 connect_typed_text(widget, resolve(self._owner, slot))
159 continue
160 signal = getattr(widget, binding.signal)
161 for slot in binding.slots:
162 if slot is WATCH:
163 watch(signal, widget)
164 else:
165 signal.connect(resolve(self._owner, slot))