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
« 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.
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
27log = get_logger(__name__)
29_KIND_MAIN = "main" # 値=移動量(負=Undo、正=Redo)
30_KIND_BRANCH = "branch" # 値=canvas.history_branches の添字
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 # ブロック角の丸み。
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)
50class _HistoryGraph(QWidget):
51 """undo/redo/分岐を、ブロックと接続線で描く2Dキャンバス。"""
53 jumpRequested = Signal(int)
54 # (分岐の添字, 切り替え後に進めるRedo回数)。クリックしたブロックまで進む。
55 branchSwitchRequested = Signal(int, int)
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)
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()
75 def _clickable(self, node):
76 return node["kind"] == _KIND_BRANCH or int(node["value"]) != 0
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
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 )
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 )
106 # --- 描画 -------------------------------------------------------------
107 def paintEvent(self, _event):
108 painter = QPainter(self)
109 painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
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)
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))
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
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
179 painter.drawRoundedRect(rect, _RADIUS, _RADIUS)
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 )
196 painter.end()
199class HistoryPanel(QWidget):
200 # クリックされた行までの移動量。負ならUndo、正ならRedoの回数。
201 jumpRequested = Signal(int)
202 # クリックされたブランチの添字と、切り替え後に進めるRedo回数。
203 branchSwitchRequested = Signal(int, int)
205 def __init__(self, parent=None):
206 super().__init__(parent)
207 self.setMinimumWidth(180)
208 self._canvas = None
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)
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)
229 def help_text(self) -> str:
230 """パネル上部に常時表示していた説明。ヘルプボタンから開く。"""
231 return tr("操作の履歴です。ブロックをクリックすると、その状態まで一気に戻る/"
232 "進むします。戻ってから編集し直したときの元の履歴は右隣の列に"
233 "分岐として残り、同じ地点から複数分岐したときは横に並びます。"
234 "点線のブロックをクリックでそちらへ戻せます。")
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()
245 def set_canvas(self, canvas):
246 self._canvas = canvas
247 self.refresh()
249 def _main_rows(self, undo_stack, redo_stack):
250 """本線の行を ``(ラベル, 移動量, 分岐位置, 未来か)`` の並びで返す。
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
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 []
276 rows = self._main_rows(undo_stack, redo_stack)
277 # 先頭に「開始状態」行があるぶん、現在行はundo段数と一致する。
278 current_row = len(undo_stack)
280 nodes = []
281 current_index = -1
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 })
300 # 分岐を列に割り当てる。行範囲が重ならない分岐は同じ列を使い回すが、
301 # 同じ分岐点から出た複数の分岐は必ず別の列になり、横に並ぶ。
302 col_spans = {} # col -> [(row_start, row_end), ...]
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
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)
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)
353 self.graph.set_nodes(nodes, current_index)
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 )