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
« 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)
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
15_SWATCH_ICON_SIZE = 14
17DEFAULT_COLOR = "#1f77b4"
18QUICK_CHIP_SIZE = 18
21class ColorPickerWidget(QWidget):
22 """colorChanged(str) は利用者の操作で色が変わったときだけ出る(set_color() では出ない)。"""
23 colorChanged = Signal(str)
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)
30 layout = QHBoxLayout(self)
31 layout.setContentsMargins(0, 0, 0, 0)
32 layout.setSpacing(6)
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)
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)
46 layout.addWidget(self.swatch_button)
47 layout.addWidget(self.hex_edit, 1)
48 # 今のパレットの色(show_quick_colors で出す。出さない欄では None のまま)
49 self.quick_colors_row = None
51 self._update_swatch()
52 self.hex_edit.setText(self._color.name())
54 def color(self):
55 return QColor(self._color)
57 def color_name(self):
58 return self._color.name()
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)
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())
90 def blockSignals(self, block):
91 # ほかの欄と同じく blockSignals で値を入れられるよう、中の部品にも伝える
92 self.swatch_button.blockSignals(block)
93 self.hex_edit.blockSignals(block)
94 return super().blockSignals(block)
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 )
107 def refresh_theme(self):
108 """ダークモードの切り替えで枠の色を当て直す(塗りはデータの色なのでテーマと関係ない)。"""
109 self._update_swatch()
111 def _on_swatch_clicked(self):
112 """登録した色(core/named_colors.py)を先に並べ、自由な色選びは「その他の色...」から。"""
113 menu = QMenu(self)
114 entries = load_named_colors(self._settings)
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()
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)
138 # menu が exec() の間生きていることに頼っている(データセットの右クリックメニューと同じ)
139 menu.exec(self.swatch_button.mapToGlobal(
140 self.swatch_button.rect().bottomLeft()))
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())
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"])
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())
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)
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()
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()
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())
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)