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

1"""グラフの下のパネル(アドバンスドだけ): データ表・フィット結果・残差・来歴・ログのタブ。 

2 

3グラフのカードとは仕切り(つまんで高さを変える)で上下に分ける。中身は選んでいるデータセットに合わせて 

4refresh_bottom_panel() が更新する(プロパティ欄の update_ui_state から)。 

5""" 

6import logging 

7 

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) 

13 

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 

20 

21BOTTOM_PANEL_HEIGHT = 200 

22HEADER_HEIGHT = 36 

23GRIP_HEIGHT = 10 

24LOG_MAX_LINES = 1000 

25LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" 

26 

27# (キー, 表示名, アイコン)。見本の順に、残差(決定 11)をフィット結果の隣へ 

28BOTTOM_PAGES = ( 

29 ("table", "データ表", "table"), 

30 ("fit", "フィット結果", "math-function"), 

31 ("residual", "残差", "chart-dots"), 

32 ("provenance", "来歴", "history"), 

33 ("log", "ログ", "terminal-2"), 

34) 

35 

36 

37class _GripHandle(QSplitterHandle): 

38 """仕切りのつまみ: 真ん中に短い横棒(見本の .grip)。""" 

39 

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

47 

48 

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) 

55 

56 def createHandle(self): 

57 return _GripHandle(self.orientation(), self) 

58 

59 

60class BottomPanel(QFrame): 

61 """角丸のパネル。上にアイコン付きのタブと、右に表示中のデータセットの名前・行数、畳むボタン。""" 

62 

63 page_changed = Signal(str) 

64 

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) 

71 

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

82 

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) 

97 

98 self.stack = QStackedWidget() 

99 self.stack.setObjectName("bottom_panel_stack") 

100 layout.addWidget(self.stack, 1) 

101 

102 self._keys = [] 

103 self._buttons = {} 

104 self._icons = {} 

105 self._badges = {} 

106 self._restore_height = BOTTOM_PANEL_HEIGHT 

107 self.refresh_icons() 

108 

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) 

136 

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) 

146 

147 def current_page(self): 

148 return self._keys[self.stack.currentIndex()] if self._keys else None 

149 

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

157 

158 def badge_text(self, key): 

159 return self._badges[key].text() if self._badges[key].isVisibleTo(self) else "" 

160 

161 def set_meta(self, text): 

162 self.meta_label.setText(text) 

163 

164 def is_collapsed(self): 

165 return self.collapse_button.isChecked() 

166 

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

183 

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

189 

190 

191class _LogEmitter(QObject): 

192 line = Signal(str) 

193 

194 

195class _PanelLogHandler(logging.Handler): 

196 """アプリのログ(graphica.log と同じ行)をパネルに流す。どのスレッドから来ても、シグナルで GUI のスレッドに渡す。""" 

197 

198 def __init__(self, emitter): 

199 super().__init__(level=logging.INFO) 

200 self._emitter = emitter 

201 self.setFormatter(logging.Formatter(LOG_FORMAT)) 

202 

203 def emit(self, record): 

204 try: 

205 self._emitter.line.emit(self.format(record)) 

206 except RuntimeError: 

207 # パネルが先に消えた(タブを閉じた直後) 

208 pass 

209 

210 

211class LogView(QPlainTextEdit): 

212 """このウィンドウを開いてからのログ。古い行は LOG_MAX_LINES で捨てる。""" 

213 

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

226 

227 

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) 

244 

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) 

249 

250 app.data_table_model.modelReset.connect(_fix_row_number_column) 

251 app.data_table = table 

252 return table 

253 

254 

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) 

263 

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 

270 

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

280 

281 

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

292 

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

302 

303 app.residual_panel.refresh(dataset, fit_result) 

304 app.provenance_panel.refresh(dataset, app.project) 

305 

306 

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

314 

315 

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)