Coverage for graphica/gui/shell/bottom_panel.py: 96.4%
244 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"""グラフの下のパネル(アドバンスドだけ): データ表・フィット結果・残差・来歴・ログのタブ。
3グラフのカードとは仕切り(つまんで高さを変える)で上下に分ける。中身は選んでいるデータセットに合わせて
4refresh_bottom_panel() が更新する(プロパティ欄の update_ui_state から)。
5"""
6import logging
8from PySide6.QtCore import QObject, QRectF, QSize, Qt, Signal
9from PySide6.QtGui import QColor, QPainter
10from PySide6.QtWidgets import (
11 QAbstractItemView, QButtonGroup, QFrame, QHBoxLayout, QHeaderView, QLabel, QPlainTextEdit, QSizePolicy,
12 QSplitter, QSplitterHandle, QStackedWidget, QTableView, QToolButton, QVBoxLayout, QWidget)
14from graphica.core.i18n import tr
15from graphica.gui import theme
16from graphica.gui.builders.common import _svg_icon
17from graphica.gui.data_table_model import DatasetTableModel, fit_dataset_for, fit_result_for
18from graphica.gui.provenance_panel import ProvenancePanel
19from graphica.gui.residual_panel import ResidualPanel
21BOTTOM_PANEL_HEIGHT = 200
22HEADER_HEIGHT = 36
23GRIP_HEIGHT = 10
24LOG_MAX_LINES = 1000
25LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
27# (キー, 表示名, アイコン)。見本の順に、残差(決定 11)をフィット結果の隣へ
28BOTTOM_PAGES = (
29 ("table", "データ表", "table"),
30 ("fit", "フィット結果", "math-function"),
31 ("residual", "残差", "chart-dots"),
32 ("provenance", "来歴", "history"),
33 ("log", "ログ", "terminal-2"),
34)
37class _GripHandle(QSplitterHandle):
38 """仕切りのつまみ: 真ん中に短い横棒(見本の .grip)。"""
40 def paintEvent(self, event):
41 painter = QPainter(self)
42 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
43 painter.setPen(Qt.PenStyle.NoPen)
44 painter.setBrush(QColor(theme.current_tokens()["border_strong"]))
45 painter.drawRoundedRect(QRectF((self.width() - 40) / 2, (self.height() - 3) / 2, 40, 3), 1.5, 1.5)
46 painter.end()
49class CenterSplitter(QSplitter):
50 def __init__(self, parent=None):
51 super().__init__(Qt.Orientation.Vertical, parent)
52 self.setObjectName("center_splitter")
53 self.setChildrenCollapsible(False)
54 self.setHandleWidth(GRIP_HEIGHT)
56 def createHandle(self):
57 return _GripHandle(self.orientation(), self)
60class BottomPanel(QFrame):
61 """角丸のパネル。上にアイコン付きのタブと、右に表示中のデータセットの名前・行数、畳むボタン。"""
63 page_changed = Signal(str)
65 def __init__(self, parent=None):
66 super().__init__(parent)
67 self.setObjectName("bottom_panel")
68 layout = QVBoxLayout(self)
69 layout.setContentsMargins(0, 0, 0, 0)
70 layout.setSpacing(0)
72 self.header = QWidget()
73 self.header.setObjectName("bottom_panel_header")
74 self.header.setFixedHeight(HEADER_HEIGHT)
75 self._header_layout = QHBoxLayout(self.header)
76 self._header_layout.setContentsMargins(6, 0, 6, 0)
77 self._header_layout.setSpacing(2)
78 self._tab_end = 0 # タブとバッジを足す位置(右の名前の手前)
79 self._group = QButtonGroup(self)
80 self._group.setExclusive(True)
81 self._group.idClicked.connect(lambda index: self.show_page(self._keys[index]))
83 self._header_layout.addStretch(1)
84 self.meta_label = QLabel()
85 self.meta_label.setObjectName("bottom_panel_meta")
86 self._header_layout.addWidget(self.meta_label)
87 self.collapse_button = QToolButton()
88 self.collapse_button.setObjectName("card_button")
89 self.collapse_button.setCheckable(True)
90 self.collapse_button.setFixedSize(26, 26)
91 self.collapse_button.setIconSize(QSize(16, 16))
92 self.collapse_button.setCursor(Qt.CursorShape.PointingHandCursor)
93 self.collapse_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
94 self.collapse_button.toggled.connect(self.set_collapsed)
95 self._header_layout.addWidget(self.collapse_button)
96 layout.addWidget(self.header)
98 self.stack = QStackedWidget()
99 self.stack.setObjectName("bottom_panel_stack")
100 layout.addWidget(self.stack, 1)
102 self._keys = []
103 self._buttons = {}
104 self._icons = {}
105 self._badges = {}
106 self._restore_height = BOTTOM_PANEL_HEIGHT
107 self.refresh_icons()
109 def add_page(self, key, title, icon_name, widget):
110 button = QToolButton()
111 button.setObjectName("bottom_tab")
112 button.setText(title)
113 button.setCheckable(True)
114 button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
115 button.setIconSize(QSize(14, 14))
116 button.setCursor(Qt.CursorShape.PointingHandCursor)
117 button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
118 index = len(self._keys)
119 self._keys.append(key)
120 self._buttons[key] = button
121 self._icons[key] = icon_name
122 button.setIcon(_svg_icon(icon_name, size=14))
123 self._group.addButton(button, index)
124 self._header_layout.insertWidget(self._tab_end, button)
125 self._tab_end += 1
126 badge = QLabel()
127 badge.setObjectName("bottom_badge")
128 badge.setFixedHeight(18)
129 badge.hide()
130 self._header_layout.insertWidget(self._tab_end, badge)
131 self._tab_end += 1
132 self._badges[key] = badge
133 self.stack.addWidget(widget)
134 if index == 0:
135 self.show_page(key)
137 def show_page(self, key):
138 if key not in self._keys: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 raise KeyError(key)
140 index = self._keys.index(key)
141 self._buttons[key].setChecked(True)
142 if self.stack.currentIndex() != index:
143 self.stack.setCurrentIndex(index)
144 self.page_changed.emit(key)
145 self.set_collapsed(False)
147 def current_page(self):
148 return self._keys[self.stack.currentIndex()] if self._keys else None
150 def set_badge(self, key, text, ok=True):
151 badge = self._badges[key]
152 badge.setText(text)
153 badge.setProperty("ok", bool(ok))
154 badge.style().unpolish(badge)
155 badge.style().polish(badge)
156 badge.setVisible(bool(text))
158 def badge_text(self, key):
159 return self._badges[key].text() if self._badges[key].isVisibleTo(self) else ""
161 def set_meta(self, text):
162 self.meta_label.setText(text)
164 def is_collapsed(self):
165 return self.collapse_button.isChecked()
167 def set_collapsed(self, collapsed):
168 """畳むと見出しの行だけを残す(仕切りは見出しの高さで止まる)。"""
169 if self.collapse_button.isChecked() != collapsed:
170 self.collapse_button.setChecked(collapsed)
171 return
172 if collapsed and self.height() > HEADER_HEIGHT + 2:
173 self._restore_height = self.height()
174 self.stack.setVisible(not collapsed)
175 self.setMaximumHeight(HEADER_HEIGHT + 2 if collapsed else 16777215)
176 self.collapse_button.setIcon(_svg_icon("chevron-right" if collapsed else "chevron-down", size=16))
177 self.collapse_button.setToolTip(tr("広げる") if collapsed else tr("畳む"))
178 splitter = self.parentWidget()
179 if not collapsed and isinstance(splitter, QSplitter) and self.height() <= HEADER_HEIGHT + 2:
180 sizes = splitter.sizes()
181 total = sum(sizes)
182 splitter.setSizes([max(0, total - self._restore_height), self._restore_height])
184 def refresh_icons(self):
185 for key, button in self._buttons.items():
186 button.setIcon(_svg_icon(self._icons[key], size=14))
187 collapsed = self.collapse_button.isChecked()
188 self.collapse_button.setIcon(_svg_icon("chevron-right" if collapsed else "chevron-down", size=16))
191class _LogEmitter(QObject):
192 line = Signal(str)
195class _PanelLogHandler(logging.Handler):
196 """アプリのログ(graphica.log と同じ行)をパネルに流す。どのスレッドから来ても、シグナルで GUI のスレッドに渡す。"""
198 def __init__(self, emitter):
199 super().__init__(level=logging.INFO)
200 self._emitter = emitter
201 self.setFormatter(logging.Formatter(LOG_FORMAT))
203 def emit(self, record):
204 try:
205 self._emitter.line.emit(self.format(record))
206 except RuntimeError:
207 # パネルが先に消えた(タブを閉じた直後)
208 pass
211class LogView(QPlainTextEdit):
212 """このウィンドウを開いてからのログ。古い行は LOG_MAX_LINES で捨てる。"""
214 def __init__(self, parent=None):
215 super().__init__(parent)
216 self.setObjectName("bottom_log")
217 self.setReadOnly(True)
218 self.setMaximumBlockCount(LOG_MAX_LINES)
219 self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
220 self.setPlaceholderText(tr("このウィンドウを開いてからのログがここに出ます(全部は graphica.log)。"))
221 self._emitter = _LogEmitter(self)
222 self._emitter.line.connect(self.appendPlainText)
223 self._handler = _PanelLogHandler(self._emitter)
224 logging.getLogger().addHandler(self._handler)
225 self.destroyed.connect(lambda *_args, handler=self._handler: logging.getLogger().removeHandler(handler))
228def _build_data_table(app):
229 app.data_table_model = DatasetTableModel(app)
230 table = QTableView()
231 table.setObjectName("bottom_data_table")
232 table.setModel(app.data_table_model)
233 table.verticalHeader().setVisible(False)
234 table.verticalHeader().setDefaultSectionSize(24)
235 table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
236 table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
237 table.setShowGrid(False)
238 table.setWordWrap(False)
239 # 見本どおり列は幅を等分(# だけ細い)。数万行でも重くならないよう、中身から幅を測らない
240 header = table.horizontalHeader()
241 header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
242 header.setMinimumSectionSize(56)
243 header.setHighlightSections(False)
245 def _fix_row_number_column():
246 if app.data_table_model.columnCount() > 0:
247 header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
248 header.resizeSection(0, 56)
250 app.data_table_model.modelReset.connect(_fix_row_number_column)
251 app.data_table = table
252 return table
255def build_bottom_panel(app):
256 """グラフのカード(plot_container)を、カードと下のパネルの仕切りに置き換える。build_tab_shell から呼ぶ。"""
257 app.fit_info_textedit.setFrameShape(QFrame.Shape.NoFrame)
258 app.residual_panel = ResidualPanel(app)
259 app.provenance_panel = ProvenancePanel(app)
260 app.bottom_log = LogView()
261 for widget in (app.residual_panel, app.provenance_panel):
262 widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
264 panel = BottomPanel()
265 pages = {"table": _build_data_table(app), "fit": app.fit_info_textedit, "residual": app.residual_panel,
266 "provenance": app.provenance_panel, "log": app.bottom_log}
267 for key, title, icon_name in BOTTOM_PAGES:
268 panel.add_page(key, tr(title), icon_name, pages[key])
269 app.bottom_panel = panel
271 card = app.ui.plot_container
272 parent_layout = card.parentWidget().layout()
273 app.center_splitter = CenterSplitter()
274 parent_layout.replaceWidget(card, app.center_splitter)
275 app.center_splitter.addWidget(card)
276 app.center_splitter.addWidget(panel)
277 app.center_splitter.setStretchFactor(0, 1)
278 app.center_splitter.setStretchFactor(1, 0)
279 app.center_splitter.setSizes([10000, BOTTOM_PANEL_HEIGHT])
282def refresh_bottom_panel(app, dataset):
283 """表示しているデータセット(None なら空)に合わせて、表・フィット結果・残差・来歴を更新する。"""
284 if not hasattr(app, 'bottom_panel'): 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true
285 return
286 datasets = app.project.datasets
287 fit_result = fit_result_for(dataset, datasets)
288 app.data_table_model.set_dataset(dataset, fit_result)
289 rows = app.data_table_model.rowCount()
290 app.bottom_panel.set_meta(tr("{name} · {rows} 行").format(name=dataset.name, rows=rows)
291 if dataset is not None and rows else "")
293 fit_dataset = fit_dataset_for(dataset, datasets)
294 fit_text = getattr(fit_dataset, 'fit_info', None) or ""
295 if app.fit_info_textedit.toPlainText() != fit_text:
296 app.fit_info_textedit.setPlainText(fit_text)
297 r_squared = fit_result.get('r_squared') if fit_result else None
298 if isinstance(r_squared, (int, float)) and r_squared == r_squared:
299 app.bottom_panel.set_badge("fit", f"R² {r_squared:.3f}")
300 else:
301 app.bottom_panel.set_badge("fit", "")
303 app.residual_panel.refresh(dataset, fit_result)
304 app.provenance_panel.refresh(dataset, app.project)
307def refresh_theme(app):
308 """明暗を替えたとき: アイコンと、トークンの色で描いた残差の図を描き直す。"""
309 if not hasattr(app, 'bottom_panel'): 309 ↛ 310line 309 didn't jump to line 310 because the condition on line 309 was never true
310 return
311 app.bottom_panel.refresh_icons()
312 shown = app.property_panel.shown_dataset()
313 app.residual_panel.refresh(shown, fit_result_for(shown, app.project.datasets))
316def highlight_table_row(app, dataset, row_number):
317 """カーソルで選んだ点の行を、表で薄いアクセントの色にして見える位置まで送る。"""
318 if not hasattr(app, 'data_table_model'): 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 return
320 shown = app.property_panel.shown_dataset()
321 row = app.data_table_model.row_for_number(row_number) if dataset is shown else None
322 app.data_table_model.set_highlighted_row(row)
323 if row is not None:
324 app.data_table.scrollTo(app.data_table_model.index(row, 0), QAbstractItemView.ScrollHint.EnsureVisible)