Coverage for graphica/gui/color_picker_widget.py: 76.6%

156 statements  

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

1"""データセットの色の欄: 色見本のボタン(クリックで色を選ぶ)と、#RRGGBB の入力欄。""" 

2from PySide6.QtCore import Signal, Qt 

3from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap 

4from PySide6.QtWidgets import (QWidget, QHBoxLayout, QPushButton, QLineEdit, 

5 QMenu) 

6 

7from graphica.gui import notify 

8from graphica.core.i18n import tr 

9from graphica.core.named_colors import ( 

10 POPUP_LIMIT, NamedColorError, add_named_color, load_named_colors, 

11 save_named_colors, 

12) 

13from graphica.gui.color_history import get_color_with_history 

14 

15_SWATCH_ICON_SIZE = 14 

16 

17DEFAULT_COLOR = "#1f77b4" 

18QUICK_CHIP_SIZE = 18 

19 

20 

21class ColorPickerWidget(QWidget): 

22 """colorChanged(str) は利用者の操作で色が変わったときだけ出る(set_color() では出ない)。""" 

23 colorChanged = Signal(str) 

24 

25 def __init__(self, settings, parent=None, initial_color=DEFAULT_COLOR): 

26 super().__init__(parent) 

27 self._settings = settings 

28 self._color = QColor(initial_color) 

29 

30 layout = QHBoxLayout(self) 

31 layout.setContentsMargins(0, 0, 0, 0) 

32 layout.setSpacing(6) 

33 

34 self.swatch_button = QPushButton(self) 

35 self.swatch_button.setFixedSize(30, 24) 

36 self.swatch_button.setCursor(Qt.CursorShape.PointingHandCursor) 

37 self.swatch_button.setToolTip(tr("クリックしてパレットを開く")) 

38 self.swatch_button.clicked.connect(self._on_swatch_clicked) 

39 

40 self.hex_edit = QLineEdit(self) 

41 self.hex_edit.setPlaceholderText("#RRGGBB") 

42 self.hex_edit.setMaxLength(7) 

43 self.hex_edit.editingFinished.connect(self._on_hex_edited) 

44 self.hex_edit.textChanged.connect(self._on_hex_text_changed) 

45 

46 layout.addWidget(self.swatch_button) 

47 layout.addWidget(self.hex_edit, 1) 

48 # 今のパレットの色(show_quick_colors で出す。出さない欄では None のまま) 

49 self.quick_colors_row = None 

50 

51 self._update_swatch() 

52 self.hex_edit.setText(self._color.name()) 

53 

54 def color(self): 

55 return QColor(self._color) 

56 

57 def color_name(self): 

58 return self._color.name() 

59 

60 def set_color(self, color): 

61 """表示だけ変える(colorChanged は出ない)。""" 

62 new_color = QColor(color) 

63 if not new_color.isValid(): 

64 return 

65 self._color = new_color 

66 self._update_swatch() 

67 if self.quick_colors_row is not None: 

68 self.quick_colors_row.set_current_color(self._color.name()) 

69 self.hex_edit.blockSignals(True) 

70 self.hex_edit.setText(self._color.name()) 

71 self.hex_edit.blockSignals(False) 

72 

73 def show_quick_colors(self, colors): 

74 """見本と入力欄の間に、押すとすぐその色になるチップを並べる(色が同じなら作り直さない)。""" 

75 from graphica.gui.widgets import ColorChipRow 

76 if self.quick_colors_row is None: 

77 self.quick_colors_row = ColorChipRow(chip_size=QUICK_CHIP_SIZE, parent=self) 

78 self.quick_colors_row.colorPicked.connect(self._apply_color_name) 

79 self.layout().insertWidget(1, self.quick_colors_row) 

80 # チップが並ぶと幅が足りないので、入力欄は #RRGGBB が入るだけの幅にする 

81 # 文字幅に、QSS の入力欄の左右の余白と枠の分を足す 

82 self.hex_edit.setFixedWidth(self.hex_edit.fontMetrics().horizontalAdvance("#DDDDDD") + 30) 

83 self.layout().setStretchFactor(self.hex_edit, 0) 

84 self.layout().setStretchFactor(self.quick_colors_row, 1) 

85 normalized = [QColor(c).name().upper() for c in colors] 

86 if normalized != self.quick_colors_row.colors(): 

87 self.quick_colors_row.set_colors(normalized) 

88 self.quick_colors_row.set_current_color(self._color.name()) 

89 

90 def blockSignals(self, block): 

91 # ほかの欄と同じく blockSignals で値を入れられるよう、中の部品にも伝える 

92 self.swatch_button.blockSignals(block) 

93 self.hex_edit.blockSignals(block) 

94 return super().blockSignals(block) 

95 

96 def _update_swatch(self, preview_color=None): 

97 # preview_color は入力中(未確定)の色。None なら確定した self._color 

98 from graphica.gui import theme 

99 border_color = theme.current_tokens()["border_strong"] 

100 color_name = (preview_color or self._color).name() 

101 self.swatch_button.setStyleSheet( 

102 f"background-color: {color_name};" 

103 f"border: 1px solid {border_color};" 

104 f"border-radius: 4px;" 

105 ) 

106 

107 def refresh_theme(self): 

