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

1"""見た目と注釈のダイアログ。呼び出し側は `from graphica.gui.dialogs import X` で参照する。""" 

2 

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) 

43 

44 

45class LabelEditDialog(QDialog): 

46 """タイトルと軸ラベルの編集。装飾ボタンは選択範囲を mathtext で包み、記号はカーソル位置に入れる。""" 

47 

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) 

53 

54 layout = QVBoxLayout(self) 

55 

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) 

62 

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) 

70 

71 button_row = QHBoxLayout() 

72 button_row.setSpacing(4) 

73 

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 

84 

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", "下付き文字") 

89 

90 # ボタンがフォーカスを取ると、pressed が出るより前にテキスト欄の選択が消える。 

91 # だから装飾ボタンはフォーカスを取らない。pressed で選択を控えるのは、それでも移る環境のための保険 

92 self._pending_selection = None # (start, selected_text)、選択が無ければ None 

93 self._pending_cursor = 0 

94 

95 for button in (bold_button, italic_button, superscript_button, subscript_button): 

96 button.pressed.connect(self._capture_pending_selection) 

97 

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

102 

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) 

114 

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) 

140 

141 button_row.addStretch(1) 

142 layout.addLayout(button_row) 

143 

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) 

150 

151 self._refresh_preview() 

152 

153 def _refresh_preview(self): 

154 """テキスト欄の内容を描いてプレビューに出す(タイトル欄のプレビューと同じ描き方)。""" 

155 from graphica.gui import theme 

156 from graphica.gui.mathtext_preview import render_mathtext_to_pixmap 

157 

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) 

163 

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

173 

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) 

181 

182 def _apply_wrap(self, kind, wrap_fn): 

183 """選択範囲を wrap_fn の中身で包み、$...$ で囲む。kind は "bold" / "italic" / "super" / "sub"。 

184 

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 

196 

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) 

230 

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

239 

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

251 

252 def get_text(self): 

253 return self.text_edit.text() 

254 

255 

256class ColorPaletteDialog(QDialog): 

257 """「自動配色」のパレットを作り、選ぶ。「Matplotlib既定」と組み込みのパレットは読み取り専用。""" 

258 

259 DEFAULT_PALETTE_NAME = DEFAULT_PALETTE_NAME 

260 

261 def __init__(self, palettes: dict, active_name: str, parent=None): 

262 super().__init__(parent) 

263 self.setWindowTitle("配色パレットの管理") 

264 self.resize(420, 440) 

265 

266 # キャンセルしたとき元に戻せるよう、呼び出し側の辞書は変えない 

267 self.palettes = {name: list(colors) for name, colors in palettes.items()} 

268 

269 layout = QVBoxLayout(self) 

270 

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) 

279 

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) 

288 

289 self.color_list = QListWidget() 

290 layout.addWidget(self.color_list) 

291 

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) 

299 

300 info_label = QLabel("「OK」を押すと、選択中のパレットが以後「自動配色」ボタンで使われます。") 

301 info_label.setWordWrap(True) 

302 layout.addWidget(info_label) 

303 

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) 

309 

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

317 

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

325 

326 def _is_default_selected(self): 

327 return self._is_readonly_palette(self.palette_combo.currentText()) 

328 

329 def _is_readonly_palette(self, name): 

330 """「Matplotlib既定」か組み込みのパレット(どちらも編集できない)。""" 

331 return name == self.DEFAULT_PALETTE_NAME or name in BUILTIN_PALETTES 

332 

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) 

339 

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) 

356 

357 row_widget = QWidget() 

358 row_layout = QHBoxLayout(row_widget) 

359 row_layout.setContentsMargins(6, 2, 6, 2) 

360 row_layout.setSpacing(8) 

361 

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) 

370 

371 item.setSizeHint(row_widget.sizeHint()) 

372 self.color_list.setItemWidget(item, row_widget) 

373 

374 def _on_palette_selected(self, _name): 

375 self._refresh_color_list() 

376 self._update_button_states() 

377 

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) 

388 

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) 

401 

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

414 

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

424 

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

434 

435 def get_result(self): 

436 """(パレットの辞書, 選んだパレット名)。QSettings への保存は呼び出し側。""" 

437 return self.palettes, self.palette_combo.currentText() 

438 

439 

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) 

444 

445 

446class NamedColorManagerDialog(QDialog): 

447 """名前を付けて登録した色(core/named_colors.py)の管理。配色パレット(色の並び)とは別物。 

448 

449 登録簿を育てる操作で、プロットの見た目は変えないので、OK を待たずにその場で保存する。 

