Coverage for paintmaskanimator/history_panel.py: 69%

217 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""History panel: a branching, block-graph view of the undo/redo stacks. 

2 

3The canvas keeps a delta-based ``undo_stack`` / ``redo_stack`` plus the futures 

4that were split off when a new edit followed an undo (``history_branches``). 

5This panel does not own any state of its own; it lays the history out on a big 

62-D canvas of blocks. The main line runs straight down column 0 (oldest past 

7state at the top, the current state highlighted, future redo states dimmed 

8below). Each split-off future becomes its own downward column of blocks, 

9diverging from the block it branched off; several branches sharing one 

10divergence point fan out into separate columns side by side. Clicking a 

11main-line block jumps there by replaying the right number of undo/redo steps; 

12clicking a branch block switches the canvas over to that future instead. 

13""" 

14from PySide6.QtCore import QRectF, Qt, Signal 

15from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen 

16from PySide6.QtWidgets import ( 

17 QLabel, 

18 QMessageBox, 

19 QScrollArea, 

20 QVBoxLayout, 

21 QWidget, 

22) 

23from .i18n import tr 

24from .undo_entries import history_label_for 

25from .logging_setup import get_logger 

26 

27log = get_logger(__name__) 

28 

29_KIND_MAIN = "main" # 値=移動量(負=Undo、正=Redo) 

30_KIND_BRANCH = "branch" # 値=canvas.history_branches の添字 

31 

32# ブロック(1状態)とグリッドの寸法。 

33_BLOCK_W = 128 # ブロックの横幅。 

34_BLOCK_H = 26 # ブロックの高さ。 

35_COL_GAP = 28 # 列(レーン)どうしの隙間。 

36_ROW_GAP = 16 # 行どうしの隙間。 

37_COL_PITCH = _BLOCK_W + _COL_GAP 

38_ROW_PITCH = _BLOCK_H + _ROW_GAP 

39_MARGIN = 14 # キャンバス端からの余白。 

40_RADIUS = 6 # ブロック角の丸み。 

41 

42 

43def _block_rect(col, row): 

44 """列・行のグリッド座標を、そのブロックの矩形に変換する。""" 

45 x = _MARGIN + col * _COL_PITCH 

46 y = _MARGIN + row * _ROW_PITCH 

47 return QRectF(x, y, _BLOCK_W, _BLOCK_H) 

48 

49 

50class _HistoryGraph(QWidget): 

51 """undo/redo/分岐を、ブロックと接続線で描く2Dキャンバス。""" 

52 

53 jumpRequested = Signal(int) 

54 # (分岐の添字, 切り替え後に進めるRedo回数)。クリックしたブロックまで進む。 

55 branchSwitchRequested = Signal(int, int) 

56 

57 def __init__(self, parent=None): 

58 super().__init__(parent) 

59 self._nodes = [] # 描画・当たり判定用のノード情報。 

60 self._current_index = -1 # 現在ノードの添字。 

61 self.setMouseTracking(True) 

62 self.setMinimumWidth(_MARGIN * 2 + _BLOCK_W) 

63 

64 def set_nodes(self, nodes, current_index): 

65 self._nodes = nodes 

66 self._current_index = current_index 

67 max_col = max((n["col"] for n in nodes), default=0) 

68 max_row = max((n["row"] for n in nodes), default=0) 

69 width = _MARGIN * 2 + (max_col + 1) * _BLOCK_W + max_col * _COL_GAP 

70 height = _MARGIN * 2 + (max_row + 1) * _BLOCK_H + max_row * _ROW_GAP 

71 self.setMinimumSize(int(width), int(height)) 

72 self.resize(int(max(width, self.width())), int(height)) 

73 self.update() 

74 

75 def _clickable(self, node): 

76 return node["kind"] == _KIND_BRANCH or int(node["value"]) != 0 

77 

78 # --- 当たり判定・操作 ------------------------------------------------- 

79 def _node_at(self, pos): 

80 for node in self._nodes: 

81 if _block_rect(node["col"], node["row"]).contains(pos): 

82 return node 

83 return None 

84 

85 def mousePressEvent(self, event): 

86 node = self._node_at(event.position()) 

87 if node is None: 

88 return 

89 if node["kind"] == _KIND_MAIN: 

90 delta = int(node["value"]) 

91 if delta != 0: 

92 self.jumpRequested.emit(delta) 

93 elif node["kind"] == _KIND_BRANCH: 

94 self.branchSwitchRequested.emit( 

95 int(node["value"]), int(node["steps"]) 

96 ) 

97 

98 def mouseMoveEvent(self, event): 

99 node = self._node_at(event.position()) 

100 self.setCursor( 

101 Qt.CursorShape.PointingHandCursor 

102 if node is not None and self._clickable(node) 

103 else Qt.CursorShape.ArrowCursor 

104 ) 

105 

106 # --- 描画 ------------------------------------------------------------- 

107 def paintEvent(self, _event): 

108 painter = QPainter(self) 

109 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) 

110 

111 pal = self.palette() 

112 text_color = pal.text().color() 

113 mid_color = pal.mid().color() 

114 accent = pal.highlight().color() 

115 accent_text = pal.highlightedText().color() 

116 past_fill = QColor(mid_color) 

117 past_fill.setAlpha(60) 

118 

119 # まず接続線を下地として描く(ブロックの背面)。 

120 for node in self._nodes: 

121 src = node.get("from") 

122 if src is None: 122 ↛ 124line 122 didn't jump to line 124 because the condition on line 122 was always true

123 continue 

124 parent = _block_rect(src[0], src[1]) 

125 child = _block_rect(node["col"], node["row"]) 

126 p0x = parent.center().x() 

127 p0y = parent.bottom() 

128 p1x = child.center().x() 

129 p1y = child.top() 

130 painter.setBrush(Qt.BrushStyle.NoBrush) 

131 if node["curve"]: 

132 # 別の列へ枝分かれ:S字カーブでつなぐ。 

133 painter.setPen(QPen(mid_color, 2)) 

134 path = QPainterPath() 

135 path.moveTo(p0x, p0y) 

136 mid_y = (p0y + p1y) / 2 

137 path.cubicTo(p0x, mid_y, p1x, mid_y, p1x, p1y) 

138 painter.drawPath(path) 

139 else: 

140 # 同じ列の連続:まっすぐ縦線。 

141 pen = QPen(mid_color, 2) 

142 painter.setPen(pen) 

143 painter.drawLine(int(p0x), int(p0y), int(p1x), int(p1y)) 

144 

145 # ブロック本体。 

146 normal_font = self.font() 

147 bold_font = self.font() 

148 bold_font.setBold(True) 

149 for index, node in enumerate(self._nodes): 

150 rect = _block_rect(node["col"], node["row"]) 

151 is_current = ( 

152 node["kind"] == _KIND_MAIN and index == self._current_index 

153 ) 

154 is_branch = node["kind"] == _KIND_BRANCH 

155 

156 if is_current: 156 ↛ 161line 156 didn't jump to line 161 because the condition on line 156 was always true

157 painter.setPen(QPen(accent, 2)) 

158 painter.setBrush(accent) 

159 text_pen = accent_text 

160 font = bold_font 

161 elif is_branch: 

162 pen = QPen(mid_color, 1.5) 

163 pen.setStyle(Qt.PenStyle.DashLine) 

164 painter.setPen(pen) 

165 painter.setBrush(Qt.BrushStyle.NoBrush) 

166 text_pen = mid_color 

167 font = normal_font 

168 elif node["is_future"]: 

169 painter.setPen(QPen(mid_color, 1.5)) 

170 painter.setBrush(Qt.BrushStyle.NoBrush) 

171 text_pen = mid_color 

172 font = normal_font 

173 else: 

174 painter.setPen(QPen(mid_color, 1.5)) 

175 painter.setBrush(past_fill) 

176 text_pen = text_color 

177 font = normal_font 

178 

179 painter.drawRoundedRect(rect, _RADIUS, _RADIUS) 

180 

181 painter.setFont(font) 

182 painter.setPen(text_pen) 

183 fm = painter.fontMetrics() 

184 text_rect = rect.adjusted(8, 0, -8, 0) 

185 label = fm.elidedText( 

186 node["label"], 

187 Qt.TextElideMode.ElideRight, 

188 int(text_rect.width()), 

189 ) 

190 painter.drawText( 

191 text_rect, 

192 int(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft), 

193 label, 

194 ) 

195 

196 painter.end() 

197 

198 

199class HistoryPanel(QWidget): 

200 # クリックされた行までの移動量。負ならUndo、正ならRedoの回数。 

201 jumpRequested = Signal(int) 

202 # クリックされたブランチの添字と、切り替え後に進めるRedo回数。 

203 branchSwitchRequested = Signal(int, int) 

204 

205 def __init__(self, parent=None): 

206 super().__init__(parent) 

207 self.setMinimumWidth(180) 

208 self._canvas = None 

209 

210 layout = QVBoxLayout(self) 

211 layout.setContentsMargins(3, 3, 3, 3) 

212 layout.setSpacing(2) 

213 layout.addWidget(QLabel(tr("<b>ヒストリー</b>"))) 

214 self.graph = _HistoryGraph() 

215 self.graph.jumpRequested.connect(self.jumpRequested) 

216 self.graph.branchSwitchRequested.connect(self.branchSwitchRequested) 

217 

218 self._scroll = QScrollArea() 

219 self._scroll.setWidgetResizable(False) 

220 self._scroll.setWidget(self.graph) 

221 self._scroll.setHorizontalScrollBarPolicy( 

222 Qt.ScrollBarPolicy.ScrollBarAsNeeded 

223 ) 

224 self._scroll.setVerticalScrollBarPolicy( 

225 Qt.ScrollBarPolicy.ScrollBarAsNeeded 

226 ) 

227 layout.addWidget(self._scroll, 1) 

228 

229 def help_text(self) -> str: 

230 """パネル上部に常時表示していた説明。ヘルプボタンから開く。""" 

231 return tr("操作の履歴です。ブロックをクリックすると、その状態まで一気に戻る/" 

232 "進むします。戻ってから編集し直したときの元の履歴は右隣の列に" 

233 "分岐として残り、同じ地点から複数分岐したときは横に並びます。" 

234 "点線のブロックをクリックでそちらへ戻せます。") 

235 

236 def show_help(self) -> None: 

237 """ヒストリーパネルの使い方をダイアログで表示する。""" 

238 box = QMessageBox(self) 

239 box.setWindowTitle(tr("ヒストリーパネルの使い方")) 

240 box.setIcon(QMessageBox.Icon.Information) 

241 box.setTextFormat(Qt.TextFormat.PlainText) 

242 box.setText(self.help_text()) 

243 box.exec() 

244 

245 def set_canvas(self, canvas): 

246 self._canvas = canvas 

247 self.refresh() 

248 

249 def _main_rows(self, undo_stack, redo_stack): 

250 """本線の行を ``(ラベル, 移動量, 分岐位置, 未来か)`` の並びで返す。 

