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 a1b95b5..ea68f8a 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -1,15 +1,11 @@ +from __future__ import annotations + import tkinter as tk +from typing import Any -from dusted.commands import ( - ClearInputsCommand, - 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 +16,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 +93,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) + ) self.bind( - "", lambda e: self.move_cursor(0, 1 - self.cell_width) + "", lambda e: self._move_cursor(0, self._cell_width - 1) ) - 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._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 +207,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 +216,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 +255,118 @@ 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( - 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): - self.undo_stack.execute(ClearInputsCommand(self.cursor.selection)) + def clear_selection(self) -> None: + with self._undo_stack.execute("Clear selection"): + self._inputs.clear(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: + with self._undo_stack.execute(f"Insert {count} frame(s)"): + self._inputs.insert_frames(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 -= 1 - if width == 0: + count = self._cursor.selection_width + if self._cursor.selection_right == len(self._inputs): + 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, 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()) - 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): + 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 +375,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 +449,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 +462,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 +506,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") 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()