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

1"""画面の欄と設定の辞書のキーの対応(バインディング)を表で持ち、集める・戻す・信号を止める・つなぐを表から行う。 

2 

3表の順が戻す順になる。戻す順は値に効く(スピンボックスの範囲による丸め、途中の例外でどこまで入ったか)ので、 

4行を並べ替えるときは戻したあとの欄の値が変わらないことを確かめる。 

5""" 

6from dataclasses import dataclass 

7from typing import Any, Callable 

8 

9 

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 

26 

27 

28# slots に置くと、Binder.connect に渡した watch(signal, widget) でつなぐ 

29WATCH = object() 

30 

31 

32def text(key, widget, slots): 

33 return Binding(key, widget, lambda _o, w: w.text(), lambda _o, w, v: w.setText(v), 'textChanged', slots) 

34 

35 

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) 

40 

41 

42def connect_typed_text(line_edit, slot): 

43 """打ち込みは確定のときに1回だけ slot を呼ぶ。プログラムからの setText(isModified が偽)はすぐ呼ぶ。 

44 

45 editingFinished は何も変えずにフォーカスを外しても出るので、打ち込んだ(isModified)ときだけ呼ぶ。 

46 """ 

47 def on_text_changed(*_args): 

48 if not line_edit.isModified(): 

49 slot() 

50 

51 def on_editing_finished(): 

52 if line_edit.isModified(): 

53 line_edit.setModified(False) 

54 slot() 

55 

56 line_edit.textChanged.connect(on_text_changed) 

57 line_edit.editingFinished.connect(on_editing_finished) 

58 

59 

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) 

62 

63 

64def number(key, widget, slots): 

65 return Binding(key, widget, lambda _o, w: w.value(), lambda _o, w, v: w.setValue(v), 'valueChanged', slots) 

66 

67 

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) 

71 

72 

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) 

76 

77 

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) 

84 

85 

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) 

92 

93 

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) 

99 

100 

101def resolve(owner, path): 

102 obj = owner 

103 for part in path.split('.'): 

104 obj = getattr(obj, part) 

105 return obj 

106 

107 

108class Binder: 

109 """持ち主(PlotterApp)と表から、集める・戻す・止める・つなぐを行う。欄は組み立ての途中で差し替わるので毎回引く。""" 

110 

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) 

116 

117 @property 

118 def bindings(self): 

119 return self._bindings 

120 

121 def _widget(self, binding): 

122 return None if binding.widget is None else resolve(self._owner, binding.widget) 

123 

124 def gather(self): 

125 return {b.key: b.read(self._owner, self._widget(b)) for b in self._bindings} 

126 

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 

133 

134 def read(self, binding): 

135 return binding.read(self._owner, self._widget(binding)) 

136 

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) 

143 

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) 

150 

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))