108 """ダークモードの切り替えで枠の色を当て直す(塗りはデータの色なのでテーマと関係ない)。""" 

109 self._update_swatch() 

110 

111 def _on_swatch_clicked(self): 

112 """登録した色(core/named_colors.py)を先に並べ、自由な色選びは「その他の色...」から。""" 

113 menu = QMenu(self) 

114 entries = load_named_colors(self._settings) 

115 

116 # 登録は際限なく増えるので、並べるのは先頭 POPUP_LIMIT 件まで。残りは検索できる一覧から選ぶ 

117 for entry in entries[:POPUP_LIMIT]: 

118 action = menu.addAction(_color_icon(entry["color"]), 

119 f'{entry["name"]} {entry["color"]}') 

120 action.setData(entry["color"]) 

121 action.triggered.connect( 

122 lambda checked=False, c=entry["color"]: self._apply_color_name(c)) 

123 if len(entries) > POPUP_LIMIT: 

124 more_action = menu.addAction( 

125 tr("すべての登録色... (%d件)") % len(entries)) 

126 more_action.triggered.connect(self._on_choose_from_all_named_colors) 

127 if entries: 

128 menu.addSeparator() 

129 

130 register_action = menu.addAction(tr("この色を登録...")) 

131 register_action.triggered.connect(self._on_register_current_color) 

132 manage_action = menu.addAction(tr("色名の管理...")) 

133 manage_action.triggered.connect(self._on_manage_named_colors) 

134 menu.addSeparator() 

135 other_action = menu.addAction(tr("その他の色...")) 

136 other_action.triggered.connect(self._on_pick_other_color) 

137 

138 # menu が exec() の間生きていることに頼っている(データセットの右クリックメニューと同じ) 

139 menu.exec(self.swatch_button.mapToGlobal( 

140 self.swatch_button.rect().bottomLeft())) 

141 

142 def _apply_color_name(self, color_name): 

143 """値が変わったときだけ知らせる。""" 

144 candidate = QColor(color_name) 

145 if not candidate.isValid() or candidate.name() == self._color.name(): 

146 return 

147 self.set_color(candidate) 

148 self.colorChanged.emit(self._color.name()) 

149 

150 def _on_choose_from_all_named_colors(self): 

151 from graphica.gui.dialogs import NamedColorPickerDialog 

152 dialog = NamedColorPickerDialog(self._settings, self) 

153 if dialog.exec() != dialog.DialogCode.Accepted: 

154 return 

155 entry = dialog.selected_entry() 

156 if entry: 

157 self._apply_color_name(entry["color"]) 

158 

159 def _on_pick_other_color(self): 

160 color = get_color_with_history(self._settings, self, initial=self._color) 

161 if not color.isValid(): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 return 

163 if color.name() == self._color.name(): 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return 

165 self.set_color(color) 

166 self.colorChanged.emit(self._color.name()) 

167 

168 def _on_register_current_color(self): 

169 name, ok = notify.get_text( 

170 self, tr("色を登録"), 

171 tr("この色の登録名 (%s)") % self._color.name()) 

172 if not ok: 

173 return 

174 entries = load_named_colors(self._settings) 

175 try: 

176 entries = add_named_color(entries, name, self._color.name()) 

177 except NamedColorError as e: 

178 notify.warning(self, tr("色を登録"), str(e)) 

179 return 

180 save_named_colors(self._settings, entries) 

181 

182 def _on_manage_named_colors(self): 

183 """dialogs がこのモジュールを import しているので、ここで遅れて import する。""" 

184 from graphica.gui.dialogs import NamedColorManagerDialog 

185 dialog = NamedColorManagerDialog(self._settings, self) 

186 dialog.exec() 

187 

188 def _on_hex_text_changed(self, text): 

189 """入力中でも有効な色になったら色見本に先に出す。確定(反映・Undo・colorChanged)は _on_hex_edited だけ(打つたびに Undo を積まないように)。""" 

190 candidate = QColor(text.strip()) 

191 if candidate.isValid(): 

192 self._update_swatch(preview_color=candidate) 

193 else: 

194 self._update_swatch() 

195 

196 def _on_hex_edited(self): 

197 text = self.hex_edit.text().strip() 

198 candidate = QColor(text) 

199 if not candidate.isValid(): 

200 # 無効な入力なら今の色の表記に戻す 

201 self.hex_edit.setText(self._color.name()) 

202 return 

203 if candidate.name() == self._color.name(): 

204 return 

205 self.set_color(candidate) 

206 self.colorChanged.emit(self._color.name()) 

207 

208 

209def _color_icon(color_name, size=_SWATCH_ICON_SIZE): 

210 """メニューの色見本。白や淡い色が背景に溶けないよう1pxの枠を描く。""" 

211 pixmap = QPixmap(size, size) 

212 pixmap.fill(Qt.GlobalColor.transparent) 

213 painter = QPainter(pixmap) 

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

215 from graphica.gui import theme 

216 painter.setPen(QPen(QColor(theme.current_tokens()["border_strong"]), 1)) 

217 painter.setBrush(QColor(color_name)) 

218 painter.drawRoundedRect(0, 0, size - 1, size - 1, 3, 3) 

219 painter.end() 

220 return QIcon(pixmap)