Coverage for graphica/gui/builders/canvas_area.py: 99.1%
198 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"""キャンバス・ツールバー・ミニマップ・ステータスバーの組み立て。"""
2import logging
3import time
4from PySide6.QtCore import QSize, Qt, QTimer
5from PySide6.QtGui import QAction
6from PySide6.QtWidgets import (QApplication, QFrame, QHBoxLayout, QLabel, QToolBar, QToolButton,
7 QVBoxLayout, QWidget)
8from graphica.core.axis_settings import axis_setting
9from graphica.core.i18n import tr
10from graphica.gui import app_settings, theme
11from graphica.gui.builders.common import TOOLBAR_ICON_SIZE, _svg_icon
12from graphica.gui.canvas import MplCanvas
13from graphica.gui.minimap_widget import MinimapWidget
14from graphica.gui.tools.cursor import COORDINATE_EMPTY_TEXT
15from graphica.gui.theme_color import theme_color_or_default
17logger = logging.getLogger(__name__)
20def build_canvas_and_toolbar(app):
21 app.canvas = MplCanvas(app, width=5, height=4, dpi=100)
22 app.canvas.dark_mode = app_settings.DARK_MODE.read(app.settings)
23 # アイコンは作るときにテーマの色を焼き込むので、アイコンを作り始める前にテーマを当てる
24 # (当てないと、ダークモードで起動したときツールバーのアイコンがライト用の色になって見えない)
25 theme.apply_theme(QApplication.instance(), app.canvas.dark_mode,
26 theme_color_or_default(app_settings.THEME_COLOR.read(app.settings)))
27 app.canvas.point_label_max_points = app_settings.POINT_LABEL_MAX_POINTS.read(app.settings)
28 # 表示範囲の操作はツールバーではなくマウスで行う(gui/tools/view_navigation.py)ので、matplotlib のツールバーは使わない
29 _create_mode_actions(app)
30 _build_advanced_toolbar(app)
31 _build_lite_tool_rail(app)
32 _build_card(app)
35# マウスの操作のモード: (属性名, アイコン, 短い名前(ツールバーの文字), 説明(ツールチップ), 切り替えのメソッド)。
36# 互いに排他にするため、QAction は属性で持つ(gui/tools/manager.py の表から操作する)
37_MODE_ACTIONS = (
38 ("cursor_action", "pointer", "カーソル", "データカーソル", "_toggle_cursor_mode"),
39 ("annotation_action", "message-2", "注釈", "注釈 (クリック:テキスト / ドラッグ:矢印 / 右クリック:削除)",
40 "_toggle_annotation_mode"),
41 ("range_select_action", "select-all", "範囲選択", "範囲選択 (ドラッグした範囲のカレントデータセットをマスク)",
42 "_toggle_range_select_mode"),
43 ("peak_placement_action", "mountain", "ピーク",
44 "ピーク配置 (クリックで多峰分離フィットの初期値を追加、右クリックで削除)", "_toggle_peak_placement_mode"),
45 ("region_highlight_action", "highlight", "領域",
46 "領域ハイライト (横ドラッグ:縦帯 / 縦ドラッグ:横帯 / 右クリック:削除)", "_toggle_region_highlight_mode"),
47 ("slice_extraction_action", "chart-line", "スライス",
48 "スライス抽出 (2Dマップ上でドラッグした線分に沿って1Dデータを抽出)", "_toggle_slice_extraction_mode"),
49 ("layout_edit_action", "layout-grid", "配置",
50 "レイアウト編集 (自由配置レイアウト時のみ: ドラッグでプロットを移動/リサイズ)", "_toggle_layout_edit_mode"),
51)
52# テーマを替えたときにアイコンを作り直す QAction(_refresh_custom_svg_icons)
53TOOL_ACTION_ICONS = tuple((entry[0], entry[1]) for entry in _MODE_ACTIONS) + (
54 ("reset_zoom_action", "refresh"), ("fit_tool_action", "math-function"), ("smoothing_tool_action", "chart-line"))
55# シンプルの縦の道具に出すモード(見本: カーソル・注釈・範囲選択、区切りの下にフィット)
56LITE_MODE_ACTIONS = ("cursor_action", "annotation_action", "range_select_action")
59def _create_mode_actions(app):
60 for attr, icon_name, label, description, toggle in _MODE_ACTIONS:
61 action = QAction(_svg_icon(icon_name), tr(label), app)
62 action.setToolTip(tr(description))
63 action.setCheckable(True)
64 action.triggered.connect(getattr(app, toggle))
65 setattr(app, attr, action)
66 # 自由配置レイアウトのときだけ使える
67 app.layout_edit_action.setEnabled(False)
69 app.reset_zoom_action = QAction(_svg_icon("refresh"), tr("表示をリセット (拡大/パンを元に戻す)"), app)
70 app.reset_zoom_action.triggered.connect(app._reset_zoom)
71 app.fit_tool_action = QAction(_svg_icon("math-function"), tr("フィット"), app)
72 app.fit_tool_action.setToolTip(tr("曲線フィット..."))
73 app.fit_tool_action.triggered.connect(lambda _checked=False: app.fitting.fit_current_dataset())
74 app.smoothing_tool_action = QAction(_svg_icon("chart-line"), tr("平滑化"), app)
75 app.smoothing_tool_action.setToolTip(tr("Savitzky-Golayフィルタ") + "...")
76 app.smoothing_tool_action.triggered.connect(lambda _checked=False: app.processing.savgol_smooth())
79def _build_advanced_toolbar(app):
80 """アドバンスドの、プロットの上の文字付きの道具。モードの7つは1つの塊に見せる(QSS の toolSegment)。"""
81 toolbar = QToolBar(tr("プロット"), app)
82 toolbar.setObjectName("plot_toolbar")
83 toolbar.setIconSize(QSize(TOOLBAR_ICON_SIZE - 3, TOOLBAR_ICON_SIZE - 3))
84 toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
85 app.plot_toolbar = toolbar
86 attrs = [entry[0] for entry in _MODE_ACTIONS]
87 for position, attr in enumerate(attrs):
88 action = getattr(app, attr)
89 toolbar.addAction(action)
90 if position == 0:
91 place = "first"
92 elif position == len(attrs) - 1:
93 place = "last"
94 else:
95 place = "middle"
96 toolbar.widgetForAction(action).setProperty("toolSegment", place)
97 toolbar.addSeparator()
98 toolbar.addAction(app.fit_tool_action)
99 toolbar.addAction(app.smoothing_tool_action)
102def _build_lite_tool_rail(app):
103 """シンプルの、グラフの左に浮かせた縦の道具(アイコンだけ)。QAction はアドバンスドのものと同じ。"""
104 rail = QToolBar(tr("道具"), app)
105 rail.setObjectName("plot_tool_rail")
106 rail.setOrientation(Qt.Orientation.Vertical)
107 rail.setIconSize(QSize(18, 18))
108 rail.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
109 for attr in LITE_MODE_ACTIONS:
110 rail.addAction(getattr(app, attr))
111 rail.addSeparator()
112 rail.addAction(app.fit_tool_action)
113 app.plot_tool_rail = rail
116def _card_button(icon_name, tooltip):
117 button = QToolButton()
118 button.setObjectName("card_button")
119 button.setToolTip(tooltip)
120 button.setIcon(_svg_icon(icon_name))
121 button.setProperty("iconName", icon_name)
122 button.setIconSize(QSize(17, 17))
123 button.setFixedSize(28, 28)
124 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
125 button.setCursor(Qt.CursorShape.PointingHandCursor)
126 return button
129def _build_card(app):
130 """グラフのカード: タイトルの行、(アドバンスドの道具)、(シンプルの縦の道具 | キャンバスとミニマップ)。"""
131 app.ui.plot_container.setObjectName("plot_container")
132 card_layout = QVBoxLayout(app.ui.plot_container)
133 card_layout.setContentsMargins(14, 10, 14, 10)
134 card_layout.setSpacing(6)
136 title_row = QHBoxLayout()
137 title_row.setSpacing(6)
138 app.card_title_label = QLabel()
139 app.card_title_label.setObjectName("card_title")
140 app.card_series_label = QLabel()
141 app.card_series_label.setObjectName("card_meta")
142 app.card_subplot_label = QLabel()
143 app.card_subplot_label.setObjectName("card_meta")
144 app.card_reset_button = _card_button("refresh", app.reset_zoom_action.text())
145 app.card_reset_button.clicked.connect(lambda _checked=False: app.reset_zoom_action.trigger())
146 # ミニマップと切り離しの QAction はメニューと同じもので、メニューを作った後に finish_card() がつなぐ
147 app.card_minimap_button = _card_button("map", tr("ミニマップ"))
148 app.card_detach_button = _card_button("external-link", tr("キャンバスを別ウィンドウに切り離す"))
149 title_row.addWidget(app.card_title_label)
150 title_row.addWidget(app.card_series_label)
151 title_row.addStretch(1)
152 for widget in (app.card_subplot_label, app.card_reset_button, app.card_minimap_button, app.card_detach_button):
153 title_row.addWidget(widget)
154 card_layout.addLayout(title_row)
155 card_layout.addWidget(app.plot_toolbar)
157 body = QHBoxLayout()
158 body.setSpacing(10)
159 body.addWidget(app.plot_tool_rail, 0, Qt.AlignmentFlag.AlignTop)
160 plot_layout = QVBoxLayout()
161 plot_layout.setSpacing(6)
162 plot_layout.addWidget(app.canvas)
163 body.addLayout(plot_layout, 1)
164 card_layout.addLayout(body, 1)
166 # キャンバスを別ウィンドウへ切り離したあと、元の位置に戻せるようにする
167 # (この後に足すミニマップはキャンバスより後ろなので、この位置は変わらない)
168 app._plot_layout = plot_layout
169 app._canvas_layout_index = plot_layout.indexOf(app.canvas)
170 app._canvas_detach_window = None
171 app.canvas_detached = False
173 # グラフの下の小さな全体図。ドラッグで全部の軸の X の範囲を絞る。区切りは見本どおり線を引かず、すき間だけ
174 app.minimap_separator = QFrame()
175 app.minimap_separator.setObjectName("canvas_separator")
176 app.minimap_separator.setFixedHeight(2)
177 plot_layout.addWidget(app.minimap_separator)
179 app.minimap = MinimapWidget(app)
180 app.minimap.range_selected.connect(app._on_minimap_range_selected)
181 plot_layout.addWidget(app.minimap)
183 app.minimap_visible = app_settings.MINIMAP_VISIBLE.read(app.settings)
184 app.minimap.setVisible(app.minimap_visible)
185 app.minimap_separator.setVisible(app.minimap_visible)
187 app.canvas.mpl_connect('draw_event', lambda _event: refresh_card_title(app))
190def finish_card(app):
191 """メニューを作った後に呼ぶ: タイトルの行のボタンを、メニューと同じ QAction につなぐ。"""
192 app.card_minimap_button.setCheckable(True)
193 app.card_minimap_button.setChecked(app.minimap_action.isChecked())
194 app.card_minimap_button.clicked.connect(lambda _checked=False: app.minimap_action.trigger())
195 app.minimap_action.toggled.connect(app.card_minimap_button.setChecked)
196 app.card_detach_button.clicked.connect(lambda _checked=False: app.canvas_detach_action.trigger())
197 refresh_card_title(app)
200def refresh_card_title(app):
201 """タイトルは選んでいるサブプロットのタイトル(数式の $ は外す)。無ければ「サブプロット N」(決定 8)。"""
202 settings_list = getattr(app.project, 'all_plot_settings', None) or []
203 index = getattr(app.project, 'active_axis_index', 0) or 0
204 settings = settings_list[index] if 0 <= index < len(settings_list) else {}
205 title = str(axis_setting(settings, 'title') or '').replace('$', '').strip()
206 app.card_title_label.setText(title or tr("サブプロット {n}").format(n=index + 1))
207 count = sum(1 for ds in app.project.datasets
208 if getattr(ds, 'visible', True) and getattr(ds, 'subplot_target', 0) == index)
209 app.card_series_label.setText(tr("{n} 系列").format(n=count))
210 total = max(1, len(settings_list))
211 app.card_subplot_label.setText(tr("サブプロット {i} / {n}").format(i=index + 1, n=total))
214# 自動保存の「N 分前」を書き直す間隔
215STATUS_REFRESH_MS = 30_000
218def autosave_status_text(enabled, interval_minutes, seconds_since_save):
219 """ステータスバーの自動保存の文字。seconds_since_save は、このタブでまだ保存していなければ None。"""
220 if not enabled:
221 return tr("自動保存 オフ")
222 if seconds_since_save is None:
223 return tr("自動保存 {n} 分ごと").format(n=interval_minutes)
224 minutes = int(seconds_since_save // 60)
225 if minutes < 1:
226 return tr("自動保存 たった今")
227 return tr("自動保存 {n} 分前").format(n=minutes)
230def setup_status_bar(app):
231 """見本のステータスバー: 左に「● 準備完了」と(アドバンスド)データセットと選択の数、右に(アドバンスド)座標と自動保存。
233 左の2つは通常の部品なので、showMessage() の間は一時的に知らせに置き換わる。
234 """
235 bar = app.ui.statusbar
236 bar.setSizeGripEnabled(False)
237 app.status_ready = QWidget()
238 ready_layout = QHBoxLayout(app.status_ready)
239 ready_layout.setContentsMargins(4, 0, 8, 0)
240 ready_layout.setSpacing(6)
241 dot = QFrame()
242 dot.setObjectName("status_dot")
243 dot.setFixedSize(7, 7)
244 ready_text = QLabel(tr("準備完了"))
245 ready_text.setObjectName("status_text")
246 ready_layout.addWidget(dot)
247 ready_layout.addWidget(ready_text)
248 bar.addWidget(app.status_ready)
249 app.status_selection_label = QLabel()
250 app.status_selection_label.setObjectName("status_text")
251 bar.addWidget(app.status_selection_label)
253 app.coordinate_label = QLabel(COORDINATE_EMPTY_TEXT)
254 app.coordinate_label.setObjectName("status_coordinates")
255 bar.addPermanentWidget(app.coordinate_label)
256 app.autosave_status_label = QLabel()
257 app.autosave_status_label.setObjectName("status_text")
258 bar.addPermanentWidget(app.autosave_status_label)
260 app._last_autosave_time = None
261 app.status_refresh_timer = QTimer(app)
262 app.status_refresh_timer.timeout.connect(lambda: refresh_status_bar(app))
263 app.status_refresh_timer.start(STATUS_REFRESH_MS)
264 app.canvas.mpl_connect('draw_event', lambda _event: refresh_status_bar(app))
267def refresh_status_bar(app):
268 """データセットと選択の数、自動保存の文字を今の状態に合わせる。"""
269 if not hasattr(app, 'status_selection_label'): 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 return
271 total = len(app.project.datasets)
272 selected = len(app._get_selected_datasets()) if hasattr(app, 'property_panel') else 0
273 app.status_selection_label.setText(tr("{n} データセット · {m} 選択").format(n=total, m=selected))
274 timer = getattr(app, 'autosave_timer', None)
275 enabled = timer is not None and timer.isActive()
276 interval = timer.interval() // 60000 if enabled else 0
277 last = app._last_autosave_time
278 since = None if last is None else time.monotonic() - last
279 app.autosave_status_label.setText(autosave_status_text(enabled, interval, since))