Coverage for graphica/gui/dialogs/appearance.py: 91.8%
625 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"""見た目と注釈のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。"""
3import re
4from PySide6.QtWidgets import (
5 QCheckBox,
6 QComboBox,
7 QDialog,
8 QDialogButtonBox,
9 QDoubleSpinBox,
10 QFormLayout,
11 QGridLayout,
12 QHBoxLayout,
13 QLabel,
14 QLineEdit,
15 QListWidget,
16 QListWidgetItem,
17 QMenu,
18 QMessageBox,
19 QPushButton,
20 QToolButton,
21 QVBoxLayout,
22 QWidget,
23 QWidgetAction,
24)
25from PySide6.QtCore import QSize, Qt
26from PySide6.QtGui import QColor, QFont, QIcon, QPixmap
27from graphica.gui import notify
28from graphica.gui import icon_utils
29from graphica.gui.color_history import ask_color, get_color_with_history
30from graphica.gui.theme import apply_form_spacing
31from graphica.gui.mathtext_preview import FitWidthPixmapLabel
32from graphica.core.i18n import tr
33from graphica.core.color_palettes import BUILTIN_PALETTES, DEFAULT_PALETTE_NAME, default_color_cycle
34from graphica.core.named_colors import (
35 NamedColorError,
36 add_named_color,
37 load_named_colors,
38 move_named_color,
39 remove_named_color,
40 save_named_colors,
41 update_named_color,
42)
45class LabelEditDialog(QDialog):
46 """タイトルと軸ラベルの編集。装飾ボタンは選択範囲を mathtext で包み、記号はカーソル位置に入れる。"""
48 def __init__(self, initial_text, window_title, symbol_palette, parent=None):
49 """symbol_palette は main_window の LABEL_SYMBOL_PALETTE(dialogs は main_window を import できないので渡してもらう)。"""
50 super().__init__(parent)
51 self.setWindowTitle(window_title)
52 self.setMinimumWidth(420)
54 layout = QVBoxLayout(self)
56 self.text_edit = QLineEdit(initial_text)
57 self.text_edit.setMinimumHeight(34)
58 larger_font = QFont(self.text_edit.font())
59 larger_font.setPointSize(larger_font.pointSize() + 1)
60 self.text_edit.setFont(larger_font)
61 layout.addWidget(self.text_edit)
63 # QLineEdit では装飾を見せられないので、描いた結果を下に出す
64 self.preview_label = FitWidthPixmapLabel()
65 self.preview_label.setObjectName("mathtext_preview_label")
66 self.preview_label.setMinimumHeight(36)
67 self.preview_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
68 layout.addWidget(self.preview_label)
69 self.text_edit.textChanged.connect(self._refresh_preview)
71 button_row = QHBoxLayout()
72 button_row.setSpacing(4)
74 def _make_icon_button(icon_name, tooltip):
75 button = QPushButton()
76 button.setIcon(icon_utils.icon(icon_name, size=16))
77 button.setToolTip(tooltip)
78 button.setProperty("iconOnly", True)
79 button.setFixedSize(28, 28)
80 # フォーカスを取るとテキスト欄の選択が消える(下の説明)
81 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
82 button_row.addWidget(button)
83 return button
85 bold_button = _make_icon_button("bold", "太字")
86 italic_button = _make_icon_button("italic", "イタリック")
87 superscript_button = _make_icon_button("superscript", "上付き文字")
88 subscript_button = _make_icon_button("subscript", "下付き文字")
90 # ボタンがフォーカスを取ると、pressed が出るより前にテキスト欄の選択が消える。
91 # だから装飾ボタンはフォーカスを取らない。pressed で選択を控えるのは、それでも移る環境のための保険
92 self._pending_selection = None # (start, selected_text)、選択が無ければ None
93 self._pending_cursor = 0
95 for button in (bold_button, italic_button, superscript_button, subscript_button):
96 button.pressed.connect(self._capture_pending_selection)
98 bold_button.clicked.connect(lambda: self._apply_wrap("bold", lambda s: f"\\mathbf{{{s}}}"))
99 italic_button.clicked.connect(lambda: self._apply_wrap("italic", lambda s: f"\\mathit{{{s}}}"))
100 superscript_button.clicked.connect(lambda: self._apply_wrap("super", lambda s: f"{{}}^{{{s}}}"))
101 subscript_button.clicked.connect(lambda: self._apply_wrap("sub", lambda s: f"{{}}_{{{s}}}"))
103 # 記号は32個あり、常に並べると場所を取るのでドロップダウンにする
104 symbol_button = QToolButton()
105 symbol_button.setText("Ω")
106 symbol_button.setToolTip("ギリシャ文字・数学記号を挿入")
107 symbol_button.setProperty("iconOnly", True)
108 symbol_button.setFixedSize(28, 28)
109 symbol_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
110 # 既定でも NoFocus だが、装飾ボタンと同じ理由で明示する
111 symbol_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
112 # メニューを開く動作でも選択が消えうるので、開く前に控える
113 symbol_button.pressed.connect(self._capture_pending_selection)
115 symbol_menu = QMenu(symbol_button)
116 symbol_panel = QWidget()
117 symbol_grid = QGridLayout(symbol_panel)
118 symbol_grid.setContentsMargins(6, 6, 6, 6)
119 symbol_grid.setSpacing(2)
120 # 既定の大きさでは記号が読みにくい
121 symbol_font = QFont(self.font())
122 symbol_font.setPointSize(symbol_font.pointSize() + 4)
123 for index, (glyph, macro) in enumerate(symbol_palette):
124 item_button = QToolButton()
125 item_button.setText(glyph)
126 item_button.setFont(symbol_font)
127 # macro=None は mathtext のマクロを持たない文字そのもの(マイナス記号など)
128 item_button.setToolTip(f"\\{macro}" if macro else glyph)
129 item_button.setFixedSize(30, 30)
130 item_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
131 item_button.clicked.connect(
132 lambda checked=False, g=glyph, m=macro, sm=symbol_menu: (self._insert_symbol(g, m), sm.close())
133 )
134 symbol_grid.addWidget(item_button, index // 4, index % 4)
135 symbol_widget_action = QWidgetAction(symbol_button)
136 symbol_widget_action.setDefaultWidget(symbol_panel)
137 symbol_menu.addAction(symbol_widget_action)
138 symbol_button.setMenu(symbol_menu)
139 button_row.addWidget(symbol_button)
141 button_row.addStretch(1)
142 layout.addLayout(button_row)
144 button_box = QDialogButtonBox(
145 QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
146 )
147 button_box.accepted.connect(self.accept)
148 button_box.rejected.connect(self.reject)
149 layout.addWidget(button_box)
151 self._refresh_preview()
153 def _refresh_preview(self):
154 """テキスト欄の内容を描いてプレビューに出す(タイトル欄のプレビューと同じ描き方)。"""
155 from graphica.gui import theme
156 from graphica.gui.mathtext_preview import render_mathtext_to_pixmap
158 tokens = theme.current_tokens()
159 text = self.text_edit.text()
160 color = tokens["text_primary"] if text else tokens["text_muted"]
161 pixmap = render_mathtext_to_pixmap(text if text else " ", color=color, fontsize=15)
162 self.preview_label.set_natural_pixmap(pixmap)
164 def _capture_pending_selection(self):
165 """ボタンが押された瞬間の選択範囲を控える(clicked まで待つと消えている環境がある)。"""
166 if self.text_edit.hasSelectedText():
167 self._pending_selection = (
168 self.text_edit.selectionStart(), self.text_edit.selectedText()
169 )
170 else:
171 self._pending_selection = None
172 self._pending_cursor = self.text_edit.cursorPosition()
174 # 選択が $...$ で囲んだ1つの断片か(直前の装飾の結果をさらに装飾するとき)
175 _MATH_SPAN_RE = re.compile(r'^\$(.*)\$$', re.DOTALL)
176 _MATHBF_RE = re.compile(r'^\\mathbf\{(.*)\}$', re.DOTALL)
177 _MATHIT_RE = re.compile(r'^\\mathit\{(.*)\}$', re.DOTALL)
178 _BOLDSYMBOL_RE = re.compile(r'^\\boldsymbol\{(.*)\}$', re.DOTALL)
179 _SUPER_RE = re.compile(r'^\{\}\^\{(.*)\}$', re.DOTALL)
180 _SUB_RE = re.compile(r'^\{\}_\{(.*)\}$', re.DOTALL)
182 def _apply_wrap(self, kind, wrap_fn):
183 """選択範囲を wrap_fn の中身で包み、$...$ で囲む。kind は "bold" / "italic" / "super" / "sub"。
185 選択が既に $...$ の断片なら、中身を取り出してから包み直す($ が入れ子になると壊れた構文になる)。
186 \\mathbf と \\mathit は入れ子にしても合成されないので、両方なら \\boldsymbol にする。
187 同じ装飾がもう外側にかかっていれば外す(連打で入れ子が積み重ならないように)。
188 装飾が無くなれば $ も外す(mathtext の中の裸の文字は斜体になる)。
189 """
190 if not self._pending_selection:
191 # 選択が無ければ何もしない(案内は出さない)
192 return
193 start, selected = self._pending_selection
194 span_match = self._MATH_SPAN_RE.match(selected)
195 inner = span_match.group(1) if span_match else selected
197 combined = None
198 if kind == "bold":
199 m = self._BOLDSYMBOL_RE.match(inner)
200 if m: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 combined = f"\\mathit{{{m.group(1)}}}" # 太字だけ外し、イタリックは残す
202 else:
203 m = self._MATHIT_RE.match(inner)
204 if m:
205 combined = f"\\boldsymbol{{{m.group(1)}}}"
206 else:
207 m = self._MATHBF_RE.match(inner)
208 if m:
209 combined = m.group(1)
210 elif kind == "italic":
211 m = self._BOLDSYMBOL_RE.match(inner)
212 if m:
213 combined = f"\\mathbf{{{m.group(1)}}}" # イタリックだけ外し、太字は残す
214 else:
215 m = self._MATHBF_RE.match(inner)
216 if m:
217 combined = f"\\boldsymbol{{{m.group(1)}}}"
218 else:
219 m = self._MATHIT_RE.match(inner)
220 if m: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 combined = m.group(1)
222 elif kind == "super": 222 ↛ 226line 222 didn't jump to line 226 because the condition on line 222 was always true
223 m = self._SUPER_RE.match(inner)
224 if m:
225 combined = m.group(1)
226 elif kind == "sub":
227 m = self._SUB_RE.match(inner)
228 if m:
229 combined = m.group(1)
231 new_inner = combined if combined is not None else wrap_fn(inner)
232 if re.search(r'[\\^_{}]', new_inner):
233 replacement = f"${new_inner}$"
234 else:
235 replacement = new_inner
236 text = self.text_edit.text()
237 self.text_edit.setText(text[:start] + replacement + text[start + len(selected):])
238 self.text_edit.setSelection(start, len(replacement))
240 def _insert_symbol(self, glyph, macro):
241 """記号を選択範囲に置き換えるか、カーソル位置に入れる。macro が None なら $ で包まない(包むと斜体になる)。"""
242 text = self.text_edit.text()
243 if self._pending_selection:
244 start, selected = self._pending_selection
245 end = start + len(selected)
246 else:
247 start = end = self._pending_cursor
248 replacement = glyph if macro is None else f"$\\{macro}$"
249 self.text_edit.setText(text[:start] + replacement + text[end:])
250 self.text_edit.setCursorPosition(start + len(replacement))
252 def get_text(self):
253 return self.text_edit.text()
256class ColorPaletteDialog(QDialog):
257 """「自動配色」のパレットを作り、選ぶ。「Matplotlib既定」と組み込みのパレットは読み取り専用。"""
259 DEFAULT_PALETTE_NAME = DEFAULT_PALETTE_NAME
261 def __init__(self, palettes: dict, active_name: str, parent=None):
262 super().__init__(parent)
263 self.setWindowTitle("配色パレットの管理")
264 self.resize(420, 440)
266 # キャンセルしたとき元に戻せるよう、呼び出し側の辞書は変えない
267 self.palettes = {name: list(colors) for name, colors in palettes.items()}
269 layout = QVBoxLayout(self)
271 combo_layout = QHBoxLayout()
272 combo_layout.addWidget(QLabel("パレット"))
273 self.palette_combo = QComboBox()
274 self.palette_combo.addItem(self.DEFAULT_PALETTE_NAME)
275 self.palette_combo.addItems(sorted(BUILTIN_PALETTES.keys()))
276 self.palette_combo.addItems(sorted(self.palettes.keys()))
277 combo_layout.addWidget(self.palette_combo, stretch=1)
278 layout.addLayout(combo_layout)
280 palette_button_layout = QHBoxLayout()
281 self.new_palette_button = QPushButton("新規パレット...")
282 self.rename_palette_button = QPushButton("名前を変更...")
283 self.delete_palette_button = QPushButton("削除")
284 palette_button_layout.addWidget(self.new_palette_button)
285 palette_button_layout.addWidget(self.rename_palette_button)
286 palette_button_layout.addWidget(self.delete_palette_button)
287 layout.addLayout(palette_button_layout)
289 self.color_list = QListWidget()
290 layout.addWidget(self.color_list)
292 color_button_layout = QHBoxLayout()
293 self.add_color_button = QPushButton("色を追加...")
294 self.remove_color_button = QPushButton("選択した色を削除")
295 color_button_layout.addWidget(self.add_color_button)
296 color_button_layout.addWidget(self.remove_color_button)
297 color_button_layout.addStretch()
298 layout.addLayout(color_button_layout)
300 info_label = QLabel("「OK」を押すと、選択中のパレットが以後「自動配色」ボタンで使われます。")
301 info_label.setWordWrap(True)
302 layout.addWidget(info_label)
304 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
305 QDialogButtonBox.StandardButton.Cancel)
306 button_box.accepted.connect(self.accept)
307 button_box.rejected.connect(self.reject)
308 layout.addWidget(button_box)
310 self.palette_combo.currentTextChanged.connect(self._on_palette_selected)
311 self.new_palette_button.clicked.connect(self._on_new_palette)
312 self.rename_palette_button.clicked.connect(self._on_rename_palette)
313 self.delete_palette_button.clicked.connect(self._on_delete_palette)
314 self.add_color_button.clicked.connect(self._on_add_color)
315 self.remove_color_button.clicked.connect(self._on_remove_color)
316 self.color_list.currentRowChanged.connect(lambda _row: self._update_button_states())
318 # 組み込みのパレットも初期の選択にする(でないと既定の表示のまま OK を押して上書きしてしまう)
319 if active_name in self.palettes or active_name in BUILTIN_PALETTES:
320 self.palette_combo.setCurrentText(active_name)
321 else:
322 self.palette_combo.setCurrentText(self.DEFAULT_PALETTE_NAME)
323 self._refresh_color_list()
324 self._update_button_states()
326 def _is_default_selected(self):
327 return self._is_readonly_palette(self.palette_combo.currentText())
329 def _is_readonly_palette(self, name):
330 """「Matplotlib既定」か組み込みのパレット(どちらも編集できない)。"""
331 return name == self.DEFAULT_PALETTE_NAME or name in BUILTIN_PALETTES
333 def _update_button_states(self):
334 editable = not self._is_default_selected()
335 self.rename_palette_button.setEnabled(editable)
336 self.delete_palette_button.setEnabled(editable)
337 self.add_color_button.setEnabled(editable)
338 self.remove_color_button.setEnabled(editable and self.color_list.currentRow() >= 0)
340 def _refresh_color_list(self):
341 # 行は QSS に任せず色見本+テーマの文字色のラベルにする。QSS で ::item に何か当てると、
342 # setBackground / setForeground が無視され、明るい色や暗い色の行が読めなくなる
343 self.color_list.clear()
344 name = self.palette_combo.currentText()
345 if name == self.DEFAULT_PALETTE_NAME:
346 colors = default_color_cycle()
347 elif name in BUILTIN_PALETTES:
348 colors = BUILTIN_PALETTES[name]
349 else:
350 colors = self.palettes.get(name, [])
351 from graphica.gui import theme
352 border_color = theme.current_tokens()["border_strong"]
353 for color_hex in colors:
354 item = QListWidgetItem()
355 self.color_list.addItem(item)
357 row_widget = QWidget()
358 row_layout = QHBoxLayout(row_widget)
359 row_layout.setContentsMargins(6, 2, 6, 2)
360 row_layout.setSpacing(8)
362 swatch = QLabel()
363 swatch.setFixedSize(20, 20)
364 swatch.setStyleSheet(
365 f"background-color: {color_hex}; border: 1px solid {border_color}; "
366 f"border-radius: 4px;"
367 )
368 row_layout.addWidget(swatch)
369 row_layout.addWidget(QLabel(color_hex), 1)
371 item.setSizeHint(row_widget.sizeHint())
372 self.color_list.setItemWidget(item, row_widget)
374 def _on_palette_selected(self, _name):
375 self._refresh_color_list()
376 self._update_button_states()
378 def _on_new_palette(self):
379 name, ok = notify.get_text(self, "新規パレット", "パレット名")
380 if not ok or not name:
381 return
382 if self._is_readonly_palette(name) or name in self.palettes:
383 notify.warning(self, "エラー", f"パレット名 '{name}' は既に使われています。")
384 return
385 self.palettes[name] = []
386 self.palette_combo.addItem(name)
387 self.palette_combo.setCurrentText(name)
389 def _on_rename_palette(self):
390 old_name = self.palette_combo.currentText()
391 if self._is_readonly_palette(old_name):
392 return
393 new_name, ok = notify.get_text(self, "名前を変更", "新しいパレット名", text=old_name)
394 if not ok or not new_name or new_name == old_name: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 return
396 if self._is_readonly_palette(new_name) or new_name in self.palettes:
397 notify.warning(self, "エラー", f"パレット名 '{new_name}' は既に使われています。")
398 return
399 self.palettes[new_name] = self.palettes.pop(old_name)
400 self.palette_combo.setItemText(self.palette_combo.currentIndex(), new_name)
402 def _on_delete_palette(self):
403 name = self.palette_combo.currentText()
404 if self._is_readonly_palette(name):
405 return
406 reply = notify.question(
407 self, "パレットを削除", f"パレット '{name}' を削除しますか?",
408 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
409 )
410 if reply != QMessageBox.StandardButton.Yes:
411 return
412 del self.palettes[name]
413 self.palette_combo.removeItem(self.palette_combo.currentIndex())
415 def _on_add_color(self):
416 name = self.palette_combo.currentText()
417 if self._is_readonly_palette(name):
418 return
419 color = ask_color(self)
420 if not color.isValid():
421 return
422 self.palettes.setdefault(name, []).append(color.name())
423 self._refresh_color_list()
425 def _on_remove_color(self):
426 name = self.palette_combo.currentText()
427 if self._is_readonly_palette(name):
428 return
429 row = self.color_list.currentRow()
430 if row < 0:
431 return
432 del self.palettes[name][row]
433 self._refresh_color_list()
435 def get_result(self):
436 """(パレットの辞書, 選んだパレット名)。QSettings への保存は呼び出し側。"""
437 return self.palettes, self.palette_combo.currentText()
440def _named_color_icon(color_name, size=16):
441 """リスト行の先頭に出す色見本(色欄のポップアップと同じ見た目に揃える)。"""
442 from graphica.gui.color_picker_widget import _color_icon
443 return _color_icon(color_name, size=size)
446class NamedColorManagerDialog(QDialog):
447 """名前を付けて登録した色(core/named_colors.py)の管理。配色パレット(色の並び)とは別物。
449 登録簿を育てる操作で、プロットの見た目は変えないので、OK を待たずにその場で保存する。
450 """
452 def __init__(self, settings, parent=None):
453 super().__init__(parent)
454 self.setWindowTitle(tr("色名の管理"))
455 self.resize(420, 400)
456 self._settings = settings
457 self.entries = load_named_colors(settings)
459 layout = QVBoxLayout(self)
461 description = QLabel(tr(
462 "よく使う色に名前を付けて登録します。データセットの色欄で"
463 "スウォッチを押すと、ここに登録した色を名前から選べます。"))
464 description.setWordWrap(True)
465 layout.addWidget(description)
467 self.color_list = QListWidget()
468 self.color_list.setIconSize(QSize(16, 16))
469 self.color_list.itemDoubleClicked.connect(lambda _item: self._on_edit())
470 layout.addWidget(self.color_list, stretch=1)
472 button_layout = QHBoxLayout()
473 self.add_button = QPushButton(tr("追加..."))
474 self.edit_button = QPushButton(tr("編集..."))
475 self.delete_button = QPushButton(tr("削除"))
476 self.up_button = QPushButton(tr("上へ"))
477 self.down_button = QPushButton(tr("下へ"))
478 for button in (self.add_button, self.edit_button, self.delete_button):
479 button_layout.addWidget(button)
480 button_layout.addStretch()
481 button_layout.addWidget(self.up_button)
482 button_layout.addWidget(self.down_button)
483 layout.addLayout(button_layout)
485 order_note = QLabel(tr("並び順は、色欄のポップアップにそのまま反映されます。"))
486 order_note.setWordWrap(True)
487 layout.addWidget(order_note)
489 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
490 button_box.rejected.connect(self.reject)
491 button_box.accepted.connect(self.accept)
492 layout.addWidget(button_box)
494 self.add_button.clicked.connect(self._on_add)
495 self.edit_button.clicked.connect(self._on_edit)
496 self.delete_button.clicked.connect(self._on_delete)
497 self.up_button.clicked.connect(lambda: self._on_move(-1))
498 self.down_button.clicked.connect(lambda: self._on_move(1))
499 self.color_list.currentRowChanged.connect(lambda _row: self._update_button_states())
501 self._reload_list()
502 self._update_button_states()
504 def _update_button_states(self):
505 row = self.color_list.currentRow()
506 has_current = row >= 0
507 self.edit_button.setEnabled(has_current)
508 self.delete_button.setEnabled(has_current)
509 self.up_button.setEnabled(has_current and row > 0)
510 self.down_button.setEnabled(has_current and row < self.color_list.count() - 1)
513 def _reload_list(self, select_index=None):
514 self.color_list.clear()
515 for entry in self.entries:
516 item = QListWidgetItem(f'{entry["name"]} {entry["color"]}')
517 item.setIcon(_named_color_icon(entry["color"]))
518 self.color_list.addItem(item)
519 if select_index is not None and 0 <= select_index < self.color_list.count():
520 self.color_list.setCurrentRow(select_index)
522 def _commit(self, entries, select_index=None):
523 self.entries = entries
524 save_named_colors(self._settings, self.entries)
525 self._reload_list(select_index)
527 def _current_index(self):
528 return self.color_list.currentRow()
530 def _ask_name_and_color(self, title, name="", color="#1f77b4"):
531 """名前と色を尋ねる。(name, color) かキャンセルなら None。"""
532 chosen = ask_color(self, QColor(color), tr("色を選択"))
533 if not chosen.isValid(): 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true
534 return None
535 text, ok = notify.get_text(
536 self, title, tr("登録名"), text=name)
537 if not ok: 537 ↛ 538line 537 didn't jump to line 538 because the condition on line 537 was never true
538 return None
539 return text, chosen.name()
542 def _on_add(self):
543 result = self._ask_name_and_color(tr("色を登録"))
544 if result is None: 544 ↛ 545line 544 didn't jump to line 545 because the condition on line 544 was never true
545 return
546 name, color = result
547 try:
548 entries = add_named_color(self.entries, name, color)
549 except NamedColorError as e:
550 notify.warning(self, tr("色を登録"), str(e))
551 return
552 self._commit(entries, len(entries) - 1)
554 def _on_edit(self):
555 index = self._current_index()
556 if index < 0:
557 notify.information(self, tr("色名の管理"), tr("編集する登録を選んでください。"))
558 return
559 current = self.entries[index]
560 result = self._ask_name_and_color(
561 tr("登録を編集"), current["name"], current["color"])
562 if result is None:
563 return
564 name, color = result
565 try:
566 entries = update_named_color(self.entries, index, name, color)
567 except NamedColorError as e:
568 notify.warning(self, tr("登録を編集"), str(e))
569 return
570 self._commit(entries, index)
572 def _on_delete(self):
573 index = self._current_index()
574 if index < 0: 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true
575 notify.information(self, tr("色名の管理"), tr("削除する登録を選んでください。"))
576 return
577 try:
578 entries = remove_named_color(self.entries, index)
579 except NamedColorError as e:
580 notify.warning(self, tr("色名の管理"), str(e))
581 return
582 self._commit(entries, min(index, len(entries) - 1))
584 def _on_move(self, offset):
585 index = self._current_index()
586 if index < 0: 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 return
588 try:
589 entries = move_named_color(self.entries, index, offset)
590 except NamedColorError:
591 return
592 new_index = max(0, min(index + offset, len(entries) - 1))
593 self._commit(entries, new_index)
596class NamedColorPickerDialog(QDialog):
597 """登録した色を名前で絞り込んで選ぶ(色欄のポップアップは先頭 POPUP_LIMIT 件しか並べない)。編集はしない。"""
599 def __init__(self, settings, parent=None, title=None):
600 super().__init__(parent)
601 self.setWindowTitle(title or tr("登録色を選択"))
602 self.resize(380, 420)
603 self.entries = load_named_colors(settings)
604 self._selected = None
606 layout = QVBoxLayout(self)
608 self.filter_edit = QLineEdit()
609 self.filter_edit.setPlaceholderText(tr("名前または色コードで絞り込み"))
610 self.filter_edit.setClearButtonEnabled(True)
611 layout.addWidget(self.filter_edit)
613 self.color_list = QListWidget()
614 self.color_list.setIconSize(QSize(16, 16))
615 layout.addWidget(self.color_list, stretch=1)
617 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
618 QDialogButtonBox.StandardButton.Cancel)
619 button_box.accepted.connect(self.accept)
620 button_box.rejected.connect(self.reject)
621 layout.addWidget(button_box)
623 self.filter_edit.textChanged.connect(self._reload_list)
624 self.color_list.itemDoubleClicked.connect(lambda _item: self.accept())
626 self._reload_list()
628 def _reload_list(self, _text=None):
629 """名前と色コードの両方で絞り込む。"""
630 needle = self.filter_edit.text().strip().lower()
631 self.color_list.clear()
632 for entry in self.entries:
633 if needle and needle not in entry["name"].lower() and needle not in entry["color"].lower():
634 continue
635 item = QListWidgetItem(f'{entry["name"]} {entry["color"]}')
636 item.setIcon(_named_color_icon(entry["color"]))
637 item.setData(Qt.ItemDataRole.UserRole, entry)
638 self.color_list.addItem(item)
639 if self.color_list.count():
640 self.color_list.setCurrentRow(0)
642 def accept(self):
643 item = self.color_list.currentItem()
644 self._selected = item.data(Qt.ItemDataRole.UserRole) if item else None
645 super().accept()
647 def selected_entry(self):
648 """選んだ登録({'name', 'color'})。キャンセルか未選択なら None。"""
649 return self._selected
652class LegendOrderDialog(QDialog):
653 """凡例の並びを、描画順とは別にドラッグで決める。"""
655 def __init__(self, labels, parent=None):
656 super().__init__(parent)
657 self.setWindowTitle("凡例の順序")
658 self.resize(320, 380)
660 layout = QVBoxLayout(self)
661 layout.addWidget(QLabel("ドラッグして凡例の表示順序を変更できます:"))
663 self.list_widget = QListWidget()
664 self.list_widget.setDragDropMode(QListWidget.DragDropMode.InternalMove)
665 self.list_widget.addItems(labels)
666 layout.addWidget(self.list_widget)
668 reset_button = QPushButton("描画順にリセット")
669 reset_button.clicked.connect(self._on_reset)
670 layout.addWidget(reset_button)
671 self._original_labels = list(labels)
673 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
674 QDialogButtonBox.StandardButton.Cancel)
675 button_box.accepted.connect(self.accept)
676 button_box.rejected.connect(self.reject)
677 layout.addWidget(button_box)
679 def _on_reset(self):
680 self.list_widget.clear()
682 def get_order(self):
683 """今の並び。「描画順にリセット」なら空のリスト(描画順に従う)。"""
684 return [self.list_widget.item(i).text() for i in range(self.list_widget.count())]
687DEFAULT_ANNOTATION_COLOR = '#000000'
690class _AnnotationColorButton(QPushButton):
691 """注釈の色の見本。押すと色を選ぶ(最近使った色は settings に残す)。"""
693 def __init__(self, settings, color=DEFAULT_ANNOTATION_COLOR, parent=None):
694 super().__init__(parent)
695 self._settings = settings
696 self.set_color(color)
697 self.clicked.connect(self._choose_color)
699 def color(self):
700 return self._color
702 def set_color(self, color):
703 self._color = QColor(color).name()
704 swatch = QPixmap(16, 16)
705 swatch.fill(QColor(self._color))
706 self.setIcon(QIcon(swatch))
707 self.setText(self._color)
709 def _choose_color(self):
710 color = get_color_with_history(self._settings, self, initial=QColor(self._color))
711 if color.isValid():
712 self.set_color(color.name())
715class TextAnnotationDialog(QDialog):
716 """文字の注釈の文字と色。"""
718 def __init__(self, parent=None, settings=None):
719 super().__init__(parent)
720 self.setWindowTitle("テキスト注釈の追加")
721 self.resize(360, 140)
723 layout = QVBoxLayout(self)
724 layout.addWidget(QLabel("表示するテキスト:"))
725 self.text_edit = QLineEdit()
726 layout.addWidget(self.text_edit)
728 form = QFormLayout()
729 self.color_button = _AnnotationColorButton(settings)
730 form.addRow("色", self.color_button)
731 layout.addLayout(form)
733 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
734 QDialogButtonBox.StandardButton.Cancel)
735 button_box.accepted.connect(self.accept)
736 button_box.rejected.connect(self.reject)
737 layout.addWidget(button_box)
739 apply_form_spacing(self)
741 def get_text(self):
742 return self.text_edit.text().strip()
744 def color(self):
745 return self.color_button.color()
748class ArrowAnnotationDialog(QDialog):
749 """矢印の注釈のラベル、形('single' / 'double' / 'bracket')、曲がり具合。"""
751 STYLE_SINGLE = "通常(片矢印)"
752 STYLE_DOUBLE = "両矢印"
753 STYLE_BRACKET = "ブラケット(有意差表示等)"
754 STYLES = [STYLE_SINGLE, STYLE_DOUBLE, STYLE_BRACKET]
755 _STYLE_KEY_BY_LABEL = {STYLE_SINGLE: "single", STYLE_DOUBLE: "double", STYLE_BRACKET: "bracket"}
757 def __init__(self, parent=None, settings=None):
758 super().__init__(parent)
759 self.setWindowTitle("矢印注釈の追加")
760 self.resize(360, 200)
762 layout = QVBoxLayout(self)
763 layout.addWidget(QLabel("ラベル(空欄可):"))
764 self.text_edit = QLineEdit()
765 layout.addWidget(self.text_edit)
767 form = QFormLayout()
768 self.style_combo = QComboBox()
769 self.style_combo.addItems(self.STYLES)
770 form.addRow("矢印の形状", self.style_combo)
772 self.curvature_spinbox = QDoubleSpinBox()
773 self.curvature_spinbox.setRange(-1.0, 1.0)
774 self.curvature_spinbox.setSingleStep(0.05)
775 self.curvature_spinbox.setDecimals(2)
776 self.curvature_spinbox.setValue(0.0)
777 self.curvature_spinbox.setToolTip(
778 "0で直線。正/負で曲がる向きが変わります(matplotlibのarc3 rad相当)。"
779 )
780 form.addRow("曲率", self.curvature_spinbox)
782 self.color_button = _AnnotationColorButton(settings)
783 form.addRow("色", self.color_button)
784 layout.addLayout(form)
786 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
787 QDialogButtonBox.StandardButton.Cancel)
788 button_box.accepted.connect(self.accept)
789 button_box.rejected.connect(self.reject)
790 layout.addWidget(button_box)
792 apply_form_spacing(self)
794 def get_settings(self):
795 """(ラベル, 形, 曲がり具合)"""
796 style_text = self.style_combo.currentText()
797 style = self._STYLE_KEY_BY_LABEL[style_text]
798 return self.text_edit.text().strip(), style, self.curvature_spinbox.value()
800 def color(self):
801 return self.color_button.color()
804class InsetDialog(QDialog):
805 """拡大図の位置(角と大きさ)と、拡大する X の範囲。"""
807 CORNERS = ["右上", "左上", "右下", "左下"]
809 def __init__(self, x_min, x_max, default_zoom_min, default_zoom_max, parent=None, default_y_range=(0.0, 1.0)):
810 super().__init__(parent)
811 self.setWindowTitle("インセット(拡大図)を追加")
812 self.resize(380, 320)
814 layout = QVBoxLayout(self)
815 info_label = QLabel(
816 f"データのX範囲: {x_min:.4g} 〜 {x_max:.4g}\n"
817 "拡大表示したいX範囲と、インセットを表示する位置を選んでください。"
818 )
819 info_label.setWordWrap(True)
820 layout.addWidget(info_label)
822 form = QFormLayout()
823 self.zoom_min_spinbox = QDoubleSpinBox()
824 self.zoom_min_spinbox.setRange(-1e12, 1e12)
825 self.zoom_min_spinbox.setDecimals(4)
826 self.zoom_min_spinbox.setValue(default_zoom_min)
827 form.addRow("拡大範囲(最小)", self.zoom_min_spinbox)
829 self.zoom_max_spinbox = QDoubleSpinBox()
830 self.zoom_max_spinbox.setRange(-1e12, 1e12)
831 self.zoom_max_spinbox.setDecimals(4)
832 self.zoom_max_spinbox.setValue(default_zoom_max)
833 form.addRow("拡大範囲(最大)", self.zoom_max_spinbox)
835 # 指定しなければ、範囲内のデータに合わせ、親の軸の Y 範囲に収める
836 self.y_range_checkbox = QCheckBox("Y範囲を指定する")
837 form.addRow(self.y_range_checkbox)
838 self.y_min_spinbox = QDoubleSpinBox()
839 self.y_min_spinbox.setRange(-1e12, 1e12)
840 self.y_min_spinbox.setDecimals(4)
841 self.y_min_spinbox.setValue(default_y_range[0])
842 form.addRow("Y範囲(最小)", self.y_min_spinbox)
843 self.y_max_spinbox = QDoubleSpinBox()
844 self.y_max_spinbox.setRange(-1e12, 1e12)
845 self.y_max_spinbox.setDecimals(4)
846 self.y_max_spinbox.setValue(default_y_range[1])
847 form.addRow("Y範囲(最大)", self.y_max_spinbox)
848 for spinbox in (self.y_min_spinbox, self.y_max_spinbox):
849 spinbox.setEnabled(False)
850 self.y_range_checkbox.toggled.connect(spinbox.setEnabled)
852 self.corner_combo = QComboBox()
853 self.corner_combo.addItems(self.CORNERS)
854 form.addRow("表示位置", self.corner_combo)
856 self.size_spinbox = QDoubleSpinBox()
857 self.size_spinbox.setRange(0.15, 0.6)
858 self.size_spinbox.setSingleStep(0.05)
859 self.size_spinbox.setValue(0.35)
860 form.addRow("大きさ(軸に対する比率)", self.size_spinbox)
861 layout.addLayout(form)
863 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
864 QDialogButtonBox.StandardButton.Cancel)
865 button_box.accepted.connect(self.accept)
866 button_box.rejected.connect(self.reject)
867 layout.addWidget(button_box)
869 apply_form_spacing(self)
871 def get_settings(self):
872 """{'corner', 'size', 'zoom_x_range'(昇順)}。Y範囲を指定したときだけ 'zoom_y_range'(昇順)も。"""
873 lo = self.zoom_min_spinbox.value()
874 hi = self.zoom_max_spinbox.value()
875 settings = {
876 'corner': self.corner_combo.currentText(),
877 'size': self.size_spinbox.value(),
878 'zoom_x_range': (min(lo, hi), max(lo, hi)),
879 }
880 if self.y_range_checkbox.isChecked():
881 y_lo = self.y_min_spinbox.value()
882 y_hi = self.y_max_spinbox.value()
883 settings['zoom_y_range'] = (min(y_lo, y_hi), max(y_lo, y_hi))
884 return settings