From 17456af50ab44d6dacc92b5d3514fbcd140f8af0 Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Tue, 30 Dec 2025 19:11:27 +0000 Subject: [PATCH 1/2] Type hint inputs view --- src/dusted/inputs_view.py | 429 +++++++++++++++++++++----------------- 1 file changed, 233 insertions(+), 196 deletions(-) diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index a1b95b5..f703b3a 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -1,15 +1,20 @@ +from __future__ import annotations + import tkinter as tk +from typing import Any from dusted.commands import ( ClearInputsCommand, + Command, CommandSequence, DeleteFramesCommand, FillInputsCommand, InsertFramesCommand, SetInputsCommand, ) +from dusted.cursor import Cursor from dusted.dialog import SimpleDialog -from dusted.inputs import DEFAULT_INPUTS, INTENT_COUNT +from dusted.inputs import DEFAULT_INPUTS, INTENT_COUNT, Inputs from dusted.jump_to_frame import JumpToFrameDialog from dusted.replay_diagnostics import ReplayDiagnostics from dusted.undo_stack import UndoStack @@ -20,61 +25,76 @@ class InsertFramesDialog(SimpleDialog): - def __init__(self, grid): + def __init__(self, grid: Grid) -> None: super().__init__(grid, "Number of frames: ", "Insert") - self.grid = grid + self._grid = grid - def ok(self, text): + def ok(self, text: str) -> bool: try: n = int(text) except ValueError: return False if n >= 0: - self.grid.insert_frames(n) + self._grid.insert_frames(n) return True return False class GridCell: - def __init__(self, canvas, rect, text): - self.canvas = canvas - self.rect_object = rect - self.text_object = text - - self.state = None - self.fg = None - self.bg = None - self.text = None - - def delete(self): - self.canvas.delete(self.rect_object) - self.canvas.delete(self.text_object) - - def config(self, /, state=None, fg=None, bg=None, text=None): - rect_kwargs = {} - text_kwargs = {} - if state is not None and state != self.state: + def __init__(self, canvas: tk.Canvas, rect: int, text: int) -> None: + self._canvas = canvas + self._rect_object = rect + self._text_object = text + + self._state: str | None = None + self._fg: str | None = None + self._bg: str | None = None + self._text: str | None = None + + def delete(self) -> None: + self._canvas.delete(self._rect_object) + self._canvas.delete(self._text_object) + + def config( + self, + /, + state: str | None = None, + fg: str | None = None, + bg: str | None = None, + text: str | None = None, + ) -> None: + rect_kwargs: dict[str, Any] = {} + text_kwargs: dict[str, Any] = {} + if state is not None and state != self._state: rect_kwargs["state"] = state text_kwargs["state"] = state - self.state = state - if fg is not None and fg != self.fg: + self._state = state + if fg is not None and fg != self._fg: text_kwargs["fill"] = fg - self.fg = fg - if bg is not None and bg != self.bg: + self._fg = fg + if bg is not None and bg != self._bg: rect_kwargs["fill"] = bg - self.bg = bg - if text is not None and text != self.text: + self._bg = bg + if text is not None and text != self._text: text_kwargs["text"] = text - self.text = text + self._text = text if rect_kwargs: - self.canvas.itemconfig(self.rect_object, **rect_kwargs) + self._canvas.itemconfig(self._rect_object, **rect_kwargs) if text_kwargs: - self.canvas.itemconfig(self.text_object, **text_kwargs) + self._canvas.itemconfig(self._text_object, **text_kwargs) class Grid(tk.Canvas): - def __init__(self, parent, scrollbar, inputs, diagnostics, cursor, undo_stack): + def __init__( + self, + parent: tk.Misc, + scrollbar: tk.Scrollbar, + inputs: Inputs, + diagnostics: ReplayDiagnostics, + cursor: Cursor, + undo_stack: UndoStack, + ) -> None: super().__init__( parent, height=GRID_SIZE * (GRID_ROWS + 1), @@ -82,108 +102,112 @@ def __init__(self, parent, scrollbar, inputs, diagnostics, cursor, undo_stack): highlightthickness=0, ) - self.scrollbar = scrollbar - self.inputs = inputs - self.diagnostics = diagnostics - self.cursor = cursor - self.undo_stack = undo_stack - - self.pixel_width = 0 # view width - self.cell_width = 0 # number of cells in view - self.grid_objects = [[] for _ in range(GRID_ROWS)] - self.frame_objects = [] - self.current_col = 0 - self.redraw_scheduled = False - self.drag_timer = None - self.scroll_fraction = 0 - - self.inputs.subscribe(self.redraw) - self.diagnostics.subscribe(self.redraw) - self.cursor.subscribe(self.on_cursor_move) - - self.context_menu = tk.Menu(self, tearoff=0) - self.context_menu.add_command(label="Cut", command=self.cut) - self.context_menu.add_command(label="Copy", command=self.copy) - self.context_menu.add_command(label="Paste", command=self.paste) - self.context_menu.add_command( + self._scrollbar = scrollbar + self._inputs = inputs + self._diagnostics = diagnostics + self._cursor = cursor + self._undo_stack = undo_stack + + self._pixel_width = 0 # view width + self._cell_width = 0 # number of cells in view + self._grid_objects: list[list[GridCell]] = [[] for _ in range(GRID_ROWS)] + self._frame_objects: list[tuple[int, int]] = [] + self._current_col = 0 + self._redraw_scheduled = False + self._drag_timer: str | None = None + self._scroll_fraction = 0 + + self._inputs.subscribe(self.redraw) + self._diagnostics.subscribe(self.redraw) + self._cursor.subscribe(self._on_cursor_move) + + self._context_menu = tk.Menu(self, tearoff=0) + self._context_menu.add_command(label="Cut", command=self.cut) + self._context_menu.add_command(label="Copy", command=self.copy) + self._context_menu.add_command(label="Paste", command=self.paste) + self._context_menu.add_command( label="Insert frames", command=lambda: InsertFramesDialog(self) ) - self.context_menu.add_command(label="Delete frames", command=self.delete_frames) + self._context_menu.add_command( + label="Delete frames", command=self.delete_frames + ) - self.bind("", lambda e: self.resize()) + self.bind("", lambda e: self._on_resize()) - self.bind("", self.on_click) - self.bind("", lambda e: self.on_click(e, True)) - self.bind("", self.on_drag) - self.bind("", lambda e: self.on_release()) - self.bind("", self.on_right_click) - self.bind("", lambda e: self.on_scroll(tk.SCROLL, -1, tk.UNITS)) - self.bind("", lambda e: self.on_scroll(tk.SCROLL, 1, tk.UNITS)) + self.bind("", self._on_click) + self.bind("", lambda e: self._on_click(e, True)) + self.bind("", self._on_drag) + self.bind("", lambda e: self._on_release()) + self.bind("", self._on_right_click) + self.bind("", lambda e: self._on_scroll(tk.SCROLL, -1, tk.UNITS)) + self.bind("", lambda e: self._on_scroll(tk.SCROLL, 1, tk.UNITS)) self.bind( "", - lambda e: self.on_scroll(tk.SCROLL, -e.delta // 120, tk.UNITS), + lambda e: self._on_scroll(tk.SCROLL, -e.delta // 120, tk.UNITS), ) self.bind("", lambda e: self.cut()) self.bind("", lambda e: self.copy()) self.bind("", lambda e: self.paste()) - self.bind("", lambda e: self.undo_stack.undo()) - self.bind("", lambda e: self.undo_stack.redo()) + self.bind("", lambda e: self._undo_stack.undo()) + self.bind("", lambda e: self._undo_stack.redo()) self.bind("", lambda e: self.clear_selection()) self.bind("", lambda e: self.clear_selection()) - self.bind("", lambda e: self.move_cursor(0, -1)) - self.bind("", lambda e: self.move_cursor(0, 1)) - self.bind("", lambda e: self.move_cursor(-1, 0)) - self.bind("", lambda e: self.move_cursor(1, 0)) + self.bind("", lambda e: self._move_cursor(0, -1)) + self.bind("", lambda e: self._move_cursor(0, 1)) + self.bind("", lambda e: self._move_cursor(-1, 0)) + self.bind("", lambda e: self._move_cursor(1, 0)) self.bind( - "", lambda e: self.move_cursor(0, 1 - self.cell_width) + "", lambda e: self._move_cursor(0, 1 - self._cell_width) ) - self.bind("", lambda e: self.move_cursor(0, self.cell_width - 1)) self.bind( - "", lambda e: self.cursor.set(self.cursor.position[0], 0) + "", lambda e: self._move_cursor(0, self._cell_width - 1) + ) + self.bind( + "", lambda e: self._cursor.set(self._cursor.position[0], 0) ) self.bind( "", - lambda e: self.cursor.set(self.cursor.position[0], len(self.inputs) - 1), + lambda e: self._cursor.set(self._cursor.position[0], len(self._inputs) - 1), ) - self.bind("", lambda e: self.move_cursor(0, -1, True)) - self.bind("", lambda e: self.move_cursor(0, 1, True)) - self.bind("", lambda e: self.move_cursor(-1, 0, True)) - self.bind("", lambda e: self.move_cursor(1, 0, True)) + self.bind("", lambda e: self._move_cursor(0, -1, True)) + self.bind("", lambda e: self._move_cursor(0, 1, True)) + self.bind("", lambda e: self._move_cursor(-1, 0, True)) + self.bind("", lambda e: self._move_cursor(1, 0, True)) self.bind( "", - lambda e: self.move_cursor(0, 1 - self.cell_width, True), + lambda e: self._move_cursor(0, 1 - self._cell_width, True), ) self.bind( "", - lambda e: self.move_cursor(0, self.cell_width - 1, True), + lambda e: self._move_cursor(0, self._cell_width - 1, True), ) self.bind( "", - lambda e: self.cursor.set(self.cursor.position[0], 0, True), + lambda e: self._cursor.set(self._cursor.position[0], 0, True), ) self.bind( "", - lambda e: self.cursor.set( - self.cursor.position[0], len(self.inputs) - 1, True + lambda e: self._cursor.set( + self._cursor.position[0], len(self._inputs) - 1, True ), ) self.bind( - "", lambda e: JumpToFrameDialog(self, self.cursor) + "", lambda e: JumpToFrameDialog(self, self._cursor) ) - self.bind("", self.on_key) + self.bind("", self._on_key) - def resize(self): + def _on_resize(self) -> None: new_pixel_width = self.winfo_width() new_cell_width = new_pixel_width // GRID_SIZE + 1 - if new_cell_width > self.cell_width: - for col in range(self.cell_width, new_cell_width): + if new_cell_width > self._cell_width: + for col in range(self._cell_width, new_cell_width): x = GRID_SIZE * col # Create frame tick @@ -192,7 +216,7 @@ def resize(self): text = self.create_text( x + 5, GRID_SIZE // 2, text=str(col), anchor="w" ) - self.frame_objects.append((line, text)) + self._frame_objects.append((line, text)) # Create cells for row in range(GRID_ROWS): @@ -201,35 +225,35 @@ def resize(self): x, y, x + GRID_SIZE, y + GRID_SIZE, outline="gray" ) text = self.create_text(x + GRID_SIZE // 2, y + GRID_SIZE // 2) - self.grid_objects[row].append(GridCell(self, rect, text)) + self._grid_objects[row].append(GridCell(self, rect, text)) else: - for col in reversed(range(new_cell_width, self.cell_width)): + for col in reversed(range(new_cell_width, self._cell_width)): # Delete off-screen frame ticks if col % 10 == 0: - line, text = self.frame_objects[col // 10] + line, text = self._frame_objects[col // 10] self.delete(line) self.delete(text) - del self.frame_objects[col // 10] + del self._frame_objects[col // 10] # Delete off-screen cells for row in range(GRID_ROWS): - self.grid_objects[row][col].delete() - del self.grid_objects[row][col] + self._grid_objects[row][col].delete() + del self._grid_objects[row][col] - self.pixel_width = new_pixel_width - self.cell_width = new_cell_width + self._pixel_width = new_pixel_width + self._cell_width = new_cell_width self.redraw() - def cut(self): + def cut(self) -> None: self.copy() self.clear_selection() - def copy(self): - selection = self.inputs.read(self.cursor.selection) + def copy(self) -> None: + selection = self._inputs.read(self._cursor.selection) self.clipboard_clear() self.clipboard_append("\n".join("".join(row) for row in selection)) - def paste(self): + def paste(self) -> None: try: inputs = self.clipboard_get() except tk.TclError: @@ -240,127 +264,133 @@ def paste(self): block = [list(line) for line in inputs.split("\n")] # Ensure that the pasted block lies within the grid - if self.cursor.selection_top + len(block) > INTENT_COUNT: + if self._cursor.selection_top + len(block) > INTENT_COUNT: return # Check if the input grid needs to be resized extra_frames = max( - 0, self.cursor.selection_left + len(block[0]) - self.inputs.length + 0, self._cursor.selection_left + len(block[0]) - self._inputs.length ) - self.undo_stack.execute( + self._undo_stack.execute( CommandSequence( "Paste inputs", - InsertFramesCommand(self.inputs.length, extra_frames), - SetInputsCommand(self.cursor.selection_start, block), + InsertFramesCommand(self._inputs.length, extra_frames), + SetInputsCommand(self._cursor.selection_start, block), ) ) - def clear_selection(self): - self.undo_stack.execute(ClearInputsCommand(self.cursor.selection)) + def clear_selection(self) -> None: + self._undo_stack.execute(ClearInputsCommand(self._cursor.selection)) - def insert_frames(self, count): - self.undo_stack.execute(InsertFramesCommand(self.cursor.selection_left, count)) + def insert_frames(self, count: int) -> None: + self._undo_stack.execute( + InsertFramesCommand(self._cursor.selection_left, count) + ) - def delete_frames(self): + def delete_frames(self) -> None: # Protect against deleting the "frame-after-last" - width = self.cursor.selection_width - if self.cursor.selection_right == len(self.inputs): + width = self._cursor.selection_width + if self._cursor.selection_right == len(self._inputs): width -= 1 if width == 0: return - self.undo_stack.execute(DeleteFramesCommand(self.cursor.selection_left, width)) + self._undo_stack.execute( + DeleteFramesCommand(self._cursor.selection_left, width) + ) - def on_click(self, event, keep_selection=False): + def _on_click(self, event: tk.Event, keep_selection: bool = False) -> None: self.focus_set() raw_col = (event.x_root - self.winfo_rootx()) // GRID_SIZE raw_row = (event.y_root - self.winfo_rooty()) // GRID_SIZE - 2 # Clamp to the bounds of the view. - col = max(0, min(self.cell_width - 2, raw_col)) + col = max(0, min(self._cell_width - 2, raw_col)) row = max(0, min(INTENT_COUNT - 1, raw_row)) - self.cursor.set(row, col + self.current_col, keep_selection) + self._cursor.set(row, col + self._current_col, keep_selection) - if self.drag_timer is None: - self.drag_timer = self.after_idle(self.on_drag_tick) + if self._drag_timer is None: + self._drag_timer = self.after_idle(self._on_drag_tick) - def on_drag(self, event): + def _on_drag(self, event: tk.Event) -> None: raw_col = (event.x_root - self.winfo_rootx()) // GRID_SIZE raw_row = (event.y_root - self.winfo_rooty()) // GRID_SIZE - 2 # Clamp to the bounds of the view. - col = max(0, min(self.cell_width - 2, raw_col)) + col = max(0, min(self._cell_width - 2, raw_col)) row = max(0, min(INTENT_COUNT - 1, raw_row)) - self.cursor.set(row, col + self.current_col, True) + self._cursor.set(row, col + self._current_col, True) - def on_drag_tick(self) -> None: + def _on_drag_tick(self) -> None: """Called frequently while dragging.""" # If the mouse is outside the view, scroll in that direction. mouse_x = self.winfo_pointerx() - self.winfo_rootx() if mouse_x < 0: - self.scroll_fraction += mouse_x - elif mouse_x > (self.cell_width - 1) * GRID_SIZE: - self.scroll_fraction += mouse_x - (self.cell_width - 1) * GRID_SIZE + self._scroll_fraction += mouse_x + elif mouse_x > (self._cell_width - 1) * GRID_SIZE: + self._scroll_fraction += mouse_x - (self._cell_width - 1) * GRID_SIZE - col_offset, self.scroll_fraction = divmod(self.scroll_fraction, GRID_SIZE) - self.move_cursor(0, col_offset, keep_selection=True) + col_offset, self._scroll_fraction = divmod(self._scroll_fraction, GRID_SIZE) + self._move_cursor(0, col_offset, keep_selection=True) - self.drag_timer = self.after(33, self.on_drag_tick) + self._drag_timer = self.after(33, self._on_drag_tick) - def on_release(self) -> None: - if self.drag_timer is not None: - self.after_cancel(self.drag_timer) - self.drag_timer = None - self.scroll_fraction = 0 + def _on_release(self) -> None: + if self._drag_timer is not None: + self.after_cancel(self._drag_timer) + self._drag_timer = None + self._scroll_fraction = 0 - def on_right_click(self, event): + def _on_right_click(self, event: tk.Event) -> None: # This widget will not see any mouse release events that are sent while # the popup window is open, so emulate one now to be safe. - self.on_release() + self._on_release() - self.context_menu.tk_popup(event.x_root, event.y_root) + self._context_menu.tk_popup(event.x_root, event.y_root) - def on_key(self, event): + def _on_key(self, event: tk.Event) -> None: # Ignore special characters and anything with held modifiers + assert isinstance(event.state, int) if not event.char or modifier_held(event.state): return - if fill := self.cursor.has_selection: - command = FillInputsCommand(self.cursor.selection, event.char.lower()) + command: Command + if fill := self._cursor.has_selection: + command = FillInputsCommand(self._cursor.selection, event.char.lower()) else: - command = SetInputsCommand(self.cursor.position, [[event.char.lower()]]) + command = SetInputsCommand(self._cursor.position, [[event.char.lower()]]) - if self.cursor.selection_right == len(self.inputs): - self.undo_stack.execute( + if self._cursor.selection_right == len(self._inputs): + self._undo_stack.execute( CommandSequence( "Fill selection" if fill else "Set inputs", - InsertFramesCommand(self.cursor.current_col, 1), + InsertFramesCommand(self._cursor.current_col, 1), command, ) ) else: - self.undo_stack.execute(command) + self._undo_stack.execute(command) - def on_scroll(self, command, *args): + def _on_scroll(self, command, *args): if command == tk.MOVETO: f = max(0.0, min(1.0, float(args[0]))) - col = int(f * len(self.inputs)) - self.current_col = col + col = int(f * len(self._inputs)) + self._current_col = col elif command == tk.SCROLL: direction, size = args direction = int(direction) if size == tk.UNITS: - self.current_col += direction + self._current_col += direction elif size == tk.PAGES: - self.current_col += direction * (self.cell_width - 1) - self.current_col = max(0, min(len(self.inputs), self.current_col)) + self._current_col += direction * (self._cell_width - 1) + self._current_col = max(0, min(len(self._inputs), self._current_col)) self.redraw() - def move_cursor( + def _move_cursor( self, row_offset: int, col_offset: int, @@ -369,66 +399,70 @@ def move_cursor( """Move the cursor, keeping it on-screen.""" # Delay our cursor move callback from running until we have finished. - with self.cursor.batch(): - self.cursor.move(row_offset, col_offset, keep_selection) + with self._cursor.batch(): + self._cursor.move(row_offset, col_offset, keep_selection) # Check if the cursor is now off-screen. - _, col = self.cursor.position - if not (self.current_col <= col < self.current_col + self.cell_width - 1): + _, col = self._cursor.position + if not ( + self._current_col <= col < self._current_col + self._cell_width - 1 + ): # Scroll the view by the same amount. - self.current_col = max( - 0, min(len(self.inputs), self.current_col + col_offset) + self._current_col = max( + 0, min(len(self._inputs), self._current_col + col_offset) ) - def on_cursor_move(self): - _, col = self.cursor.position - if not (self.current_col <= col < self.current_col + self.cell_width - 1): + def _on_cursor_move(self) -> None: + _, col = self._cursor.position + if not (self._current_col <= col < self._current_col + self._cell_width - 1): # Scroll so that the cursor is in the middle of the view - self.current_col = max(0, min(len(self.inputs), col - self.cell_width // 2)) + self._current_col = max( + 0, min(len(self._inputs), col - self._cell_width // 2) + ) self.redraw() - def redraw(self, force=False): + def redraw(self, force: bool = False) -> None: if force: self._redraw() - elif not self.redraw_scheduled: - self.redraw_scheduled = True + elif not self._redraw_scheduled: + self._redraw_scheduled = True self.after_idle(self._redraw) - def _redraw(self): - self.redraw_scheduled = False + def _redraw(self) -> None: + self._redraw_scheduled = False frame_ticks = 0 - for col in range(self.cell_width): - true_col = self.current_col + col + for col in range(self._cell_width): + true_col = self._current_col + col # Draw cells for row in range(INTENT_COUNT): - cell = self.grid_objects[row + 1][col] - if true_col <= len(self.inputs): - if true_col == len(self.inputs): + cell = self._grid_objects[row + 1][col] + if true_col <= len(self._inputs): + if true_col == len(self._inputs): value = "" else: - value = self.inputs.at(row, true_col) + value = self._inputs.at(row, true_col) fg = "black" if value == DEFAULT_INPUTS[row]: fg = "lightgray" - if self.cursor.is_selected(row, true_col): + if self._cursor.is_selected(row, true_col): if value == DEFAULT_INPUTS[row]: fg = "#56a" else: fg = "white" bg = "#24b" - elif (row, true_col) in self.diagnostics.warnings: + elif (row, true_col) in self._diagnostics.warnings: fg = "black" bg = "#e82" - elif (row, true_col) in self.diagnostics.errors: + elif (row, true_col) in self._diagnostics.errors: fg = "black" bg = "#d22" elif true_col < 55: # Inputs before the player has control bg = "#dfd" - elif true_col >= len(self.inputs) - 14: + elif true_col >= len(self._inputs) - 14: # Inputs that are not early-exit safe bg = "#feb" else: @@ -439,12 +473,12 @@ def _redraw(self): cell.config(state="hidden") # Draw frame cell - self.grid_objects[0][col].config(text=str(true_col % 10)) + self._grid_objects[0][col].config(text=str(true_col % 10)) # Draw next frame tick if true_col % 10 == 0: x = GRID_SIZE * col - line, text = self.frame_objects[frame_ticks] + line, text = self._frame_objects[frame_ticks] self.coords(line, x, 0, x, (GRID_ROWS + 1) * GRID_SIZE) self.coords(text, x + 5, GRID_SIZE // 2) self.itemconfig(text, text=str(true_col)) @@ -452,26 +486,29 @@ def _redraw(self): frame_ticks += 1 # Hide unused frame ticks - for frame_tick in range(frame_ticks, len(self.frame_objects)): - line, text = self.frame_objects[frame_tick] + for frame_tick in range(frame_ticks, len(self._frame_objects)): + line, text = self._frame_objects[frame_tick] self.coords(line, -1, -1, -1, -1) self.itemconfig(text, text="") - self.update_scrollbar() - - def update_scrollbar(self): - left = self.current_col - right = self.current_col + self.cell_width - 1 + self._update_scrollbar() - length = max(1, len(self.inputs)) - left /= length - right /= length - - self.scrollbar.set(left, right) + def _update_scrollbar(self) -> None: + left = self._current_col + right = self._current_col + self._cell_width - 1 + length = max(1, len(self._inputs)) + self._scrollbar.set(left / length, right / length) class InputsView(tk.Frame): - def __init__(self, parent, inputs, diagnostics, cursor, undo_stack): + def __init__( + self, + parent: tk.Misc, + inputs: Inputs, + diagnostics: ReplayDiagnostics, + cursor: Cursor, + undo_stack: UndoStack, + ): super().__init__(parent) for row, text in enumerate( @@ -493,7 +530,7 @@ def __init__(self, parent, inputs, diagnostics, cursor, undo_stack): scrollbar = tk.Scrollbar(self, orient=tk.HORIZONTAL) grid = Grid(self, scrollbar, inputs, diagnostics, cursor, undo_stack) - scrollbar.config(command=grid.on_scroll) + scrollbar.config(command=grid._on_scroll) grid.grid(row=0, rowspan=GRID_ROWS + 1, column=1, sticky="ew") scrollbar.grid(row=GRID_ROWS + 1, column=1, sticky="ew") From 7f0d406eb8b65dbdb632b2648dae5f636b723daf Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Sun, 18 Jan 2026 19:21:00 +0000 Subject: [PATCH 2/2] Refactor undo stack to store the entire state Previously, the undo stack only stored the "commands" that were applied to update the state. This is memory efficient, but has become difficult to reason about. This new approach simply stores the entire state of the application before and after each action is taken. This will use a lot more memory, but this should be possible to optimise later if it turns out to be a problem. --- src/dusted/commands.py | 131 -------------------------------------- src/dusted/inputs_view.py | 60 ++++++----------- src/dusted/undo_stack.py | 128 ++++++++++++++++++++++++++----------- 3 files changed, 108 insertions(+), 211 deletions(-) delete mode 100644 src/dusted/commands.py diff --git a/src/dusted/commands.py b/src/dusted/commands.py deleted file mode 100644 index 26b231f..0000000 --- a/src/dusted/commands.py +++ /dev/null @@ -1,131 +0,0 @@ -from dusted.inputs import INTENT_COUNT - - -class Command: - def __init__(self, name): - self.name = name - - def undo(self, inputs, cursor): - raise NotImplementedError - - def redo(self, inputs, cursor): - raise NotImplementedError - - -class CommandSequence(Command): - def __init__(self, name, *commands): - super().__init__(name) - self.commands = commands - - def redo(self, inputs, cursor): - for command in self.commands: - command.redo(inputs, cursor) - - def undo(self, inputs, cursor): - for command in reversed(self.commands): - command.undo(inputs, cursor) - - -class SetInputsCommand(Command): - def __init__(self, position, new_inputs): - super().__init__("Set inputs") - self.position = position - self.new_inputs = new_inputs - self.old_inputs = None - self.old_selection = None - - def redo(self, inputs, cursor): - if self.old_inputs is None or self.old_selection is None: - assert self.old_inputs is None and self.old_selection is None - end = ( - self.position[0] + len(self.new_inputs) - 1, - self.position[1] + len(self.new_inputs[0]) - 1, - ) - self.old_inputs = inputs.read((*self.position, *end)) - self.old_selection = cursor.selection - - inputs.write(self.position, self.new_inputs) - cursor.select(self.old_selection) - - def undo(self, inputs, cursor): - inputs.write(self.position, self.old_inputs) - cursor.select(self.old_selection) - - -class FillInputsCommand(Command): - def __init__(self, selection, char): - super().__init__("Fill selection") - self.selection = selection - self.char = char - self.old_inputs = None - - def redo(self, inputs, cursor): - if self.old_inputs is None: - self.old_inputs = inputs.read(self.selection) - inputs.fill(self.selection, self.char) - cursor.select(self.selection) - - def undo(self, inputs, cursor): - inputs.write(self.selection[:2], self.old_inputs) - cursor.select(self.selection) - - -class ClearInputsCommand(Command): - def __init__(self, selection): - super().__init__("Clear selection") - self.selection = selection - self.old_inputs = None - - def redo(self, inputs, cursor): - if self.old_inputs is None: - self.old_inputs = inputs.read(self.selection) - inputs.clear(self.selection) - cursor.select(self.selection) - - def undo(self, inputs, cursor): - inputs.write(self.selection[:2], self.old_inputs) - cursor.select(self.selection) - - -class InsertFramesCommand(Command): - def __init__(self, start, count): - super().__init__(f"Insert {count} frame(s)") - self.start = start - self.count = count - self.old_selection_start = None - - def redo(self, inputs, cursor): - if self.old_selection_start is None: - self.old_selection_start = cursor.selection_start - - inputs.insert_frames(self.start, self.count) - cursor.set(*self.old_selection_start) - - def undo(self, inputs, cursor): - inputs.delete_frames(self.start, self.count) - cursor.set(*self.old_selection_start) - - -class DeleteFramesCommand(Command): - def __init__(self, start, count): - super().__init__(f"Delete {count} frame(s)") - self.start = start - self.count = count - self.old_inputs = None - self.old_selection_start = None - - def redo(self, inputs, cursor): - if self.old_inputs is None or self.old_selection_start is None: - assert self.old_inputs is None and self.old_selection_start is None - self.old_inputs = inputs.read( - (0, self.start, INTENT_COUNT - 1, self.start + self.count - 1) - ) - self.old_selection_start = cursor.selection_start - - inputs.delete_frames(self.start, self.count) - cursor.set(*self.old_selection_start) - - def undo(self, inputs, cursor): - inputs.insert_frames(self.start, self.count) - inputs.write((0, self.start), self.old_inputs) - cursor.select((0, self.start, INTENT_COUNT - 1, self.start + self.count - 1)) diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index f703b3a..ea68f8a 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -3,15 +3,6 @@ import tkinter as tk from typing import Any -from dusted.commands import ( - ClearInputsCommand, - Command, - CommandSequence, - DeleteFramesCommand, - FillInputsCommand, - InsertFramesCommand, - SetInputsCommand, -) from dusted.cursor import Cursor from dusted.dialog import SimpleDialog from dusted.inputs import DEFAULT_INPUTS, INTENT_COUNT, Inputs @@ -272,32 +263,27 @@ def paste(self) -> None: 0, self._cursor.selection_left + len(block[0]) - self._inputs.length ) - self._undo_stack.execute( - CommandSequence( - "Paste inputs", - InsertFramesCommand(self._inputs.length, extra_frames), - SetInputsCommand(self._cursor.selection_start, block), - ) - ) + with self._undo_stack.execute("Paste inputs"): + self._inputs.insert_frames(self._inputs.length, extra_frames) + self._inputs.write(self._cursor.selection_start, block) def clear_selection(self) -> None: - self._undo_stack.execute(ClearInputsCommand(self._cursor.selection)) + with self._undo_stack.execute("Clear selection"): + self._inputs.clear(self._cursor.selection) def insert_frames(self, count: int) -> None: - self._undo_stack.execute( - InsertFramesCommand(self._cursor.selection_left, count) - ) + with self._undo_stack.execute(f"Insert {count} frame(s)"): + self._inputs.insert_frames(self._cursor.selection_left, count) def delete_frames(self) -> None: # Protect against deleting the "frame-after-last" - width = self._cursor.selection_width + count = self._cursor.selection_width if self._cursor.selection_right == len(self._inputs): - width -= 1 - if width == 0: + count -= 1 + if count == 0: return - self._undo_stack.execute( - DeleteFramesCommand(self._cursor.selection_left, width) - ) + with self._undo_stack.execute(f"Delete {count} frame(s)"): + self._inputs.delete_frames(self._cursor.selection_left, count) def _on_click(self, event: tk.Event, keep_selection: bool = False) -> None: self.focus_set() @@ -358,22 +344,12 @@ def _on_key(self, event: tk.Event) -> None: if not event.char or modifier_held(event.state): return - command: Command - if fill := self._cursor.has_selection: - command = FillInputsCommand(self._cursor.selection, event.char.lower()) - else: - command = SetInputsCommand(self._cursor.position, [[event.char.lower()]]) - - if self._cursor.selection_right == len(self._inputs): - self._undo_stack.execute( - CommandSequence( - "Fill selection" if fill else "Set inputs", - InsertFramesCommand(self._cursor.current_col, 1), - command, - ) - ) - else: - self._undo_stack.execute(command) + with self._undo_stack.execute( + "Fill selection" if self._cursor.has_selection else "Set inputs" + ): + if self._cursor.selection_right == len(self._inputs): + self._inputs.insert_frames(self._cursor.current_col, 1) + self._inputs.fill(self._cursor.selection, event.char.lower()) def _on_scroll(self, command, *args): if command == tk.MOVETO: diff --git a/src/dusted/undo_stack.py b/src/dusted/undo_stack.py index 53af47a..6eb81c5 100644 --- a/src/dusted/undo_stack.py +++ b/src/dusted/undo_stack.py @@ -1,76 +1,128 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass + from dusted.broadcaster import Broadcaster +from dusted.cursor import Cursor +from dusted.inputs import Inputs + + +@dataclass(frozen=True, slots=True) +class Action: + """ + An action that can be undone and redone. + + :param name: The name of the action + :param before: The state of the world before the action was performed + :param after: The state of the world after the action was performed + """ + + name: str + before: Snapshot + after: Snapshot + + +@dataclass(frozen=True, slots=True) +class Snapshot: + """A snapshot of the application state.""" + + inputs: tuple[tuple[str, ...], ...] + cursor: tuple[int, int, int, int] class UndoStack(Broadcaster): - def __init__(self, inputs, cursor): + def __init__(self, inputs: Inputs, cursor: Cursor) -> None: super().__init__() - self.inputs = inputs - self.cursor = cursor - self.stack = [] - self.index = 0 - self.unmodified_index = -1 + self._inputs = inputs + self._cursor = cursor - def clear(self): - self.stack = [] - self.index = 0 - self.unmodified_index = -1 + self._stack: list[Action] = [] + self._index = 0 + self._unmodified_index = -1 - self.broadcast() + def clear(self) -> None: + self._stack = [] + self._index = 0 + self._unmodified_index = -1 - def execute(self, command): - del self.stack[self.index :] - if self.unmodified_index > self.index: - self.unmodified_index = -1 + self.broadcast() - command.redo(self.inputs, self.cursor) - self.stack.append(command) - self.index += 1 + def _snapshot(self) -> Snapshot: + return Snapshot( + inputs=tuple(tuple(row) for row in self._inputs.get()), + cursor=self._cursor.selection, + ) + + @contextmanager + def execute(self, name: str) -> Generator[None, None, None]: + before = self._snapshot() + yield + after = self._snapshot() + + del self._stack[self._index :] + if self._unmodified_index > self._index: + self._unmodified_index = -1 + + self._stack.append( + Action( + name=name, + before=before, + after=after, + ) + ) + self._index += 1 self.broadcast() @property - def can_undo(self): - return self.index > 0 + def can_undo(self) -> bool: + return self._index > 0 @property - def can_redo(self): - return self.index < len(self.stack) + def can_redo(self) -> bool: + return self._index < len(self._stack) @property - def is_modified(self): - return self.index != self.unmodified_index + def is_modified(self) -> bool: + return self._index != self._unmodified_index - def set_unmodified(self): - self.unmodified_index = self.index + def set_unmodified(self) -> None: + self._unmodified_index = self._index self.broadcast() - def undo_text(self): + def undo_text(self) -> str: if not self.can_undo: return "" - return self.stack[self.index - 1].name + return self._stack[self._index - 1].name - def redo_text(self): + def redo_text(self) -> str: if not self.can_redo: return "" - return self.stack[self.index].name + return self._stack[self._index].name - def undo(self): + def undo(self) -> None: if not self.can_undo: return - self.index -= 1 - command = self.stack[self.index] - command.undo(self.inputs, self.cursor) + self._index -= 1 + + snapshot = self._stack[self._index].before + self._inputs.set(snapshot.inputs) + self._cursor.select(snapshot.cursor) self.broadcast() - def redo(self): + def redo(self) -> None: if not self.can_redo: return - command = self.stack[self.index] - self.index += 1 - command.redo(self.inputs, self.cursor) + snapshot = self._stack[self._index].after + self._inputs.set(snapshot.inputs) + self._cursor.select(snapshot.cursor) + + self._index += 1 self.broadcast()