diff --git a/src/dusted/broadcaster.py b/src/dusted/broadcaster.py index 6237baa..0e51ab0 100644 --- a/src/dusted/broadcaster.py +++ b/src/dusted/broadcaster.py @@ -1,10 +1,34 @@ +from __future__ import annotations + +from collections.abc import Callable, Generator +from contextlib import contextmanager + + class Broadcaster: - def __init__(self): - self.callbacks = [] + def __init__(self) -> None: + self._callbacks: list[Callable[[], None]] = [] + + self._batching = False + self._broadcast_scheduled = False + + @contextmanager + def batch(self) -> Generator[None, None, None]: + """Batch any events until the context manager has closed.""" + try: + self._batching = True + yield + finally: + self._batching = False + if self._broadcast_scheduled: + self.broadcast() - def subscribe(self, callback): - self.callbacks.append(callback) + def subscribe(self, callback: Callable[[], None]) -> None: + self._callbacks.append(callback) - def broadcast(self): - for callback in self.callbacks: - callback() + def broadcast(self) -> None: + if self._batching: + self._broadcast_scheduled = True + else: + self._broadcast_scheduled = False + for callback in self._callbacks: + callback() diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index 45d4ed1..e6c72b3 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -92,6 +92,8 @@ def __init__(self, parent, scrollbar, inputs, cursor, undo_stack): 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.cursor.subscribe(self.on_cursor_move) @@ -110,6 +112,7 @@ def __init__(self, parent, scrollbar, inputs, cursor, undo_stack): 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)) @@ -128,12 +131,14 @@ def __init__(self, parent, scrollbar, inputs, cursor, undo_stack): self.bind("", lambda e: self.clear_selection()) self.bind("", lambda e: self.clear_selection()) - self.bind("", lambda e: self.cursor.move(0, -1)) - self.bind("", lambda e: self.cursor.move(0, 1)) - self.bind("", lambda e: self.cursor.move(-1, 0)) - self.bind("", lambda e: self.cursor.move(1, 0)) - self.bind("", lambda e: self.cursor.move(0, -self.cell_width)) - self.bind("", lambda e: self.cursor.move(0, self.cell_width)) + 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, self.cell_width - 1)) self.bind( "", lambda e: self.cursor.set(self.cursor.position[0], 0) ) @@ -142,17 +147,17 @@ def __init__(self, parent, scrollbar, inputs, cursor, undo_stack): lambda e: self.cursor.set(self.cursor.position[0], len(self.inputs) - 1), ) - self.bind("", lambda e: self.cursor.move(0, -1, True)) - self.bind("", lambda e: self.cursor.move(0, 1, True)) - self.bind("", lambda e: self.cursor.move(-1, 0, True)) - self.bind("", lambda e: self.cursor.move(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.cursor.move(0, -self.cell_width, True), + lambda e: self.move_cursor(0, 1 - self.cell_width, True), ) self.bind( "", - lambda e: self.cursor.move(0, self.cell_width, True), + lambda e: self.move_cursor(0, self.cell_width - 1, True), ) self.bind( "", @@ -265,16 +270,48 @@ def delete_frames(self): def on_click(self, event, keep_selection=False): self.focus_set() - col = (event.x_root - self.winfo_rootx()) // GRID_SIZE - row = (event.y_root - self.winfo_rooty()) // GRID_SIZE - 2 - if 0 <= row < INTENT_COUNT and 0 <= col: - self.cursor.set(row, col + self.current_col, keep_selection) + + 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)) + row = max(0, min(INTENT_COUNT - 1, raw_row)) + + self.cursor.set(row, col + self.current_col, keep_selection) + + self.drag_timer = self.after_idle(self.on_drag_tick) def on_drag(self, event): - col = (event.x_root - self.winfo_rootx()) // GRID_SIZE - row = (event.y_root - self.winfo_rooty()) // GRID_SIZE - 2 - if 0 <= row < INTENT_COUNT and 0 <= col: - self.cursor.set(row, col + self.current_col, True) + 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)) + row = max(0, min(INTENT_COUNT - 1, raw_row)) + + self.cursor.set(row, col + self.current_col, True) + + 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 + + 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) + + 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): self.context_menu.tk_popup(event.x_root, event.y_root) @@ -315,6 +352,26 @@ def on_scroll(self, command, *args): self.current_col = max(0, min(len(self.inputs), self.current_col)) self.redraw() + def move_cursor( + self, + row_offset: int, + col_offset: int, + keep_selection: bool = False, + ) -> None: + """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) + + # 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): + # Scroll the view by the same amount. + 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):