From 3b12096930affb8f7d21f1f12c2030c359e4844e Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Sun, 14 Dec 2025 12:46:05 +0000 Subject: [PATCH 1/3] Keep cursor still when scrolling off-screen with keyboard Now using PageUp and PageDown scroll exactly one page, and will keep the cursor in the same page on the screen, instead of centering it. In the same vein, when using the arrow keys to move the cursor off-screen, the view will now only be scrolled by a single cell instead of jumping to move the cursor to the center. --- src/dusted/broadcaster.py | 38 ++++++++++++++++++++++++++------ src/dusted/inputs_view.py | 46 +++++++++++++++++++++++++++++---------- 2 files changed, 65 insertions(+), 19 deletions(-) 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..acfe5e4 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -128,12 +128,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 +144,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( "", @@ -315,6 +317,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): From cd2f6d582596afc45b1d10235fd37f74948dc1fa Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Sun, 14 Dec 2025 14:05:57 +0000 Subject: [PATCH 2/3] Prevent dragging off-screen causing wild jumps --- src/dusted/inputs_view.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index acfe5e4..1042b58 100644 --- a/src/dusted/inputs_view.py +++ b/src/dusted/inputs_view.py @@ -267,16 +267,25 @@ 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) 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_right_click(self, event): self.context_menu.tk_popup(event.x_root, event.y_root) From ba442ec867df66707f5a4d9530c127aacbf5be15 Mon Sep 17 00:00:00 2001 From: Alex Morson Date: Sun, 14 Dec 2025 14:31:10 +0000 Subject: [PATCH 3/3] Scroll smoothly when dragging outside the grid --- src/dusted/inputs_view.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/dusted/inputs_view.py b/src/dusted/inputs_view.py index 1042b58..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)) @@ -277,6 +280,8 @@ def on_click(self, event, keep_selection=False): 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): raw_col = (event.x_root - self.winfo_rootx()) // GRID_SIZE raw_row = (event.y_root - self.winfo_rooty()) // GRID_SIZE - 2 @@ -287,6 +292,27 @@ def on_drag(self, event): 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)