Coverage for graphica/gui/widgets/segmented.py: 94.4%

122 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 16:38 +0000

1"""セグメント(横に並んだ排他のボタン)と、今のコンボを値の持ち主にしたままの同期。""" 

2from PySide6.QtCore import QPointF, QSize, Qt, Signal 

3from PySide6.QtGui import QColor, QPainter, QPen 

4from PySide6.QtWidgets import QButtonGroup, QFrame, QHBoxLayout, QSizePolicy, QToolButton 

5 

6from graphica.gui import theme 

7 

8# matplotlib の線の種類と、それを描く Qt のペン 

9LINE_STYLE_PENS = { 

10 "-": Qt.PenStyle.SolidLine, 

11 "solid": Qt.PenStyle.SolidLine, 

12 "--": Qt.PenStyle.DashLine, 

13 "dashed": Qt.PenStyle.DashLine, 

14 ":": Qt.PenStyle.DotLine, 

15 "dotted": Qt.PenStyle.DotLine, 

16 "-.": Qt.PenStyle.DashDotLine, 

17 "dashdot": Qt.PenStyle.DashDotLine, 

18} 

19 

20 

21class _LineSegmentButton(QToolButton): 

22 """文字の代わりに線の見本を描く。色は描くたびにトークンから取るので、テーマを替えても描き直すだけでよい。""" 

23 

24 def __init__(self, pen_style, parent=None): 

25 super().__init__(parent) 

26 self._pen_style = pen_style 

27 

28 def sizeHint(self): 

29 return QSize(40, 24) 

30 

31 def paintEvent(self, event): 

32 super().paintEvent(event) 

33 painter = QPainter(self) 

34 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

35 pen = QPen(QColor(theme.current_tokens()["text_primary"])) 

36 pen.setWidthF(2.0) 

37 pen.setStyle(self._pen_style) 

38 pen.setCapStyle(Qt.PenCapStyle.FlatCap) 

39 painter.setPen(pen) 

40 y = self.height() / 2 

41 half = min(15.0, self.width() / 2 - 5) 

42 center = self.width() / 2 

43 painter.drawLine(QPointF(center - half, y), QPointF(center + half, y)) 

44 painter.end() 

45 

46 

47class SegmentedControl(QFrame): 

48 """QComboBox と同じ呼び方(currentIndex・itemData・findData・setCurrentIndex)ができる排他のボタンの列。""" 

49 

50 currentIndexChanged = Signal(int) 

51 

52 def __init__(self, parent=None): 

53 super().__init__(parent) 

54 self.setObjectName("segmented_control") 

55 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

56 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

57 layout = QHBoxLayout(self) 

58 layout.setContentsMargins(2, 2, 2, 2) 

59 layout.setSpacing(2) 

60 self._group = QButtonGroup(self) 

61 self._group.setExclusive(True) 

62 self._group.idClicked.connect(self.setCurrentIndex) 

63 self._items = [] # (text, data) 

64 self._current = -1 

65 

66 def _add_button(self, button, text, data, tooltip): 

67 button.setObjectName("segment") 

68 button.setCheckable(True) 

69 button.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

70 button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 

71 button.setCursor(Qt.CursorShape.PointingHandCursor) 

72 if tooltip: 

73 button.setToolTip(tooltip) 

74 index = len(self._items) 

75 self._items.append((text, data)) 

76 self._group.addButton(button, index) 

77 self.layout().addWidget(button) 

78 if self._current < 0: 

79 # QComboBox と同じく、最初の項目を足した時点でそれを選ぶ 

80 self.setCurrentIndex(0) 

81 return index 

82 

83 def addItem(self, text, data=None, tooltip=""): 

84 button = QToolButton(self) 

85 button.setText(text) 

86 button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly) 

87 return self._add_button(button, text, data, tooltip) 

88 

89 def addIconItem(self, icon, data=None, tooltip=""): 

90 button = QToolButton(self) 

91 button.setIcon(icon) 

92 button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) 

93 return self._add_button(button, "", data, tooltip) 

94 

95 def addLineStyleItem(self, line_style, data=None, tooltip=""): 

96 """線の見本のボタン。line_style は matplotlib の書き方('-'・'--'・':'・'-.' など)。""" 

97 button = _LineSegmentButton(LINE_STYLE_PENS[line_style], self) 

98 return self._add_button(button, "", line_style if data is None else data, tooltip) 

99 

100 def count(self): 

101 return len(self._items) 

102 

103 def itemText(self, index): 

104 return self._items[index][0] if 0 <= index < len(self._items) else "" 

105 

106 def itemData(self, index): 

107 return self._items[index][1] if 0 <= index < len(self._items) else None 

108 

109 def findData(self, data): 

110 for index, (_, item_data) in enumerate(self._items): 

111 if item_data == data: 

112 return index 

113 return -1 

114 

115 def currentIndex(self): 

116 return self._current 

117 

118 def currentData(self): 

119 return self.itemData(self._current) 

120 

121 def setCurrentIndex(self, index): 

122 if index < -1 or index >= len(self._items) or index == self._current: 

123 return 

124 self._current = index 

125 if index >= 0: 

126 self._group.button(index).setChecked(True) 

127 else: 

128 # 何も選ばない(コンボに見本に無い値が入っているとき)。排他を一時外さないとチェックを外せない 

129 self._group.setExclusive(False) 

130 for button in self._group.buttons(): 

131 button.setChecked(False) 

132 self._group.setExclusive(True) 

133 self.currentIndexChanged.emit(index) 

134 

135 def keyPressEvent(self, event): 

136 step = {Qt.Key.Key_Left: -1, Qt.Key.Key_Up: -1, Qt.Key.Key_Right: 1, Qt.Key.Key_Down: 1}.get(event.key()) 

137 if step is None or not self._items: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 super().keyPressEvent(event) 

139 return 

140 self.setCurrentIndex(max(0, min(len(self._items) - 1, self._current + step))) 

141 

142 

143class ComboMirror: 

144 """今のコンボを値の持ち主にしたまま、見た目の代わり(セグメントなど)と同期する。 

145 

146 見た目の側で選ぶとコンボの setCurrentIndex を呼ぶ(コンボのシグナルで今までどおり値が反映される)。 

147 コンボがシグナルを止めて書き換えられる(バインディングの「戻す」)と同期の知らせが来ないので、 

148 戻したあとに refresh() を呼ぶこと。コンボの値は itemData、無ければ表示の文字で突き合わせる。 

149 """ 

150 

151 def __init__(self, view, combo): 

152 self.view = view 

153 self.combo = combo 

154 combo.currentIndexChanged.connect(lambda _index: self.refresh()) 

155 view.currentIndexChanged.connect(self._on_view_changed) 

156 self.refresh() 

157 

158 def _combo_key(self, index): 

159 data = self.combo.itemData(index) 

160 return self.combo.itemText(index) if data is None else data 

161 

162 def refresh(self): 

163 index = self.view.findData(self._combo_key(self.combo.currentIndex())) 

164 blocked = self.view.blockSignals(True) 

165 self.view.setCurrentIndex(index) 

166 self.view.blockSignals(blocked) 

167 

168 def _on_view_changed(self, index): 

169 key = self.view.itemData(index) 

170 combo_index = self.combo.findData(key) 

171 if combo_index < 0 and isinstance(key, str): 

172 combo_index = self.combo.findText(key) 

173 if combo_index >= 0 and combo_index != self.combo.currentIndex(): 173 ↛ exitline 173 didn't return from function '_on_view_changed' because the condition on line 173 was always true

174 self.combo.setCurrentIndex(combo_index)