Coverage for graphica/gui/theme.py: 98.7%
256 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"""アプリ全体のテーマ。Fusion スタイルの上でパレットを切り替え、QSS でフラットな見た目にする。"""
2import os
3import re
4import tempfile
6from PySide6.QtCore import QRectF, Qt
7from PySide6.QtGui import QColor, QPainter, QPainterPath, QPalette, QPen, QPixmap
8from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QCheckBox, QComboBox,
9 QProxyStyle, QStyle, QStyleFactory)
11from graphica.gui.icon_utils import icon as _svg_icon
12from graphica.gui.theme_color import DEFAULT_THEME_COLOR, derive_accent, normalize_hex
14_wheel_value_change_disabled = False
15_current_proxy_style = None
16_current_tokens = None
17_current_theme_color = DEFAULT_THEME_COLOR
18_last_applied_dark = None # 同じ (明暗, テーマカラー) での apply_theme() は重い処理を省く
19_last_applied_theme_color = None
21# 選択の角丸を描くデリゲートが使う。QSS の QTreeWidget の border-radius と同じ値にしておくこと
22DATASET_LIST_ITEM_RADIUS = 8
24# スピンボックスの矢印は PNG にして QSS の ::up-arrow / ::down-arrow の image で指定する。
25# 枠を QSS で描いていると、QProxyStyle で矢印を描いても反映されたりされなかったりする。
26_ARROW_ICON_CACHE_DIR = os.path.join(tempfile.gettempdir(), "graphica_theme_icons")
29def _spinbox_arrow_icon_url(direction: str, color: str) -> str:
30 """三角の矢印の PNG を(無ければ)作り、QSS の url() に入れられるパスを返す。"""
31 os.makedirs(_ARROW_ICON_CACHE_DIR, exist_ok=True)
32 safe_color = color.lstrip("#")
33 # 大きさをファイル名に入れ、寸法を変えたとき古い PNG を使い回さない
34 size = 14
35 filename = f"spin_arrow_{direction}_{safe_color}_{size}.png"
36 path = os.path.join(_ARROW_ICON_CACHE_DIR, filename)
38 if not os.path.exists(path):
39 pixmap = QPixmap(size, size)
40 pixmap.fill(Qt.GlobalColor.transparent)
41 painter = QPainter(pixmap)
42 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
43 painter.setPen(Qt.PenStyle.NoPen)
44 painter.setBrush(QColor(color))
46 cx, cy = size / 2, size / 2
47 aw, ah = 4.4, 3.3
48 arrow_path = QPainterPath()
49 if direction == "up":
50 arrow_path.moveTo(cx - aw, cy + ah * 0.5)
51 arrow_path.lineTo(cx + aw, cy + ah * 0.5)
52 arrow_path.lineTo(cx, cy - ah * 0.5)
53 else:
54 arrow_path.moveTo(cx - aw, cy - ah * 0.5)
55 arrow_path.lineTo(cx + aw, cy - ah * 0.5)
56 arrow_path.lineTo(cx, cy + ah * 0.5)
57 arrow_path.closeSubpath()
58 painter.drawPath(arrow_path)
59 painter.end()
60 pixmap.save(path)
62 # QSS の url() は Windows の区切りの \ を読めない
63 return path.replace(os.sep, "/")
65# 配色の唯一の定義(見本は docs/dev/mockups/gui_redesign/)。利用者が変えられるのはテーマカラーだけで、
66# 強調の4色(accent・accent_fg・on_accent・accent_soft)はそこから theme_color.derive_accent() が作る。
67# 配色パレット(データの線の色)とは別物。
68_LIGHT_BASE_TOKENS = {
69 "bg": "#F1F2F4", "surface": "#FFFFFF", "surface_2": "#F3F5F7",
70 # 上部バー・帯・ステータスバー
71 "chrome": "#E4E7EA",
72 # surface_2 の地の上に浮かせる面(セグメントの選んだもの)
73 "raised": "#FFFFFF",
74 "border": "#E0E3E6", "border_strong": "#CBD0D5",
75 "text_primary": "#191D1F", "text_secondary": "#56606A", "text_muted": "#8A939B",
76 "success": "#22A06B", "danger": "#D1453B",
77 # 良い状態のバッジ(フィットの R²)の地と文字
78 "success_soft": "rgba(34, 160, 107, 0.14)", "success_fg": "#1A7F55",
79 # 欠損値のセル。強調の色と被らない
80 "warning_soft": "#FDF1D8",
81}
82_DARK_BASE_TOKENS = {
83 "bg": "#111417", "surface": "#181B1F", "surface_2": "#1F2328",
84 "chrome": "#0E1013",
85 "raised": "#373E46",
86 "border": "#272C32", "border_strong": "#373E46",
87 "text_primary": "#E6E9EC", "text_secondary": "#A0A9B1", "text_muted": "#69737B",
88 "success": "#5BD39A", "danger": "#F1857C",
89 "success_soft": "rgba(91, 211, 154, 0.14)", "success_fg": "#5BD39A",
90 "warning_soft": "rgba(250, 204, 21, 0.16)",
91}
94def build_tokens(dark: bool, theme_color: str = DEFAULT_THEME_COLOR) -> dict:
95 """テーマカラーから強調の色を計算して、明暗の地の色と合わせた全トークンを返す。"""
96 base = _DARK_BASE_TOKENS if dark else _LIGHT_BASE_TOKENS
97 tokens = dict(base)
98 derived = derive_accent(theme_color, base["surface"], dark)
99 for key in ("accent", "accent_fg", "on_accent", "accent_soft"):
100 tokens[key] = derived[key]
101 return tokens
104# 既定のテーマカラーでの全トークン。起動画面やキャンバスの定数など、設定を読む前に使う所はこれを見る
105LIGHT_TOKENS = build_tokens(False)
106DARK_TOKENS = build_tokens(True)
109def tokens_for(dark: bool, theme_color: str = DEFAULT_THEME_COLOR) -> dict:
110 """既定の色なら LIGHT_TOKENS / DARK_TOKENS そのもの、それ以外は作り直した辞書。"""
111 if normalize_hex(theme_color) == DEFAULT_THEME_COLOR:
112 return DARK_TOKENS if dark else LIGHT_TOKENS
113 return build_tokens(dark, theme_color)
115_FLAT_QSS_TEMPLATE = """
116QMainWindow, QDialog {{
117 background: {bg};
118}}
119QWidget {{
120 color: {text_primary};
121 /* 操作の強調(選択・ホバー・押下・フォーカス)はすべてテーマカラーの accent / accent_soft にそろえる。 */
122 selection-background-color: {accent_soft};
123 selection-color: {text_primary};
124}}
125QToolTip {{
126 background: {surface};
127 color: {text_primary};
128 border: 1px solid {border_strong};
129 padding: 4px 6px;
130 border-radius: 4px;
131}}
133/* --- メニューバー / メニュー --- */
134QMenuBar {{
135 background: {surface};
136 border-bottom: 1px solid {border};
137 padding: 2px;
138}}
139QMenuBar::item {{
140 background: transparent;
141 padding: 4px 10px;
142 border-radius: 4px;
143}}
144QMenuBar::item:selected {{
145 background: {accent_soft};
146 color: {text_primary};
147}}
148QMenu {{
149 background: {surface};
150 border: 1px solid {border};
151 border-radius: 8px;
152 padding: 4px;
153}}
154QMenu::item {{
155 padding: 6px 24px 6px 12px;
156 border-radius: 6px;
157}}
158QMenu::item:selected {{
159 background: {accent_soft};
160 color: {text_primary};
161}}
162QMenu::separator {{
163 height: 1px;
164 background: {border};
165 margin: 4px 8px;
166}}
168/* --- 操作ボタン行のグループ区切り --- */
169QFrame#button_row_separator {{
170 background: {border};
171 max-width: 1px;
172 border: none;
173 margin: 2px 4px;
174}}
176/* --- データセット一覧の下の短い統計値 --- */
177QLabel#dataset_mini_stats_label {{
178 color: {text_muted};
179 font-size: 11.5px;
180 padding: 2px 4px;
181}}
183/* --- キャンバスの周り: 角丸の背景のカード。枠線は付けない --- */
184QWidget#plot_container {{
185 background: {surface};
186 border: 1px solid {border};
187 border-radius: 14px;
188}}
189/* グラフの下のパネル(GUI 刷新、アドバンスド): カードと同じ角丸。上にアイコン付きのタブ */
190QFrame#bottom_panel {{
191 background: {surface};
192 border: 1px solid {border};
193 border-radius: 14px;
194}}
195QWidget#bottom_panel_header {{
196 background: transparent;
197 border: none;
198 border-bottom: 1px solid {border};
199}}
200QToolButton#bottom_tab {{
201 background: transparent;
202 border: none;
203 border-radius: 7px;
204 padding: 4px 10px;
205 color: {text_secondary};
206}}
207QToolButton#bottom_tab:hover {{
208 color: {text_primary};
209}}
210QToolButton#bottom_tab:checked {{
211 background: {surface_2};
212 color: {text_primary};
213 font-weight: 600;
214}}
215QLabel#bottom_badge {{
216 background: {surface_2};
217 color: {text_secondary};
218 border-radius: 5px;
219 padding: 0 6px;
220 font-size: 10.5px;
221}}
222QLabel#bottom_badge[ok="true"] {{
223 background: {success_soft};
224 color: {success_fg};
225}}
226QLabel#bottom_panel_meta {{
227 color: {text_muted};
228 font-size: 11.5px;
229 background: transparent;
230 margin-right: 4px;
231}}
232QStackedWidget#bottom_panel_stack, QStackedWidget#bottom_panel_stack > QWidget {{
233 background: transparent;
234 border: none;
235}}
236QTableView#bottom_data_table {{
237 background: transparent;
238 border: none;
239 border-radius: 0;
240 selection-background-color: {accent_soft};
241 selection-color: {text_primary};
242 gridline-color: {border};
243}}
244QTableView#bottom_data_table::item {{
245 border-bottom: 1px solid {border};
246 padding: 0 14px;
247}}
248QTableView#bottom_data_table QHeaderView::section {{
249 padding: 4px 14px;
250 font-weight: 600;
251}}
252QPlainTextEdit#bottom_log, QTextEdit#fit_info_textedit {{
253 background: transparent;
254 border: none;
255 padding: 6px 10px;
256}}
257QPlainTextEdit#bottom_log {{
258 font-family: "Consolas", "Menlo", monospace;
259 font-size: 11.5px;
260}}
261/* グラフのカード(GUI 刷新): ミニマップとの間は線を引かず、すき間だけ */
262QFrame#canvas_separator {{
263 background: transparent;
264 border: none;
265}}
266QLabel#card_title {{
267 font-size: 15px;
268 font-weight: 600;
269 background: transparent;
270}}
271QLabel#card_meta {{
272 color: {text_muted};
273 font-size: 11.5px;
274 background: transparent;
275}}
276QToolButton#card_button {{
277 background: transparent;
278 border: none;
279 border-radius: 7px;
280 padding: 3px;
281}}
282QToolButton#card_button:hover {{
283 background: {surface_2};
284}}
285QToolButton#card_button:checked {{
286 background: {accent_soft};
287 border: none;
288}}
289/* アドバンスドの道具: プロットの上に文字付き。モードの7つは1つの塊(toolSegment) */
290QToolBar#plot_toolbar {{
291 background: transparent;
292 border: none;
293 border-bottom: 1px solid {border};
294 padding: 0 0 6px 0;
295 spacing: 0;
296}}
297QToolBar#plot_toolbar QToolButton {{
298 border: none;
299 border-radius: 6px;
300 padding: 3px 7px;
301 margin: 0 2px 0 0;
302 color: {text_primary};
303 font-size: 12px;
304}}
305QToolBar#plot_toolbar QToolButton[toolSegment="first"],
306QToolBar#plot_toolbar QToolButton[toolSegment="middle"],
307QToolBar#plot_toolbar QToolButton[toolSegment="last"] {{
308 background: {surface_2};
309 border-radius: 0;
310 margin: 0;
311 padding: 4px 8px;
312}}
313QToolBar#plot_toolbar QToolButton[toolSegment="first"] {{
314 border-top-left-radius: 8px;
315 border-bottom-left-radius: 8px;
316}}
317QToolBar#plot_toolbar QToolButton[toolSegment="last"] {{
318 border-top-right-radius: 8px;
319 border-bottom-right-radius: 8px;
320 margin-right: 4px;
321}}
322QToolBar#plot_toolbar QToolButton:hover {{
323 background: {surface_2};
324}}
325QToolBar#plot_toolbar QToolButton[toolSegment="first"]:hover,
326QToolBar#plot_toolbar QToolButton[toolSegment="middle"]:hover,
327QToolBar#plot_toolbar QToolButton[toolSegment="last"]:hover {{
328 color: {accent_fg};
329}}
330QToolBar#plot_toolbar QToolButton:checked {{
331 background: {raised};
332 color: {accent_fg};
333 border: none;
334 font-weight: 600;
335}}
336/* シンプルの道具: グラフの左に浮かせた縦の並び */
337QToolBar#plot_tool_rail {{
338 background: {surface};
339 border: 1px solid {border};
340 border-radius: 13px;
341 padding: 4px;
342 spacing: 3px;
343}}
344QToolBar#plot_tool_rail QToolButton {{
345 border: none;
346 border-radius: 9px;
347 padding: 6px;
348}}
349QToolBar#plot_tool_rail QToolButton:hover {{
350 background: {surface_2};
351}}
352QToolBar#plot_tool_rail QToolButton:checked {{
353 background: {accent_soft};
354 border: none;
355}}
357/* --- ツールバー / ステータスバー / ドック --- */
358QToolBar {{
359 background: {surface};
360 border: none;
361 border-bottom: 1px solid {border};
362 spacing: 4px;
363 padding: 4px;
364}}
365QToolButton {{
366 background: transparent;
367 border: 1px solid transparent;
368 border-radius: 6px;
369 padding: 4px;
370}}
371QToolButton:hover {{
372 background: {surface_2};
373}}
374/* ツールバーのボタンは QToolButton なので、QPushButton とは別にホバーと押下を強調の色にする。 */
375QToolButton:pressed {{
376 background: {accent_soft};
377}}
378QToolButton:checked {{
379 background: {accent_soft};
380 border: 1px solid {accent};
381}}
382/* ステータスバー(GUI 刷新): 上部バー・帯と同じ chrome の地、小さめの文字 */
383QStatusBar {{
384 background: {chrome};
385 border-top: 1px solid {border};
386 min-height: 26px;
387 color: {text_secondary};
388 font-size: 11px;
389}}
390QStatusBar::item {{
391 border: none;
392}}
393QStatusBar QLabel#status_text, QStatusBar QLabel#status_coordinates {{
394 color: {text_secondary};
395 font-size: 11px;
396 background: transparent;
397 padding: 0 6px;
398}}
399QStatusBar QLabel#status_coordinates {{
400 font-family: "Consolas", "Menlo", monospace;
401}}
402QFrame#status_dot {{
403 background: {success};
404 border: none;
405 border-radius: 3px;
406}}
407/* ⚙ のポップアップ(テーマカラー) */
408QFrame#settings_popover {{
409 background: {surface};
410 border: 1px solid {border_strong};
411 border-radius: 12px;
412}}
413QFrame#settings_popover QLabel {{
414 background: transparent;
415}}
416QLabel#settings_popover_title {{
417 font-weight: 600;
418 font-size: 14px;
419}}
420QLabel#settings_popover_sub {{
421 color: {text_muted};
422 font-size: 11.5px;
423}}
424QLabel#settings_popover_strong {{
425 font-weight: 600;
426}}
427QLabel#settings_popover_label {{
428 color: {text_secondary};
429 font-size: 12px;
430}}
431QLabel#settings_popover_mono {{
432 font-family: "Consolas", "Menlo", monospace;
433 font-size: 12px;
434}}
435QLabel#settings_popover_note {{
436 color: {text_secondary};
437 background: {surface_2};
438 border-radius: 8px;
439 padding: 6px 8px;
440 font-size: 11.5px;
441}}
442QFrame#settings_popover_row {{
443 background: transparent;
444 border: none;
445 border-top: 1px solid {border};
446}}
447/* QScrollArea は既定の枠を消す。背景は QScrollArea・ビューポート・中身のウィジェットの3段すべてに指定する
448 (アプリ全体に QSS を当てると素の QWidget もパレットの色で塗られ、ビューポートだけでは中身に覆われて効かない)。 */
449QScrollArea {{
450 border: none;
451 background: {bg};
452}}
453QScrollArea > QWidget {{
454 background: {bg};
455}}
456QScrollArea > QWidget > QWidget {{
457 background: {bg};
458}}
459/* --- ドック: 枠と角丸で1枚のカードに見せる(積んだドックの間はスプリッターの隙間があるので二重線にならない) --- */
460QDockWidget {{
461 /* 指定しないと OS のパレットの色が透け、背景のトークンが効かない */
462 background: {bg};
463 border: 1px solid {border};
464 border-radius: 8px;
465}}
466QDockWidget::title {{
467 background: {surface_2};
468 padding: 6px 8px;
469 border-bottom: 1px solid {border};
470 border-top-left-radius: 8px;
471 border-top-right-radius: 8px;
472 font-weight: 600;
473 letter-spacing: .01em;
474}}
475/* フォーカスのあるドックの枠。dockActive は install_dock_focus_highlight() が付け外しする。 */
476QDockWidget[dockActive="true"] {{
477 border: 1px solid {accent};
478}}
479QDockWidget[dockActive="true"]::title {{
480 border-bottom: 1px solid {accent};
481}}
483/* --- スプリッター: 細く控えめにし、ホバーでだけ色を付ける --- */
484QSplitter::handle {{
485 background: {border};
486}}
487QSplitter::handle:horizontal {{
488 width: 3px;
489 margin: 2px 0;
490}}
491QSplitter::handle:vertical {{
492 height: 3px;
493 margin: 0 2px;
494}}
495QSplitter::handle:hover {{
496 background: {accent};
497}}
499/* --- グループボックス / ボタン --- */
500QGroupBox {{
501 background: transparent;
502 border: 1px solid {border};
503 border-radius: 8px;
504 margin-top: 20px;
505 padding-top: 14px;
506 font-weight: 500;
507}}
508QGroupBox::title {{
509 subcontrol-origin: margin;
510 left: 10px;
511 /* 枠の外にはみ出さない位置にする。はみ出すと、QFontDialog などの Qt 標準のダイアログでは上の余白が無く見切れる。 */
512 top: 0px;
513 padding: 2px 8px;
514 color: {accent_fg};
515 font-size: 12.5px;
516 font-weight: 600;
517 letter-spacing: .02em;
518 background: {accent_soft};
519 border-radius: 5px;
520}}
522/* --- ドックの中のグループボックスは枠とチップを付けない(カードの中に箱が入れ子になって窮屈に見える) --- */
523QDockWidget QGroupBox {{
524 border: none;
525 border-radius: 0;
526 margin-top: 18px;
527 padding-top: 6px;
528}}
529/* 折りたたみの中身のグループボックス(collapsibleBody)はタイトルを外のボタンに移したので、
530 タイトル用の上の余白(margin-top と padding-top で 24px)を 0 にする。 */
531QDockWidget QGroupBox[collapsibleBody="true"] {{
532 margin-top: 0px;
533 padding-top: 0px;
534}}
535QDockWidget QGroupBox::title {{
536 left: 0;
537 /* 上のモーダル側と同じく、枠の外にはみ出さない位置にする */
538 top: 0px;
539 padding: 0;
540 color: {text_secondary};
541 background: transparent;
542 border-radius: 0;
543}}
545/* --- プロパティ欄の節の見出し: 本文より少し大きく・太く・濃くし、中身を字下げする。
546 区切りは細い罫線1本。1本目は親の見出しのすぐ下で二重線に見えるので引かない。 --- */
547QToolButton#property_subsection_toggle {{
548 background: transparent;
549 border: none;
550 border-top: 1px solid {border};
551 border-radius: 0;
552 /* 見本の節の見出し: 上に細い罫線、中身は 20px 字下げ(罫線はパディングを含む全幅に引かれる) */
553 padding: 9px 2px 5px 0;
554 margin-top: 4px;
555 text-align: left;
556 font-weight: 600;
557 font-size: {subheading_pt}pt;
558 color: {text_primary};
559}}
560QToolButton#property_subsection_toggle[firstSection="true"] {{
561 border-top: none;
562 margin-top: 0;
563}}
564QToolButton#property_subsection_toggle:!checked {{
565 padding-top: 7px;
566 padding-bottom: 7px;
567 margin-top: 0;
568}}
569QToolButton#property_subsection_toggle:hover {{
570 background: transparent;
571 /* on_accent はライトでは白なので、薄い背景のホバーでは消える。文字用の accent_fg で強調する */
572 color: {accent_fg};
573}}
574QToolButton#property_subsection_toggle:pressed {{
575 background: transparent;
576}}
577QWidget#property_section_body QLabel {{
578 color: {text_secondary};
579 background: transparent;
580}}
581/* --- 右のインスペクタ(GUI 刷新): ドックの枠と見出しの帯は出さず、左に1本の罫線 --- */
582QDockWidget#control_dock_widget {{
583 background: {surface};
584 border: none;
585 border-left: 1px solid {border};
586 border-radius: 0;
587}}
588QWidget#inspector, QWidget#inspector_page, QWidget#inspector_axes_content, QWidget#inspector_legend_content,
589QScrollArea#inspector_scroll_properties, QScrollArea#inspector_scroll_axes,
590QScrollArea#inspector_scroll_legend, QScrollArea#inspector_scroll_export,
591QScrollArea#inspector_scroll_properties > QWidget > QWidget, QScrollArea#inspector_scroll_axes > QWidget > QWidget,
592QScrollArea#inspector_scroll_legend > QWidget > QWidget, QScrollArea#inspector_scroll_export > QWidget > QWidget {{
593 background: {surface};
594 border: none;
595}}
596QWidget#inspector_tab_row {{
597 background: {surface};
598 border-bottom: 1px solid {border};
599}}
600QTabBar#inspector_tabs::tab {{
601 background: transparent;
602 border: none;
603 border-bottom: 2px solid transparent;
604 border-radius: 0;
605 padding: 12px 0 9px 0;
606 margin-right: 16px;
607 color: {text_secondary};
608 font-weight: 400;
609}}
610QTabBar#inspector_tabs::tab:hover {{
611 background: transparent;
612 color: {text_primary};
613}}
614QTabBar#inspector_tabs::tab:selected {{
615 background: transparent;
616 border-bottom: 2px solid {accent};
617 color: {text_primary};
618 font-weight: 600;
619}}
620QWidget#inspector_target {{
621 background: {surface};
622 border-bottom: 1px solid {border};
623}}
624QLabel#inspector_target_name {{
625 font-weight: 600;
626 background: transparent;
627}}
628QLabel#inspector_target_sub {{
629 color: {text_muted};
630 font-size: 11px;
631 background: transparent;
632}}
633/* シンプルのプロパティの下の案内(点線の枠と、アドバンスドに切り替えるリンク) */
634QFrame#lite_more_box {{
635 background: transparent;
636 border: 1px dashed {border_strong};
637 border-radius: 10px;
638 margin: 12px 0 4px 0;
639}}
640QLabel#lite_more_text, QLabel#lite_more_icon {{
641 color: {text_secondary};
642 background: transparent;
643 border: none;
644}}
645QPushButton#lite_more_button {{
646 background: transparent;
647 border: none;
648 padding: 0;
649 color: {accent_fg};
650 font-weight: 600;
651 text-align: left;
652}}
653QPushButton#lite_more_button:hover {{
654 background: transparent;
655 text-decoration: underline;
656}}
657QPushButton {{
658 background: {surface};
659 border: 1px solid {border_strong};
660 border-radius: 6px;
661 padding: 5px 14px;
662}}
663QPushButton:hover {{
664 background: {surface_2};
665 border-color: {accent};
666}}
667QPushButton:pressed {{
668 background: {accent_soft};
669}}
670QPushButton:disabled {{
671 color: {text_muted};
672 border-color: {border};
673}}
674QPushButton:default {{
675 /* 既定のボタン(Enter で実行)もほかの強調と同じ色で塗る。文字色は on_accent(塗りとの比で白か黒を選んである)。 */
676 background: {accent};
677 border-color: {accent};
678 color: {on_accent};
679}}
680/* フォーカスの枠はほかの選択・フォーカスと同じ強調の色。ボタンの通常・ホバー・押下の色はそれぞれの規則で決める。 */
681QPushButton:focus, QToolButton:focus {{
682 border: 1px solid {accent};
683}}
685/* --- アイコンだけの正方形のボタン(大きさは Python 側の setFixedSize) --- */
686QPushButton[iconOnly="true"] {{
687 padding: 4px;
688}}
690/* --- 入力欄 --- */
691QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QTextEdit, QPlainTextEdit {{
692 background: {surface};
693 border: 1px solid {border};
694 border-radius: 6px;
695 padding: 4px 8px;
696 selection-background-color: {accent_soft};
697}}
698QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus,
699QComboBox:focus, QTextEdit:focus, QPlainTextEdit:focus {{
700 border-color: {accent};
701}}
702QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled, QComboBox:disabled {{
703 color: {text_muted};
704 background: {surface_2};
705}}
707/* --- タイトルと軸ラベルのプレビュー: 入力欄と同じ見た目にし、ホバーで枠を強調の色にしてクリックできることを示す --- */
708QLabel#mathtext_preview_label {{
709 background: {surface};
710 border: 1px solid {border};
711 border-radius: 6px;
712 padding: 4px 8px;
713}}
714QLabel#mathtext_preview_label:hover {{
715 border-color: {accent};
716}}
718/* --- スピンボックスの上下ボタン: 矢印は width / height を指定するだけで描かれなくなるので、
719 画像(image: url(...))で与える。 --- */
720QSpinBox, QDoubleSpinBox {{
721 padding-right: 20px;
722}}
723/* 上下のボタンはそれぞれ独立した小さな角丸にして枠から少し離し、ふだんは透明にして矢印だけを見せる
724 (ホバーと押下のときだけ背景を出す)。 */
725QSpinBox::up-button, QDoubleSpinBox::up-button {{
726 subcontrol-origin: border;
727 subcontrol-position: top right;
728 width: 16px;
729 height: 13px;
730 margin: 1px 2px 1px 1px;
731 border: 1px solid transparent;
732 border-radius: 4px;
733 background: transparent;
734}}
735QSpinBox::down-button, QDoubleSpinBox::down-button {{
736 subcontrol-origin: border;
737 subcontrol-position: bottom right;
738 width: 16px;
739 height: 13px;
740 margin: 1px 2px 1px 1px;
741 border: 1px solid transparent;
742 border-radius: 4px;
743 background: transparent;
744}}
745QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover,
746QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover {{
747 background: {border_strong};
748}}
749QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed,
750QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed {{
751 background: {accent_soft};
752}}
753QSpinBox::up-arrow, QDoubleSpinBox::up-arrow {{
754 image: url({spin_up_arrow_url});
755 width: 12px;
756 height: 12px;
757}}
758QSpinBox::down-arrow, QDoubleSpinBox::down-arrow {{
759 image: url({spin_down_arrow_url});
760 width: 12px;
761 height: 12px;
762}}
763QComboBox::drop-down {{
764 border: none;
765 width: 20px;
766}}
767QComboBox::down-arrow {{
768 /* スピンボックスと同じ理由で画像にし、大きさもそろえる */
769 image: url({spin_down_arrow_url});
770 width: 12px;
771 height: 12px;
772}}
773QComboBox QAbstractItemView {{
774 background: {surface};
775 border: 1px solid {border};
776 selection-background-color: {accent_soft};
777 selection-color: {text_primary};
778 outline: none;
779}}
781/* --- リスト / ツリー / テーブル --- */
782QTreeWidget, QListWidget, QTableWidget {{
783 background: {surface};
784 border: 1px solid {border};
785 border-radius: 8px;
786 alternate-background-color: {surface_2};
787}}
788QHeaderView::section {{
789 background: {surface_2};
790 color: {text_secondary};
791 border: none;
792 border-bottom: 1px solid {border};
793 padding: 4px 6px;
794}}
795QTreeWidget::item, QListWidget::item, QTableWidget::item {{
796 padding: 3px;
797}}
798QTreeWidget::item:selected, QListWidget::item:selected, QTableWidget::item:selected {{
799 background: {accent_soft};
800 color: {text_primary};
801}}
802QTreeWidget::item, QListWidget::item {{
803 padding: 5px 4px;
804 border-radius: 5px;
805}}
807/* --- データセット一覧の選択: 背景は _DatasetTreeSelectionDelegate が1つの角丸で描く
808 (QSS の ::item:selected はアイコンの列と文字の列に別々の角丸を描き、隙間ができる)。
809 展開矢印の字下げの列は delegate を通らない drawBranches() で描かれ、汎用の ::item:selected が
810 にじみ出るので、この一覧では透明にして打ち消す。 --- */
811QTreeWidget#dataset_list_widget {{
812 selection-background-color: transparent;
813 selection-color: {text_primary};
814 /* 無いと、選択とフォーカスが重なったとき角丸の上に点線の四角が描かれる */
815 outline: none;
816 /* 枠線だけ消し、背景と角丸は残す */
817 border: none;
818}}
819/* 汎用の ::item:selected が字下げの列ににじむのを、この一覧だけ打ち消す(上の説明を参照)。 */
820QTreeWidget#dataset_list_widget::item:selected {{
821 background: transparent;
822}}
824/* --- データセットの検索欄の枠線を消す --- */
825QLineEdit#dataset_search_edit {{
826 border: none;
827 background: {surface_2};
828 border-radius: 7px;
829 padding: 5px 9px;
830}}
832/* --- タブ --- */
833QTabWidget::pane {{
834 border: 1px solid {border};
835 border-radius: 8px;
836}}
837QTabBar::tab {{
838 background: transparent;
839 color: {text_secondary};
840 padding: 6px 14px;
841 margin-right: 2px;
842 border-top-left-radius: 6px;
843 border-top-right-radius: 6px;
844 border-bottom: 2px solid transparent;
845}}
846QTabBar::tab:hover {{
847 background: {surface_2};
848 color: {text_primary};
849}}
850QTabBar::tab:selected {{
851 background: {surface};
852 border-bottom: 2px solid {accent};
853 color: {accent_fg};
854 font-weight: 600;
855}}
856/* QTabBar::close-button には何も指定しない。何か1つでも指定するとアイコンが描かれなくなる
857 (QProxyStyle で差し替えても効かない)。見た目は _FlatThemeProxyStyle で決める。 */
859QToolButton#add_tab_button {{
860 border-radius: 7px;
861 padding: 4px;
862}}
863QToolButton#add_tab_button:hover {{
864 background: {surface};
865}}
867/* --- チェックボックスとラジオボタン: QCheckBox::indicator には何も指定しない。何か1つでも指定すると
868 チェックマークが描かれず塗りつぶしの四角になる。見た目は _FlatThemeProxyStyle が描く。 --- */
869QRadioButton::indicator {{
870 width: 14px;
871 height: 14px;
872 border: 1px solid {border_strong};
873 border-radius: 7px;
874 background: {surface};
875}}
876QRadioButton::indicator:checked {{
877 /* チェックボックスの塗りと同じ色 */
878 background: {accent};
879 border-color: {accent};
880}}
882/* --- プログレスバー --- */
883QProgressBar {{
884 border: 1px solid {border};
885 border-radius: 6px;
886 text-align: center;
887 background: {surface_2};
888}}
889QProgressBar::chunk {{
890 background: {accent};
891 border-radius: 6px;
892}}
894/* --- スクロールバー --- */
895QScrollBar:vertical {{
896 background: transparent;
897 width: 11px;
898 margin: 0;
899}}
900QScrollBar::handle:vertical {{
901 background: {border_strong};
902 border-radius: 5px;
903 min-height: 24px;
904}}
905QScrollBar::handle:vertical:hover, QScrollBar::handle:vertical:pressed {{
906 /* つかめる場所を示すだけの部品なので、強調の色ではなく濃いグレー(text_muted)で強調する。
907 ドラッグ中も :hover が当たり続けるので、:pressed もそろえる。 */
908 background: {text_muted};
909}}
910QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
911 height: 0;
912}}
913QScrollBar:horizontal {{
914 background: transparent;
915 height: 11px;
916 margin: 0;
917}}
918QScrollBar::handle:horizontal {{
919 background: {border_strong};
920 border-radius: 5px;
921 min-width: 24px;
922}}
923QScrollBar::handle:horizontal:hover, QScrollBar::handle:horizontal:pressed {{
924 background: {text_muted};
925}}
926QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
927 width: 0;
928}}
930/* --- GUI 刷新の部品(gui/widgets/)。寸法は見本 docs/dev/mockups/gui_redesign/ のまま --- */
931/* セグメント: 塗りの地の中に、選んだものだけ面の色で浮かせる */
932QFrame#segmented_control {{
933 background: {surface_2};
934 border: none;
935 border-radius: 7px;
936}}
937QToolButton#segment {{
938 background: transparent;
939 border: none;
940 border-radius: 5px;
941 padding: 3px 8px;
942 color: {text_secondary};
943}}
944QToolButton#segment:hover {{
945 color: {text_primary};
946}}
947QToolButton#segment:checked {{
948 background: {raised};
949 color: {text_primary};
950 font-weight: 600;
951}}
952QToolButton#segment:focus {{
953 border: none;
954}}
955/* グラフの種類のタイル(アイコン+文字) */
956QToolButton#type_tile {{
957 background: {surface};
958 border: 1px solid {border};
959 border-radius: 9px;
960 padding: 4px 2px;
961 color: {text_secondary};
962 font-size: 11px;
963}}
964QToolButton#type_tile:hover {{
965 border-color: {border_strong};
966 background: {surface};
967}}
968QToolButton#type_tile:checked {{
969 border-color: {accent};
970 background: {accent_soft};
971 color: {accent_fg};
972 font-weight: 600;
973}}
974/* スライダー(線の太さ・透明度)。数値は横の今のスピンボックス */
975QSlider#slider_spin_slider::groove:horizontal {{
976 height: 4px;
977 background: {border_strong};
978 border-radius: 2px;
979}}
980QSlider#slider_spin_slider::sub-page:horizontal {{
981 background: {accent};
982 border-radius: 2px;
983}}
984QSlider#slider_spin_slider::handle:horizontal {{
985 background: #FFFFFF;
986 border: 2px solid {accent};
987 width: 8px;
988 height: 8px;
989 margin: -4px 0;
990 border-radius: 6px;
991}}
992QSlider#slider_spin_slider::sub-page:horizontal:disabled {{
993 background: {border_strong};
994}}
995QSlider#slider_spin_slider::handle:horizontal:disabled {{
996 border-color: {border_strong};
997}}
998/* スライダーの横の数値(矢印なし) */
999QDoubleSpinBox[sliderValue="true"], QSpinBox[sliderValue="true"] {{
1000 padding: 3px 4px;
1001}}
1002/* 統計のカード */
1003QFrame#stat_card {{
1004 background: {surface_2};
1005 border: none;
1006 border-radius: 9px;
1007}}
1008QLabel#stat_card_label {{
1009 color: {text_muted};
1010 font-size: 11px;
1011 background: transparent;
1012}}
1013QLabel#stat_card_value {{
1014 color: {text_primary};
1015 font-size: 14px;
1016 font-weight: 600;
1017 background: transparent;
1018}}
1019/* --- 画面の骨組み(gui/shell/): 上部バー・帯・左パネル --- */
1020QWidget#app_header {{
1021 background: {chrome};
1022 border-bottom: 1px solid {border};
1023}}
1024QLabel#header_title {{
1025 font-weight: 600;
1026 background: transparent;
1027}}
1028QFrame#header_separator {{
1029 background: {border_strong};
1030 border: none;
1031}}
1032QToolButton#header_button {{
1033 background: transparent;
1034 border: none;
1035 border-radius: 7px;
1036 padding: 4px;
1037}}
1038QToolButton#header_button:hover {{
1039 background: {surface};
1040}}
1041QToolButton#header_button:disabled {{
1042 background: transparent;
1043}}
1044QTabBar#project_tab_bar {{
1045 background: transparent;
1046}}
1047QTabBar#project_tab_bar::tab {{
1048 height: 32px;
1049 padding: 0 8px 0 12px;
1050 margin: 0 2px 0 0;
1051 border: none;
1052 border-radius: 8px;
1053 background: transparent;
1054 color: {text_secondary};
1055 font-weight: 400;
1056}}
1057QTabBar#project_tab_bar::tab:hover {{
1058 background: {surface_2};
1059 color: {text_primary};
1060}}
1061QTabBar#project_tab_bar::tab:selected {{
1062 background: {surface};
1063 color: {text_primary};
1064 border: none;
1065 font-weight: 600;
1066}}
1067QPushButton#command_search {{
1068 background: {surface};
1069 border: 1px solid {border};
1070 border-radius: 7px;
1071 padding: 0;
1072 text-align: left;
1073}}
1074QPushButton#command_search:hover {{
1075 border-color: {border_strong};
1076 background: {surface};
1077}}
1078QLabel#command_search_text {{
1079 color: {text_muted};
1080 background: transparent;
1081}}
1082QLabel#kbd_hint {{
1083 color: {text_secondary};
1084 background: transparent;
1085 border: 1px solid {border_strong};
1086 border-radius: 4px;
1087 padding: 0 5px;
1088 font-size: 10.5px;
1089}}
1090QPushButton[primaryButton="true"] {{
1091 background: {accent};
1092 border: 1px solid {accent};
1093 border-radius: 7px;
1094 color: {on_accent};
1095 font-weight: 600;
1096 padding: 5px 12px;
1097}}
1098QPushButton[primaryButton="true"]:hover {{
1099 background: {accent};
1100 border-color: {accent_fg};
1101}}
1102QPushButton[primaryButton="true"]:pressed {{
1103 background: {accent_fg};
1104}}
1105QPushButton[primaryButton="true"]:disabled {{
1106 background: {border_strong};
1107 border-color: {border_strong};
1108 color: {surface};
1109}}
1110QPushButton[ghostButton="true"], QToolButton[ghostButton="true"] {{
1111 background: transparent;
1112 border: none;
1113 border-radius: 7px;
1114 padding: 4px;
1115}}
1116QPushButton[ghostButton="true"]:hover, QToolButton[ghostButton="true"]:hover {{
1117 background: {surface_2};
1118}}
1119QPushButton[ghostButton="true"]:pressed, QToolButton[ghostButton="true"]:pressed {{
1120 background: {accent_soft};
1121}}
1122QStackedWidget#quick_access_slot, QStackedWidget#quick_access_slot QToolBar {{
1123 background: transparent;
1124 border: none;
1125 padding: 0;
1126 spacing: 2px;
1127}}
1128QWidget#activity_rail {{
1129 background: {chrome};
1130 border-right: 1px solid {border};
1131}}
1132QToolButton#rail_button {{
1133 background: transparent;
1134 border: none;
1135 border-radius: 10px;
1136}}
1137QToolButton#rail_button:hover {{
1138 background: {surface_2};
1139}}
1140QToolButton#rail_button:checked {{
1141 background: {accent};
1142}}
1143QStackedWidget#left_panel, QWidget#dataset_page {{
1144 background: {surface};
1145}}
1146QStackedWidget#left_panel {{
1147 border-right: 1px solid {border};
1148}}
1149QLabel#panel_title {{
1150 font-size: 15px;
1151 font-weight: 600;
1152 background: transparent;
1153}}
1154QLabel#count_pill {{
1155 background: {surface_2};
1156 color: {text_secondary};
1157 border-radius: 9px;
1158 padding: 0 7px;
1159 font-size: 11px;
1160}}
1161QLabel#stats_target_label {{
1162 color: {text_secondary};
1163 font-size: 12px;
1164 background: transparent;
1165}}
1166QWidget#side_page, QScrollArea#side_page_scroll, QScrollArea#side_page_scroll > QWidget,
1167QWidget#side_page_body {{
1168 background: {surface};
1169 border: none;
1170}}
1171QLabel#side_page_section {{
1172 color: {text_muted};
1173 font-size: 11.5px;
1174 padding: 10px 6px 4px 6px;
1175 background: transparent;
1176}}
1177QLabel#side_page_note {{
1178 color: {text_secondary};
1179 background: transparent;
1180}}
1181QPushButton#side_page_row {{
1182 background: transparent;
1183 border: none;
1184 border-radius: 7px;
1185 padding: 4px 8px;
1186 text-align: left;
1187 color: {text_primary};
1188}}
1189QPushButton#side_page_row:hover {{
1190 background: {surface_2};
1191}}
1192QPushButton#side_page_row:checked {{
1193 background: {accent_soft};
1194 color: {accent_fg};
1195 font-weight: 600;
1196}}
1197QPushButton#side_page_row:disabled {{
1198 color: {text_muted};
1199}}
1200QFrame#panel_foot {{
1201 background: transparent;
1202 border: none;
1203 border-top: 1px solid {border};
1204}}
1206/* 開閉できる節の見出し(右に状態の要約。要約は SectionHeader が text_muted で描く) */
1207QToolButton#section_header {{
1208 background: transparent;
1209 border: none;
1210 border-radius: 0;
1211 padding: 9px 0 5px 0;
1212 text-align: left;
1213 font-weight: 600;
1214 color: {text_primary};
1215}}
1216QToolButton#section_header:hover {{
1217 color: {accent_fg};
1218}}
1219"""
1222# 見出しの文字サイズはアプリ既定のフォントからの相対にする。px で固定すると、OS 既定の大きさ
1223# (Windows 9pt、macOS 13pt 前後)によって見出しが本文より小さくなる。
1224HEADING_POINT_OFFSET = 2.0 # 上の2つの開閉見出し
1225SUBHEADING_POINT_OFFSET = 1.0 # プロパティ欄の節の見出し
1226_FALLBACK_BASE_POINT_SIZE = 9.0
1229def heading_point_sizes():
1230 """(上の見出し, 節の見出し) の pt。"""
1231 app = QApplication.instance()
1232 base = app.font().pointSizeF() if app is not None else _FALLBACK_BASE_POINT_SIZE
1233 if base <= 0: 1233 ↛ 1235line 1233 didn't jump to line 1235 because the condition on line 1233 was never true
1234 # ピクセル指定のフォントだと pointSizeF() は -1
1235 base = _FALLBACK_BASE_POINT_SIZE
1236 return base + HEADING_POINT_OFFSET, base + SUBHEADING_POINT_OFFSET
1239def build_qss(tokens: dict) -> str:
1240 """_FLAT_QSS_TEMPLATE の {token} を埋めた QSS。矢印の画像は text_primary の色で作る。"""
1241 format_args = dict(tokens)
1242 format_args["spin_up_arrow_url"] = _spinbox_arrow_icon_url("up", tokens["text_primary"])
1243 format_args["spin_down_arrow_url"] = _spinbox_arrow_icon_url("down", tokens["text_primary"])
1244 _heading_pt, subheading_pt = heading_point_sizes()
1245 format_args["subheading_pt"] = f"{subheading_pt:.1f}"
1246 return _FLAT_QSS_TEMPLATE.format(**format_args)
1249def _token_qcolor(value: str) -> QColor:
1250 """トークンの色を QColor に。QColor(str) は QSS の "rgba(r, g, b, a)" を読めず不透明の黒になるので自前で解釈する。"""
1251 match = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)", value)
1252 if not match:
1253 return QColor(value)
1254 r, g, b, a = match.groups()
1255 color = QColor(int(r), int(g), int(b))
1256 color.setAlphaF(float(a))
1257 return color
1260def build_palette(tokens: dict) -> QPalette:
1261 """トークンから QPalette を作る。QSS の効かない所(Qt 標準のダイアログ、palette(...) を使うプラグインの部品)も揃う。"""
1262 palette = QPalette()
1263 roles = {
1264 "Window": "bg", "WindowText": "text_primary",
1265 "Base": "surface", "AlternateBase": "surface_2", "Text": "text_primary",
1266 "Button": "surface", "ButtonText": "text_primary",
1267 "ToolTipBase": "surface", "ToolTipText": "text_primary", "PlaceholderText": "text_muted",
1268 "Highlight": "accent", "HighlightedText": "on_accent",
1269 "Link": "accent_fg", "LinkVisited": "accent_fg", "BrightText": "danger",
1270 "Light": "surface", "Midlight": "border", "Mid": "border_strong", "Dark": "border_strong", "Shadow": "text_muted",
1271 }
1272 for role, key in roles.items():
1273 palette.setColor(getattr(QPalette.ColorRole, role), _token_qcolor(tokens[key]))
1274 for role in ("Text", "ButtonText", "WindowText"):
1275 palette.setColor(QPalette.ColorGroup.Disabled, getattr(QPalette.ColorRole, role), QColor(tokens["text_muted"]))
1276 return palette
1279def _is_toggle_switch(widget):
1280 """スイッチの見た目にするのはチェックボックスだけ。型を先に確かめる(Qt から渡る widget を、PySide が
1281 消えた別の部品の器と取り違えて返すことがあり、そのまま property() を呼ぶと例外が連鎖する)。"""
1282 return isinstance(widget, QCheckBox) and bool(widget.property(TOGGLE_SWITCH_PROPERTY))
1285class _FlatThemeProxyStyle(QProxyStyle):
1286 """QSS では描けないもの(QSS を当てると中身が消えるもの)をここで描く。
1288 QTabBar::close-button に QSS を当てると × が消え、QCheckBox::indicator に当てるとチェックマークが
1289 描かれない。どちらも QSS は当てず、ここでアイコンを差し替え、チェックボックスを自前で描く。
1290 """
1291 def __init__(self, base_style, tokens):
1292 super().__init__(base_style)
1293 self.update_tokens(tokens)
1295 def update_tokens(self, tokens):
1296 """色だけ替える(作り直して setStyle() し直すとクラッシュする。apply_theme() を参照)。"""
1297 self._tokens = tokens
1298 self._close_icon = _svg_icon("x", color=tokens["text_secondary"], size=14)
1299 self._close_pixmap = self._close_icon.pixmap(14, 14)
1301 def standardIcon(self, standard_icon, option=None, widget=None):
1302 if standard_icon == QStyle.StandardPixmap.SP_TabCloseButton:
1303 return self._close_icon
1304 return super().standardIcon(standard_icon, option, widget)
1306 def standardPixmap(self, standard_pixmap, option=None, widget=None):
1307 # タブの閉じるボタンは standardIcon() ではなく standardPixmap() でアイコンを取る
1308 if standard_pixmap == QStyle.StandardPixmap.SP_TabCloseButton: 1308 ↛ 1310line 1308 didn't jump to line 1310 because the condition on line 1308 was always true
1309 return self._close_pixmap
1310 return super().standardPixmap(standard_pixmap, option, widget)
1312 def pixelMetric(self, metric, option=None, widget=None):
1313 if _is_toggle_switch(widget):
1314 if metric == QStyle.PixelMetric.PM_IndicatorWidth:
1315 return TOGGLE_SWITCH_SIZE[0]
1316 if metric == QStyle.PixelMetric.PM_IndicatorHeight:
1317 return TOGGLE_SWITCH_SIZE[1]
1318 return super().pixelMetric(metric, option, widget)
1320 def drawPrimitive(self, element, option, painter, widget=None):
1321 if element == QStyle.PrimitiveElement.PE_IndicatorCheckBox:
1322 if _is_toggle_switch(widget):
1323 self._draw_toggle_switch(option, painter)
1324 else:
1325 self._draw_checkbox_indicator(option, painter)
1326 return
1327 if element == QStyle.PrimitiveElement.PE_FrameTabBarBase:
1328 # タブにしたドックの上に出る灰色の線(Fusion が描く、QSS では消せない)を描かない
1329 return
1330 super().drawPrimitive(element, option, painter, widget)
1332 def _draw_checkbox_indicator(self, option, painter):
1333 tokens = self._tokens
1334 checked = bool(option.state & QStyle.StateFlag.State_On)
1335 tristate = bool(option.state & QStyle.StateFlag.State_NoChange)
1336 enabled = bool(option.state & QStyle.StateFlag.State_Enabled)
1338 if not enabled:
1339 fill = QColor(tokens["surface_2"])
1340 border = QColor(tokens["border"])
1341 elif checked or tristate:
1342 fill = QColor(tokens["accent"])
1343 border = fill
1344 else:
1345 fill = QColor(tokens["surface"])
1346 border = QColor(tokens["border_strong"])
1348 painter.save()
1349 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1351 pen = QPen(border)
1352 pen.setWidthF(1.2)
1353 painter.setPen(pen)
1354 painter.setBrush(fill)
1355 rect = QRectF(option.rect).adjusted(0.75, 0.75, -0.75, -0.75)
1356 painter.drawRoundedRect(rect, 3, 3)
1358 if checked or tristate:
1359 check_color = QColor(tokens["on_accent"]) if enabled else QColor(tokens["text_muted"])
1360 check_pen = QPen(check_color)
1361 check_pen.setWidthF(1.6)
1362 check_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
1363 check_pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
1364 painter.setPen(check_pen)
1365 painter.setBrush(Qt.BrushStyle.NoBrush)
1367 r = option.rect
1368 path = QPainterPath()
1369 if tristate:
1370 # 一部だけ選ばれている: 横棒
1371 path.moveTo(r.x() + r.width() * 0.22, r.y() + r.height() * 0.5)
1372 path.lineTo(r.x() + r.width() * 0.78, r.y() + r.height() * 0.5)
1373 else:
1374 path.moveTo(r.x() + r.width() * 0.20, r.y() + r.height() * 0.52)
1375 path.lineTo(r.x() + r.width() * 0.42, r.y() + r.height() * 0.74)
1376 path.lineTo(r.x() + r.width() * 0.82, r.y() + r.height() * 0.26)
1377 painter.drawPath(path)
1379 painter.restore()
1381 def _draw_toggle_switch(self, option, painter):
1382 """オン・オフのスイッチ。オンは強調の色の地につまみが右、オフは灰色の地につまみが左。"""
1383 tokens = self._tokens
1384 checked = bool(option.state & QStyle.StateFlag.State_On)
1385 enabled = bool(option.state & QStyle.StateFlag.State_Enabled)
1386 track = QColor(tokens["accent"] if checked else tokens["border_strong"])
1387 if not enabled:
1388 track.setAlphaF(0.45)
1390 painter.save()
1391 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1392 rect = QRectF(option.rect)
1393 height = min(rect.height(), TOGGLE_SWITCH_SIZE[1])
1394 rect = QRectF(rect.x(), rect.center().y() - height / 2, min(rect.width(), TOGGLE_SWITCH_SIZE[0]), height)
1395 painter.setPen(Qt.PenStyle.NoPen)
1396 painter.setBrush(track)
1397 painter.drawRoundedRect(rect, height / 2, height / 2)
1398 margin = 2.5
1399 knob = height - 2 * margin
1400 knob_x = rect.right() - margin - knob if checked else rect.left() + margin
1401 painter.setBrush(QColor("#FFFFFF"))
1402 painter.drawEllipse(QRectF(knob_x, rect.top() + margin, knob, knob))
1403 painter.restore()
1406# チェックボックスをスイッチの見た目にする目印(値は今のチェックボックスのまま。make_toggle_switch() で付ける)
1407TOGGLE_SWITCH_PROPERTY = "toggleSwitch"
1408TOGGLE_SWITCH_SIZE = (30, 17)
1411def make_toggle_switch(checkbox):
1412 """QCheckBox をスイッチの見た目にする。部品を差し替えないので、値の対応(バインディング)はそのまま。"""
1413 checkbox.setProperty(TOGGLE_SWITCH_PROPERTY, True)
1414 style = checkbox.style()
1415 style.unpolish(checkbox)
1416 style.polish(checkbox)
1417 checkbox.updateGeometry()
1418 return checkbox
1421def apply_theme(app, dark: bool, theme_color: str = None):
1422 """ライトでもダークでも Fusion を使い、パレットだけ替える(スタイルを替えるとボタンの大きさや余白まで変わる)。
1424 theme_color を省くと、前に当てたテーマカラーのまま明暗だけを替える。
1425 """
1426 global _current_proxy_style, _current_tokens, _current_theme_color
1427 global _last_applied_dark, _last_applied_theme_color
1428 if theme_color is not None:
1429 _current_theme_color = normalize_hex(theme_color)
1431 tokens = tokens_for(dark, _current_theme_color)
1432 _current_tokens = tokens
1434 # 同じ値で何度も呼ばれる(タブごとなど)。setPalette() と setStyleSheet() は1回約130ms かかるので省く
1435 if (_current_proxy_style is not None and _last_applied_dark == dark
1436 and _last_applied_theme_color == _current_theme_color):
1437 return
1438 _last_applied_dark = dark
1439 _last_applied_theme_color = _current_theme_color
1441 # スタイルは QApplication に1つだけ作り、setStyle() も1回だけ。setStyle() は古いスタイルを削除するので、
1442 # 差し替えるとほかのタブのウィジェットが削除済みのスタイルを触ってプロセスごと落ちる
1443 if _current_proxy_style is None:
1444 base_style = QStyleFactory.create('Fusion')
1445 _current_proxy_style = _FlatThemeProxyStyle(base_style, tokens)
1446 app.setStyle(_current_proxy_style)
1447 else:
1448 _current_proxy_style.update_tokens(tokens)
1449 app.setPalette(build_palette(tokens))
1451 app.setStyleSheet(build_qss(tokens))
1454def current_tokens() -> dict:
1455 """今のテーマの配色トークン。apply_theme() の前なら LIGHT_TOKENS。"""
1456 return _current_tokens or LIGHT_TOKENS
1459def current_theme_color() -> str:
1460 """今当てているテーマカラー(利用者が選んだ色。補正の前)。"""
1461 return _current_theme_color
1464def current_accent_soft_qcolor() -> QColor:
1465 """選択の背景(accent_soft)を QColor で返す。"""
1466 return _token_qcolor((_current_tokens or LIGHT_TOKENS)["accent_soft"])
1469def install_dock_focus_highlight(window):
1470 """フォーカスのあるドックの枠を強調する(dockActive プロパティを QSS で描く)。
1472 focusChanged はプロセス全体で1つなので、ほかのタブのドックにフォーカスが移ったら、このウィンドウの強調を消すだけにする。
1473 後から足したドック(プラグインのパネル)も、祖先を辿るので登録しなくてよい。
1474 """
1475 from PySide6.QtWidgets import QApplication, QDockWidget
1477 def _dock_ancestor(widget):
1478 while widget is not None and not isinstance(widget, QDockWidget):
1479 widget = widget.parentWidget()
1480 return widget
1482 def _set_active(dock, active):
1483 if dock is None:
1484 return
1485 dock.setProperty("dockActive", active)
1486 style = dock.style()
1487 style.unpolish(dock)
1488 style.polish(dock)
1490 state = {"active": None}
1492 def _on_focus_changed(old, new):
1493 dock = _dock_ancestor(new) if new is not None else None
1494 if dock is not None and dock.window() is not window:
1495 dock = None
1496 if dock is state["active"]:
1497 return
1498 _set_active(state["active"], False)
1499 _set_active(dock, True)
1500 state["active"] = dock
1502 app = QApplication.instance()
1503 app.focusChanged.connect(_on_focus_changed)
1504 # 窓が破棄された後に接続が残らないように
1505 window.destroyed.connect(lambda: app.focusChanged.disconnect(_on_focus_changed))
1508def apply_form_spacing(widget, spacing=12):
1509 """フォームの行の間隔を spacing まで広げる(既に広いものは縮めない)。"""
1510 from PySide6.QtWidgets import QFormLayout
1511 for form_layout in widget.findChildren(QFormLayout):
1512 if form_layout.verticalSpacing() < spacing:
1513 form_layout.setVerticalSpacing(spacing)
1516def disable_scroll_value_change():
1517 """スピンボックスとコンボでホイールによる値の変更を止める(スクロールしただけで値が変わる)。
1519 フォーカスの有無では判定できない(フォーカスはカーソルが離れても残る)ので常に止める。無視したイベントは
1520 親に渡るので、スクロールはそのままできる。クラスのメソッドを書き換えるので、起動時に1回呼ぶ。
1521 """
1522 global _wheel_value_change_disabled
1523 if _wheel_value_change_disabled:
1524 return
1525 _wheel_value_change_disabled = True
1527 def _ignore_wheel_event(self, event):
1528 event.ignore()
1530 QAbstractSpinBox.wheelEvent = _ignore_wheel_event
1531 QComboBox.wheelEvent = _ignore_wheel_event