450 """ 

451 

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) 

458 

459 layout = QVBoxLayout(self) 

460 

461 description = QLabel(tr( 

462 "よく使う色に名前を付けて登録します。データセットの色欄で" 

463 "スウォッチを押すと、ここに登録した色を名前から選べます。")) 

464 description.setWordWrap(True) 

465 layout.addWidget(description) 

466 

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) 

471 

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) 

484 

485 order_note = QLabel(tr("並び順は、色欄のポップアップにそのまま反映されます。")) 

486 order_note.setWordWrap(True) 

487 layout.addWidget(order_note) 

488 

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) 

493 

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

500 

501 self._reload_list() 

502 self._update_button_states() 

503 

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) 

511 

512 

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) 

521 

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) 

526 

527 def _current_index(self): 

528 return self.color_list.currentRow() 

529 

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

540 

541 

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) 

553 

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) 

571 

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

583 

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) 

594 

595 

596class NamedColorPickerDialog(QDialog): 

597 """登録した色を名前で絞り込んで選ぶ(色欄のポップアップは先頭 POPUP_LIMIT 件しか並べない)。編集はしない。""" 

598 

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 

605 

606 layout = QVBoxLayout(self) 

607 

608 self.filter_edit = QLineEdit() 

609 self.filter_edit.setPlaceholderText(tr("名前または色コードで絞り込み")) 

610 self.filter_edit.setClearButtonEnabled(True) 

611 layout.addWidget(self.filter_edit) 

612 

613 self.color_list = QListWidget() 

614 self.color_list.setIconSize(QSize(16, 16)) 

615 layout.addWidget(self.color_list, stretch=1) 

616 

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) 

622 

623 self.filter_edit.textChanged.connect(self._reload_list) 

624 self.color_list.itemDoubleClicked.connect(lambda _item: self.accept()) 

625 

626 self._reload_list() 

627 

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) 

641 

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

646 

647 def selected_entry(self): 

648 """選んだ登録({'name', 'color'})。キャンセルか未選択なら None。""" 

649 return self._selected 

650 

651 

652class LegendOrderDialog(QDialog): 

653 """凡例の並びを、描画順とは別にドラッグで決める。""" 

654 

655 def __init__(self, labels, parent=None): 

656 super().__init__(parent) 

657 self.setWindowTitle("凡例の順序") 

658 self.resize(320, 380) 

659 

660 layout = QVBoxLayout(self) 

661 layout.addWidget(QLabel("ドラッグして凡例の表示順序を変更できます:")) 

662 

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) 

667 

668 reset_button = QPushButton("描画順にリセット") 

669 reset_button.clicked.connect(self._on_reset) 

670 layout.addWidget(reset_button) 

671 self._original_labels = list(labels) 

672 

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) 

678 

679 def _on_reset(self): 

680 self.list_widget.clear() 

681 

682 def get_order(self): 

683 """今の並び。「描画順にリセット」なら空のリスト(描画順に従う)。""" 

684 return [self.list_widget.item(i).text() for i in range(self.list_widget.count())] 

685 

686 

687DEFAULT_ANNOTATION_COLOR = '#000000' 

688 

689 

690class _AnnotationColorButton(QPushButton): 

691 """注釈の色の見本。押すと色を選ぶ(最近使った色は settings に残す)。""" 

692 

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) 

698 

699 def color(self): 

700 return self._color 

701 

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) 

708 

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

713 

714 

715class TextAnnotationDialog(QDialog): 

716 """文字の注釈の文字と色。""" 

717 

718 def __init__(self, parent=None, settings=None): 

719 super().__init__(parent) 

720 self.setWindowTitle("テキスト注釈の追加") 

721 self.resize(360, 140) 

722 

723 layout = QVBoxLayout(self) 

724 layout.addWidget(QLabel("表示するテキスト:")) 

725 self.text_edit = QLineEdit() 

726 layout.addWidget(self.text_edit) 

727 

728 form = QFormLayout() 

729 self.color_button = _AnnotationColorButton(settings) 

730 form.addRow("色", self.color_button) 

731 layout.addLayout(form) 

732 

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) 

738 

739 apply_form_spacing(self) 

740 

741 def get_text(self): 

742 return self.text_edit.text().strip() 

743 

744 def color(self): 

745 return self.color_button.color() 

746 

747 

748class ArrowAnnotationDialog(QDialog): 

749 """矢印の注釈のラベル、形('single' / 'double' / 'bracket')、曲がり具合。""" 

750 

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"} 

756 

757 def __init__(self, parent=None, settings=None): 

758 super().__init__(parent) 

759 self.setWindowTitle("矢印注釈の追加") 

760 self.resize(360, 200) 

761 

762 layout = QVBoxLayout(self) 

763 layout.addWidget(QLabel("ラベル(空欄可):")) 

764 self.text_edit = QLineEdit() 

765 layout.addWidget(self.text_edit) 

766 

767 form = QFormLayout() 

768 self.style_combo = QComboBox() 

769 self.style_combo.addItems(self.STYLES) 

770 form.addRow("矢印の形状", self.style_combo) 

771 

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) 

781 

782 self.color_button = _AnnotationColorButton(settings) 

783 form.addRow("色", self.color_button) 

784 layout.addLayout(form) 

785 

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) 

791 

792 apply_form_spacing(self) 

793 

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

799 

800 def color(self): 

801 return self.color_button.color() 

802 

803 

804class InsetDialog(QDialog): 

805 """拡大図の位置(角と大きさ)と、拡大する X の範囲。""" 

806 

807 CORNERS = ["右上", "左上", "右下", "左下"] 

808 

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) 

813 

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) 

821 

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) 

828 

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) 

834 

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) 

851 

852 self.corner_combo = QComboBox() 

853 self.corner_combo.addItems(self.CORNERS) 

854 form.addRow("表示位置", self.corner_combo) 

855 

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) 

862 

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) 

868 

869 apply_form_spacing(self) 

870 

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