251 

252 分岐位置はその行が表す状態でのundo_stackの長さ。この値がそのまま 

253 本線ブロックの行番号(row)になり、分岐はこの行から下へ伸びる。 

254 """ 

255 n = len(undo_stack) 

256 # 何も積まれていない最初の状態。全部Undoすればここへ戻れる。 

257 rows = [(tr("開始状態"), -n, 0, False)] 

258 for i, entry in enumerate(undo_stack): 

259 # 最後のUndoエントリで到達するのが現在の状態(移動量0)。 

260 rows.append((history_label_for(entry), i - (n - 1), i + 1, False)) 

261 # 未来(Redo)。スタック末尾が次に進む先なので、近い順に並べる。 

262 for k in range(len(redo_stack)): 

263 entry = redo_stack[len(redo_stack) - 1 - k] 

264 rows.append((history_label_for(entry), k + 1, n + k + 1, True)) 

265 return rows 

266 

267 def refresh(self): 

268 """undo/redoスタックと分岐の現在の内容からブロック配置を作り直す。""" 

269 canvas = self._canvas 

270 if canvas is None: 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return 

272 undo_stack = getattr(canvas, "undo_stack", []) or [] 

273 redo_stack = getattr(canvas, "redo_stack", []) or [] 

274 branches = getattr(canvas, "history_branches", []) or [] 

275 

276 rows = self._main_rows(undo_stack, redo_stack) 

277 # 先頭に「開始状態」行があるぶん、現在行はundo段数と一致する。 

278 current_row = len(undo_stack) 

279 

280 nodes = [] 

281 current_index = -1 

282 

283 # 本線は列0にまっすぐ並ぶ。行番号はその状態の分岐位置に一致する。 

284 for label, delta, position, is_future in rows: 

285 display = tr("{label}(現在)").format(label=label) if position == current_row else label 

286 src = None if position == 0 else (0, position - 1) 

287 if position == current_row: 

288 current_index = len(nodes) 

289 nodes.append({ 

290 "kind": _KIND_MAIN, 

291 "value": int(delta), 

292 "label": display, 

293 "col": 0, 

294 "row": position, 

295 "is_future": is_future, 

296 "from": src, 

297 "curve": False, 

298 }) 

299 

300 # 分岐を列に割り当てる。行範囲が重ならない分岐は同じ列を使い回すが、 

301 # 同じ分岐点から出た複数の分岐は必ず別の列になり、横に並ぶ。 

302 col_spans = {} # col -> [(row_start, row_end), ...] 

303 

304 def alloc_col(start, end): 

305 col = 1 

306 while True: 

307 spans = col_spans.setdefault(col, []) 

308 if all(end < s or start > e for s, e in spans): 

309 spans.append((start, end)) 

310 return col 

311 col += 1 

312 

313 indexed = sorted( 

314 enumerate(branches), 

315 key=lambda pair: (int(pair[1].position), pair[0]), 

316 ) 

317 for branch_index, branch in indexed: 

318 entries = branch.entries 

319 total = len(entries) 

320 if total == 0: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 continue 

322 position = int(branch.position) 

323 start_row = position + 1 

324 end_row = position + total 

325 col = alloc_col(start_row, end_row) 

326 

327 # entries は redo_stack のコピー。末尾が分岐直後の1手なので、 

328 # 分岐点に近い順(末尾→先頭)に上から並べる。 

329 prev = (0, position) # 先頭ブロックは本線の分岐点から枝分かれ。 

330 for k in range(total): 

331 entry = entries[total - 1 - k] 

332 row = start_row + k 

333 label = history_label_for(entry) 

334 if k == 0: 

335 label = ( 

336 tr("{label}(分岐 {total}件)").format(label=label, total=total) 

337 if total > 1 else tr("{label}(分岐)").format(label=label) 

338 ) 

339 nodes.append({ 

340 "kind": _KIND_BRANCH, 

341 "value": int(branch_index), 

342 # 分岐へ切り替えたあと、このブロックまで進むRedo回数。 

343 "steps": k + 1, 

344 "label": label, 

345 "col": col, 

346 "row": row, 

347 "is_future": True, 

348 "from": prev, 

349 "curve": (k == 0), 

350 }) 

351 prev = (col, row) 

352 

353 self.graph.set_nodes(nodes, current_index) 

354 

355 # 現在ブロックが見えるようスクロールする。 

356 if current_index >= 0: 356 ↛ exitline 356 didn't return from function 'refresh' because the condition on line 356 was always true

357 rect = _block_rect( 

358 nodes[current_index]["col"], nodes[current_index]["row"] 

359 ) 

360 self._scroll.ensureVisible( 

361 int(rect.center().x()), int(rect.center().y()), 

362 _BLOCK_W, _ROW_PITCH, 

363 )