From 49f09b455c25661468bc75857642e64973dd706a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 14:43:35 +0200 Subject: [PATCH 01/28] Added: shallow frame duplication beside deep cloning --- .../categories/elements/sequencer.py | 1 + .../categories/elements/settings.py | 1 + .../coordinators/tabs/sequencer.py | 29 ++++-- .../logic/history/action.py | 1 + .../logic/project/controller.py | 9 +- .../logic/sequencer/history_detail.py | 3 +- .../logic/sequencer/order.py | 6 +- .../ui/panels/sequencer/order.py | 9 ++ .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 3 + .../project/patterns/channel.py | 2 +- src/sampletones_core/project/song.py | 23 +++-- .../logic/project/test_controller.py | 4 +- .../logic/sequencer/test_history_detail.py | 5 +- .../logic/sequencer/test_order.py | 14 ++- .../ui/panels/sequencer/test_order_keys.py | 9 ++ .../utils/gui/shortcuts/test_shipped.py | 10 ++ .../project/patterns/test_channel.py | 8 +- .../sampletones_core/project/test_song.py | 91 ++++++++++++++++--- 21 files changed, 189 insertions(+), 42 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 6660cec9..51db2e5d 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -63,6 +63,7 @@ class SequencerOrderElements(AbstractElement): LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" CONTEXT_DUPLICATE = "context_duplicate" + CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" CONTEXT_CLEAR = "context_clear" CONTEXT_REMOVE = "context_remove" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 6c9943c0..f9da5d03 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -97,6 +97,7 @@ class KeybindingActionElements(AbstractElement): ORDER_INSERT_FRAME = "order_insert_frame" ORDER_REMOVE_FRAME = "order_remove_frame" ORDER_DUPLICATE_FRAME = "order_duplicate_frame" + ORDER_CLONE_FRAME = "order_clone_frame" ORDER_CLEAR_FRAME = "order_clear_frame" ORDER_CLEAR_CELL = "order_clear_cell" ORDER_CLEAR_PREVIOUS_CELL = "order_clear_previous_cell" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index cfe0bb40..ea6fac18 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -401,7 +401,12 @@ def _wire_order_callbacks(self) -> None: self._sequencer_order_panel.on_duplicate_requested = self._undoable( HistoryAction.DUPLICATE_FRAME, self._on_order_duplicate, - detail=self._history_detail.duplicate_frame, + detail=self._history_detail.copy_frame, + ) + self._sequencer_order_panel.on_clone_requested = self._undoable( + HistoryAction.CLONE_FRAME, + self._on_order_clone, + detail=self._history_detail.copy_frame, ) self._sequencer_order_panel.on_insert_requested = self._undoable( HistoryAction.ADD_FRAME, @@ -1264,13 +1269,21 @@ def _on_order_remove(self, position: int) -> None: def _on_order_duplicate(self, position: int) -> None: self._sequencer_order_logic.duplicate_frame(position) - self._relocate_playhead( - lambda playhead: remap_after_insert( - playhead, - position + 1, - ) - ) - self._select_frame_when_idle(position + 1) + self._settle_inserted_frame(position + 1) + + def _on_order_clone(self, position: int) -> None: + self._sequencer_order_logic.clone_frame(position) + self._settle_inserted_frame(position + 1) + + def _settle_inserted_frame(self, position: int) -> None: + """Carries the playhead and the shown frame over a frame that has just been inserted. + + A frame arriving at ``position`` pushes every later frame one along, so a playhead + standing on one of them follows it, and the grid moves to the new frame for the reader + to work on. + """ + self._relocate_playhead(lambda playhead: remap_after_insert(playhead, position)) + self._select_frame_when_idle(position) def _on_order_insert(self, position: int) -> None: self._sequencer_order_logic.insert_frame(position + 1) diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index ce0f6ce5..9a40d523 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -18,6 +18,7 @@ class HistoryAction(AbstractElement): ADD_FRAME = "add_frame" REMOVE_FRAME = "remove_frame" DUPLICATE_FRAME = "duplicate_frame" + CLONE_FRAME = "clone_frame" CLEAR_FRAME = "clear_frame" MOVE_FRAME = "move_frame" SET_ORDER_ENTRY = "set_order_entry" diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 1c5d1352..5e80a04e 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -235,12 +235,12 @@ def add_pattern(self, generator: GeneratorName) -> int: self.call(self.on_song_changed) return index - def duplicate_pattern( + def clone_pattern( self, generator: GeneratorName, pattern_index: int, ) -> int: - clone_index = self.song.duplicate_pattern(generator, pattern_index) + clone_index = self.song.clone_pattern(generator, pattern_index) self._touch() self.call(self.on_song_changed) return clone_index @@ -404,6 +404,11 @@ def duplicate_frame(self, position: int) -> None: self._touch() self.call(self.on_song_changed) + def clone_frame(self, position: int) -> None: + self.song.clone_frame(position) + self._touch() + self.call(self.on_song_changed) + def clear_frame(self, position: int) -> None: self.song.clear_frame(position) self._touch() diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index b6babae7..354e92a3 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -163,7 +163,8 @@ def remove_frame(self, position: int) -> Segments: def clear_frame(self, position: int) -> Segments: return (self._frame(position),) - def duplicate_frame(self, position: int) -> Segments: + def copy_frame(self, position: int) -> Segments: + """Reads as source frame to copy, which is what both duplicating and cloning produce.""" return (self._frame(position), self._arrow(), self._frame(position + 1)) def move_frame(self, from_position: int, to_position: int) -> Segments: diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order.py index 2275ea49..0d7e3fb9 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/order.py @@ -58,9 +58,13 @@ def insert_frame(self, position: int) -> None: self._controller.insert_frame(position) def duplicate_frame(self, position: int) -> None: - """Inserts a copy of the frame at ``position`` directly after it.""" + """Repeats the frame at ``position`` directly after it, playing the same patterns.""" self._controller.duplicate_frame(position) + def clone_frame(self, position: int) -> None: + """Inserts a copy of the frame at ``position`` directly after it, with its own patterns.""" + self._controller.clone_frame(position) + def clear_frame(self, position: int) -> None: """Empties every channel in the frame at ``position``.""" self._controller.clear_frame(position) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index ecf4748a..32ae8557 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -149,6 +149,7 @@ def __init__( self.on_frame_selected: Optional[OnFrameSelectedCallback] = None self.on_remove_requested: Optional[OnRemoveCallback] = None self.on_duplicate_requested: Optional[OnFrameActionCallback] = None + self.on_clone_requested: Optional[OnFrameActionCallback] = None self.on_insert_requested: Optional[OnFrameActionCallback] = None self.on_clear_requested: Optional[OnFrameActionCallback] = None self.on_play_from_requested: Optional[OnFrameActionCallback] = None @@ -197,6 +198,7 @@ def label(element: SequencerOrderElements) -> str: self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) + self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) self._lbl_context_clear = label(SequencerOrderElements.CONTEXT_CLEAR) self._lbl_context_remove = label(SequencerOrderElements.CONTEXT_REMOVE) @@ -882,6 +884,11 @@ def _show_context_menu(self, position: int) -> None: shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), callback=lambda: self.call(self.on_duplicate_requested, position), ) + dpg.add_menu_item( + label=self._lbl_context_clone, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), + callback=lambda: self.call(self.on_clone_requested, position), + ) dpg.add_menu_item( label=self._lbl_context_insert, shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), @@ -1039,6 +1046,8 @@ def _act_on_frame(self, shortcut_id: ShortcutId, position: int) -> bool: self._on_remove_clicked() case ShortcutId.ORDER_DUPLICATE_FRAME: self.call(self.on_duplicate_requested, position) + case ShortcutId.ORDER_CLONE_FRAME: + self.call(self.on_clone_requested, position) case ShortcutId.ORDER_CLEAR_FRAME: self.call(self.on_clear_requested, position) case _: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index f8298e9a..f830b80b 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -103,6 +103,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ORDER_INSERT_FRAME = ("OrderInsertFrame", ShortcutCategory.ORDER) ORDER_REMOVE_FRAME = ("OrderRemoveFrame", ShortcutCategory.ORDER) ORDER_DUPLICATE_FRAME = ("OrderDuplicateFrame", ShortcutCategory.ORDER) + ORDER_CLONE_FRAME = ("OrderCloneFrame", ShortcutCategory.ORDER) ORDER_CLEAR_FRAME = ("OrderClearFrame", ShortcutCategory.ORDER) ORDER_CLEAR_CELL = ("OrderClearCell", ShortcutCategory.ORDER) ORDER_CLEAR_PREVIOUS_CELL = ("OrderClearPreviousCell", ShortcutCategory.ORDER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 79a9d406..02946c4f 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -74,6 +74,7 @@ bindings: OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]} OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]} OrderDuplicateFrame: {combination: "Ctrl+Ins"} + OrderCloneFrame: {combination: "Ctrl+Shift+Ins"} OrderClearFrame: {combination: "Shift+Del"} OrderClearCell: {combination: "Del"} OrderClearPreviousCell: {combination: "Backspace"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 09648678..c0e3e2f0 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -74,6 +74,7 @@ bindings: OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]} OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]} OrderDuplicateFrame: {combination: "Ctrl+Ins", aliases: ["Cmd+Alt+Enter"]} + OrderCloneFrame: {combination: "Ctrl+Shift+Ins", aliases: ["Cmd+Alt+Shift+Enter"]} OrderClearFrame: {combination: "Shift+Del", aliases: ["Cmd+Shift+Backspace"]} OrderClearCell: {combination: "Del", aliases: ["Cmd+Backspace"]} OrderClearPreviousCell: {combination: "Backspace"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 88cd8bc9..ca4faa1b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -484,6 +484,7 @@ sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" sequencer.order.label.context_duplicate: "Duplicate" +sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" sequencer.order.label.context_clear: "Clear frame" sequencer.order.label.context_remove: "Remove" @@ -532,6 +533,7 @@ sequencer.history.label.adjust_volume: "Adjust volume" sequencer.history.label.add_frame: "Add frame" sequencer.history.label.remove_frame: "Remove frame" sequencer.history.label.duplicate_frame: "Duplicate frame" +sequencer.history.label.clone_frame: "Clone frame" sequencer.history.label.clear_frame: "Clear frame" sequencer.history.label.move_frame: "Move frame" sequencer.history.label.set_order_entry: "Set order entry" @@ -778,6 +780,7 @@ settings.keybindings.label.order_add_frame: "Add frame" settings.keybindings.label.order_insert_frame: "Insert frame" settings.keybindings.label.order_remove_frame: "Remove frame" settings.keybindings.label.order_duplicate_frame: "Duplicate frame" +settings.keybindings.label.order_clone_frame: "Clone frame" settings.keybindings.label.order_clear_frame: "Clear frame" settings.keybindings.label.order_clear_cell: "Clear cell" settings.keybindings.label.order_clear_previous_cell: "Clear the previous cell" diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py index b1bc65a9..eb4f81df 100644 --- a/src/sampletones_core/project/patterns/channel.py +++ b/src/sampletones_core/project/patterns/channel.py @@ -62,7 +62,7 @@ def ensure_pattern(self, index: int, length: int) -> Pattern: return self.patterns[index] - def duplicate_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int: + def clone_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int: """Clones the pattern at ``index`` into a fresh index and returns that index. ``reserved_indices`` are extra indices the clone must avoid beyond the pool's diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py index e1bccc2e..f779f8d0 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -91,18 +91,29 @@ def add_pattern(self, generator: GeneratorName) -> int: reserved_indices=self._referenced_indices(generator), ) - def duplicate_pattern(self, generator: GeneratorName, index: int) -> int: + def clone_pattern(self, generator: GeneratorName, index: int) -> int: """Clones ``generator``'s pattern at ``index`` into a free index and returns it. The clone index clears the channel's pool and every order-referenced index, so the copy stays independent of any slot the order already plays. """ - return self.channels[generator].duplicate_pattern( + return self.channels[generator].clone_pattern( index, reserved_indices=self._referenced_indices(generator), ) def duplicate_frame(self, position: int) -> None: + """Inserts a frame playing the same patterns directly after ``position``. + + The pattern indices are copied as they stand, so both frames play one shared + pattern per channel and an edit to either is heard in both. The copy is a fresh + mapping, so assigning a channel a different pattern in one frame leaves the + other frame where it was. Silent slots stay silent, and an index whose pattern + is not yet materialised is carried across as the reference it is. + """ + self.order.insert(position + 1, dict(self.order[position])) + + def clone_frame(self, position: int) -> None: """Inserts an independent copy of the frame directly after ``position``. Each channel's referenced pattern is cloned into a fresh index within that @@ -110,17 +121,17 @@ def duplicate_frame(self, position: int) -> None: the other unchanged. Silent slots stay silent. """ source_frame = self.order[position] - duplicate: Dict[GeneratorName, Optional[int]] = {} + clone: Dict[GeneratorName, Optional[int]] = {} for generator in GeneratorName.items(): index = source_frame.get(generator) if index is None: - duplicate[generator] = None + clone[generator] = None continue self.channels[generator].ensure_pattern(index, self.rows_per_pattern) - duplicate[generator] = self.duplicate_pattern(generator, index) + clone[generator] = self.clone_pattern(generator, index) - self.order.insert(position + 1, duplicate) + self.order.insert(position + 1, clone) def _referenced_indices(self, generator: GeneratorName) -> Set[int]: return {index for frame in self.order if (index := frame.get(generator)) is not None} diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 86efdd50..4f0c996a 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -519,10 +519,10 @@ def test_add_pattern_returns_int_index(self) -> None: index = controller.add_pattern(GeneratorName.PULSE1) assert isinstance(index, int) - def test_duplicate_pattern_creates_independent_copy(self) -> None: + def test_clone_pattern_creates_independent_copy(self) -> None: controller = _controller() original_index = controller.add_pattern(GeneratorName.TRIANGLE) - clone_index = controller.duplicate_pattern( + clone_index = controller.clone_pattern( GeneratorName.TRIANGLE, original_index, ) diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index b236a09a..f1395a35 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -157,10 +157,11 @@ def test_add_frame_reports_the_landing_index(self) -> None: assert _pairs(formatter.add_frame(2)) == [("03", HistoryDetailRole.FRAME)] - def test_duplicate_frame_points_source_to_the_copy(self) -> None: + def test_copy_frame_points_source_to_the_copy(self) -> None: + """One builder serves both duplicating and cloning, since each lands a copy after its source.""" formatter = _formatter(_controller()) - assert _pairs(formatter.duplicate_frame(2)) == [ + assert _pairs(formatter.copy_frame(2)) == [ ("02", HistoryDetailRole.FRAME), (">", HistoryDetailRole.SEPARATOR), ("03", HistoryDetailRole.FRAME), diff --git a/tests/unit/sampletones_application/logic/sequencer/test_order.py b/tests/unit/sampletones_application/logic/sequencer/test_order.py index 95d72b01..7a12b468 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_order.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_order.py @@ -66,15 +66,23 @@ def test_insert_frame_adds_empty_frame_at_position(self) -> None: assert _order_column(logic, GeneratorName.PULSE1) == [None, 5] - def test_duplicate_frame_gives_the_copy_its_own_pattern(self) -> None: + def test_duplicate_frame_repeats_the_same_pattern(self) -> None: logic = _logic() logic.set_order_entry(GeneratorName.PULSE1, 0, 5) logic.duplicate_frame(0) - source_index, duplicate_index = _order_column(logic, GeneratorName.PULSE1) + assert _order_column(logic, GeneratorName.PULSE1) == [5, 5] + + def test_clone_frame_gives_the_copy_its_own_pattern(self) -> None: + logic = _logic() + logic.set_order_entry(GeneratorName.PULSE1, 0, 5) + + logic.clone_frame(0) + + source_index, clone_index = _order_column(logic, GeneratorName.PULSE1) assert source_index == 5 - assert duplicate_index != 5 + assert clone_index != 5 def test_clear_frame_empties_every_channel(self) -> None: logic = _logic() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 3f9d476c..1b6c90bd 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -26,6 +26,7 @@ class OrderPanelFixture: panel: GUISequencerOrderPanel inserted: List[int] = field(default_factory=list) duplicated: List[int] = field(default_factory=list) + cloned: List[int] = field(default_factory=list) cleared: List[int] = field(default_factory=list) removed: List[int] = field(default_factory=list) moved: List[Move] = field(default_factory=list) @@ -45,6 +46,7 @@ def order(monkeypatch: pytest.MonkeyPatch) -> OrderPanelFixture: fixture = OrderPanelFixture(panel=panel) panel.on_insert_requested = fixture.inserted.append panel.on_duplicate_requested = fixture.duplicated.append + panel.on_clone_requested = fixture.cloned.append panel.on_clear_requested = fixture.cleared.append panel.on_remove_requested = fixture.removed.append panel.on_move_requested = lambda position, target: fixture.moved.append((position, target)) @@ -63,6 +65,13 @@ class TestFrameActions: def test_the_duplicate_key_duplicates_the_cursor_frame(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Ctrl+Ins")) is True assert order.duplicated == [CURSOR_POSITION] + assert order.cloned == [] + + def test_the_clone_key_clones_the_cursor_frame(self, order: OrderPanelFixture) -> None: + """Shift separates the two copies: the plain key repeats, the shifted one clones.""" + assert order.panel._on_key_pressed(_press("Ctrl+Shift+Ins")) is True + assert order.cloned == [CURSOR_POSITION] + assert order.duplicated == [] def test_the_display_settings_key_reaches_the_application(self, order: OrderPanelFixture) -> None: """Ctrl+D belongs to the display settings now, so the table hands it to the shortcut scope.""" diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index 71493710..be485188 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -15,6 +15,7 @@ DISPLAY_SETTINGS_COMBINATION = "Ctrl+D" DUPLICATE_FRAME_COMBINATION = "Ctrl+Ins" +CLONE_FRAME_COMBINATION = "Ctrl+Shift+Ins" def _press(text: str) -> KeyEvent: @@ -57,6 +58,15 @@ def test_duplicate_frame_answers_its_press_in_the_order_table(self, shipped: Sho assert action is ShortcutId.ORDER_DUPLICATE_FRAME + def test_clone_frame_reads_under_the_combination_it_answers(self, shipped: ShortcutScheme) -> None: + assert shipped.shortcut(ShortcutId.ORDER_CLONE_FRAME).display() == CLONE_FRAME_COMBINATION + + def test_clone_frame_answers_its_press_in_the_order_table(self, shipped: ShortcutScheme) -> None: + """Shift is what separates the deep copy from the repeat, so the two keys stay adjacent.""" + action = shipped.action(ShortcutCategory.ORDER, _press(CLONE_FRAME_COMBINATION)) + + assert action is ShortcutId.ORDER_CLONE_FRAME + def test_adding_a_frame_keeps_the_unmodified_insert(self, shipped: ShortcutScheme) -> None: assert shipped.action(ShortcutCategory.ORDER, _press("Ins")) is ShortcutId.ORDER_ADD_FRAME diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py index 9d2998fe..bf55412c 100644 --- a/tests/unit/sampletones_core/project/patterns/test_channel.py +++ b/tests/unit/sampletones_core/project/patterns/test_channel.py @@ -17,7 +17,7 @@ def test_add_pattern_appends_with_requested_length(self) -> None: assert index in channel.patterns assert channel.pattern(index).length == 8 - def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None: + def test_clone_pattern_copies_rows_with_new_identity(self) -> None: channel = _channel() source = channel.patterns[0] source.rows[0] = Row( @@ -25,17 +25,17 @@ def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None: volume=10, ) - clone_index = channel.duplicate_pattern(0) + clone_index = channel.clone_pattern(0) clone = channel.pattern(clone_index) assert clone_index != 0 assert clone is not source assert clone.rows[0] == source.rows[0] - def test_duplicate_pattern_avoids_reserved_indices(self) -> None: + def test_clone_pattern_avoids_reserved_indices(self) -> None: channel = _channel() - clone_index = channel.duplicate_pattern(0, reserved_indices={1, 2}) + clone_index = channel.clone_pattern(0, reserved_indices={1, 2}) assert clone_index == 3 diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index 4313d67c..c6a38264 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -168,38 +168,105 @@ def test_duplicate_inserts_frame_after_position(self) -> None: assert song.order_length() == 3 assert song.order[2][GeneratorName.PULSE1] == 3 - def test_duplicate_points_channels_at_fresh_patterns(self) -> None: + def test_duplicate_points_channels_at_the_same_patterns(self) -> None: song = _song() song.duplicate_frame(0) source_index = song.order[0][GeneratorName.PULSE1] duplicate_index = song.order[1][GeneratorName.PULSE1] - assert duplicate_index != source_index + assert duplicate_index == source_index - def test_duplicate_avoids_indices_referenced_by_other_frames(self) -> None: + def test_duplicate_allocates_no_pattern(self) -> None: song = _song() - song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 7) + pattern_count = len(song[GeneratorName.PULSE1].patterns) song.duplicate_frame(0) + assert len(song[GeneratorName.PULSE1].patterns) == pattern_count + + def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None: + song = _song() + song.duplicate_frame(0) duplicate_index = song.order[1][GeneratorName.PULSE1] - assert duplicate_index != 7 + assert duplicate_index is not None + + _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) + + shared_pattern = song.pattern(GeneratorName.PULSE1, duplicate_index) + assert shared_pattern is not None + assert shared_pattern.rows[0].command is not None - def test_editing_duplicated_pattern_leaves_the_source_untouched(self) -> None: + def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None: + """The copy is a fresh mapping, so the two frames' slots move independently.""" + song = _song() + song.duplicate_frame(0) + + song.set_order_entry(1, GeneratorName.PULSE1, 9) + + assert song.order[0][GeneratorName.PULSE1] == 0 + + def test_duplicate_carries_an_unmaterialised_index_across(self) -> None: + song = _song() + song.set_order_entry(0, GeneratorName.PULSE1, 7) + + song.duplicate_frame(0) + + assert song.order[1][GeneratorName.PULSE1] == 7 + assert song.pattern(GeneratorName.PULSE1, 7) is None + + +class TestSongCloneFrame: + def test_clone_inserts_frame_after_position(self) -> None: + song = _song() + song.append_frame() + song.set_order_entry(1, GeneratorName.PULSE1, 3) + + song.clone_frame(0) + + assert song.order_length() == 3 + assert song.order[2][GeneratorName.PULSE1] == 3 + + def test_clone_points_channels_at_fresh_patterns(self) -> None: + song = _song() + + song.clone_frame(0) + + source_index = song.order[0][GeneratorName.PULSE1] + clone_index = song.order[1][GeneratorName.PULSE1] + assert clone_index != source_index + + def test_clone_avoids_indices_referenced_by_other_frames(self) -> None: + song = _song() + song.append_frame() + song.set_order_entry(1, GeneratorName.PULSE1, 7) + + song.clone_frame(0) + + clone_index = song.order[1][GeneratorName.PULSE1] + assert clone_index != 7 + + def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None: song = _song() _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) source_index = song.order[0][GeneratorName.PULSE1] - song.duplicate_frame(0) - duplicate_index = song.order[1][GeneratorName.PULSE1] - song[GeneratorName.PULSE1].set_row(duplicate_index, 0, Row()) + song.clone_frame(0) + clone_index = song.order[1][GeneratorName.PULSE1] + song[GeneratorName.PULSE1].set_row(clone_index, 0, Row()) source_pattern = song.pattern(GeneratorName.PULSE1, source_index) assert source_pattern is not None assert source_pattern.rows[0].command is not None + def test_clone_keeps_a_silent_slot_silent(self) -> None: + song = _song() + song.set_order_entry(0, GeneratorName.NOISE, None) + + song.clone_frame(0) + + assert song.order[1][GeneratorName.NOISE] is None + class TestSongPatternAllocation: def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None: @@ -211,12 +278,12 @@ def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None: assert index != 4 assert index in song[GeneratorName.PULSE1].patterns - def test_duplicate_pattern_skips_indices_referenced_by_the_order(self) -> None: + def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None: song = _song() song.append_frame() song.set_order_entry(1, GeneratorName.PULSE1, 6) - clone_index = song.duplicate_pattern(GeneratorName.PULSE1, 0) + clone_index = song.clone_pattern(GeneratorName.PULSE1, 0) assert clone_index != 6 From 5403e59f8aa043aad1380ac8134ea96576dc97a9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 15:03:19 +0200 Subject: [PATCH 02/28] Extracted: shared sequencer channel axis and slot vocabulary --- .../constants/sequencer.py | 5 ++ .../ui/panels/sequencer/columns.py | 21 +------ .../ui/panels/sequencer/input/order.py | 6 +- .../ui/panels/sequencer/input/state.py | 34 +++++++---- .../ui/panels/sequencer/order.py | 6 +- .../view_model/sequencer/slot.py | 58 ++++++++++++++++++ .../sequencer/input/test_order_input.py | 10 ++-- .../view_model/sequencer/test_slot.py | 59 +++++++++++++++++++ 8 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 src/sampletones_application/constants/sequencer.py create mode 100644 src/sampletones_application/view_model/sequencer/slot.py create mode 100644 tests/unit/sampletones_application/view_model/sequencer/test_slot.py diff --git a/src/sampletones_application/constants/sequencer.py b/src/sampletones_application/constants/sequencer.py new file mode 100644 index 00000000..0415da07 --- /dev/null +++ b/src/sampletones_application/constants/sequencer.py @@ -0,0 +1,5 @@ +from typing import Final, Optional, Tuple + +from sampletones_core.constants.enums import GeneratorName + +CHANNEL_AXIS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 90c17a39..7d2ed4ca 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -1,14 +1,9 @@ -from typing import Final, Optional, Tuple +from typing import Final, Optional from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName -COLUMNS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) -SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn) - _LEADING_TABLE_COLUMNS: Final[int] = 2 SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1 @@ -20,16 +15,6 @@ HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1 -def flat_index(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: - return COLUMNS.index(generator) * len(SUBCOLUMNS) + SUBCOLUMNS.index(subcolumn) - - -def from_flat(row: int, index: int) -> TrackerCursor: - index %= len(COLUMNS) * len(SUBCOLUMNS) - column, sub = divmod(index, len(SUBCOLUMNS)) - return TrackerCursor(row, COLUMNS[column], SUBCOLUMNS[sub]) - - def channel_color(colors: ChannelColors, generator: GeneratorName) -> BaseColor: match generator: case GeneratorName.PULSE1: @@ -47,8 +32,8 @@ def tracker_table_column(generator: Optional[GeneratorName]) -> int: The visual divider between the sample column and the channels occupies a table column of its own, so the channels sit one slot further right than their - logical position. The divider is purely visual, so :data:`COLUMNS` covers only - the cursor-addressable columns. + logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers + only the cursor-addressable columns. """ if generator is None: return SAMPLE_TABLE_COLUMN diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index f6042e91..868f2910 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -4,10 +4,10 @@ from pydantic.dataclasses import dataclass +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_core.constants.enums import GeneratorName INDEX_DIGITS: Final[int] = 2 -ORDER_ROWS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) @dataclass(frozen=True) @@ -58,8 +58,8 @@ def navigate_channel(self, value: int) -> OrderInputState: if self.cursor is None: return self - current = ORDER_ROWS.index(self.cursor.generator) - new_generator = ORDER_ROWS[(current + value) % len(ORDER_ROWS)] + current = CHANNEL_AXIS.index(self.cursor.generator) + new_generator = CHANNEL_AXIS[(current + value) % len(CHANNEL_AXIS)] return OrderInputState( cursor=OrderCursor(new_generator, self.cursor.position), pending="", diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 850c986b..d642e07a 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -4,17 +4,18 @@ from pydantic.dataclasses import dataclass -from sampletones_application.ui.panels.sequencer.columns import ( - COLUMNS, - SUBCOLUMNS, - flat_index, - from_flat, -) +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.general import MAX_VOLUME from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS @@ -95,6 +96,12 @@ def navigate_subcolumn( value: int, absolute: bool = False, ) -> TrackerInputState: + """Steps the cursor along the flattened slot axis, wrapping at either end. + + Wrapping is a navigation policy the cursor owns: walking right off the last + volume slot lands on the sample column's instrument, so a held arrow key + tours the whole row. + """ if self.cursor is None: return self @@ -109,9 +116,10 @@ def navigate_subcolumn( pending="", ) - current = flat_index(self.cursor.generator, self.cursor.subcolumn) + current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + slot = slot_from_flat((current + value) % SLOT_COUNT) return TrackerInputState( - cursor=from_flat(self.cursor.row, current + value), + cursor=TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn), pending="", ) @@ -119,10 +127,14 @@ def navigate_column_by(self, delta: int) -> TrackerInputState: if self.cursor is None: return self - current_idx = COLUMNS.index(self.cursor.generator) - next_idx = (current_idx + delta) % len(COLUMNS) + current_idx = CHANNEL_AXIS.index(self.cursor.generator) + next_idx = (current_idx + delta) % len(CHANNEL_AXIS) return TrackerInputState( - cursor=TrackerCursor(self.cursor.row, COLUMNS[next_idx], self.cursor.subcolumn), + cursor=TrackerCursor( + self.cursor.row, + CHANNEL_AXIS[next_idx], + self.cursor.subcolumn, + ), pending="", ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 32ae8557..3e497806 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -5,6 +5,7 @@ from sampletones_application.categories.elements.sequencer import SequencerOrderElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.layout.general.plus_minus_buttons import ( PlusMinusButtonsLayout, ) @@ -43,7 +44,6 @@ from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, - ORDER_ROWS, OrderCursor, OrderInputState, ) @@ -489,7 +489,7 @@ def _build_table(self, position_count: int) -> None: ) self._label_rows = {} - for generator in ORDER_ROWS: + for generator in CHANNEL_AXIS: self._build_row(generator, position_count) if generator is None: self._build_divider_row(position_count) @@ -691,7 +691,7 @@ def _render_cell(self, key: OrderKey) -> str: def _table_row(self, generator: Optional[GeneratorName]) -> int: if generator is None: return MASTER_TABLE_ROW - return ORDER_ROWS.index(generator) + 1 + return CHANNEL_AXIS.index(generator) + 1 def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: dpg.highlight_table_cell( diff --git a/src/sampletones_application/view_model/sequencer/slot.py b/src/sampletones_application/view_model/sequencer/slot.py new file mode 100644 index 00000000..f4902ea3 --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/slot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Final, Optional, Tuple + +from pydantic.dataclasses import dataclass + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn) +SLOT_COUNT: Final[int] = len(CHANNEL_AXIS) * len(SUBCOLUMNS) + + +@dataclass(frozen=True) +class TrackerSlot: + """One addressable cell of the tracker grid: a column paired with a subcolumn. + + The grid lays the sample column and the four channels out along + :data:`CHANNEL_AXIS`, each holding the same :data:`SUBCOLUMNS`, so a slot reads + equally as that pair and as a single index into the flattened axis. Both + readings are load-bearing: navigation and range selection walk the flat index, + while an edit addresses the column and the subcolumn it lands in. + """ + + generator: Optional[GeneratorName] + subcolumn: SubColumn + + @property + def flat_index(self) -> int: + return column_slot_base(self.generator) + SUBCOLUMNS.index(self.subcolumn) + + +def column_slot_base(generator: Optional[GeneratorName]) -> int: + """The flat index of ``generator``'s first subcolumn. + + Every base is a multiple of ``len(SUBCOLUMNS)``, which is what keeps an offset + measured from one column's base addressing the same kind of subcolumn at any + other column it is replayed against. + """ + return CHANNEL_AXIS.index(generator) * len(SUBCOLUMNS) + + +def slot_from_flat(index: int) -> TrackerSlot: + """Reads a flat index back as the column and subcolumn it addresses. + + The mapping is exact over the axis, so a caller that walks off either end is + asking for a slot the grid has no cell for: navigation wraps its index before + calling, and a range selection clips to the axis. + + Raises: + IndexError: if ``index`` lies outside ``0`` up to :data:`SLOT_COUNT`. + """ + if not 0 <= index < SLOT_COUNT: + raise IndexError(f"Tracker slot index out of range: {index}") + + column, subcolumn = divmod(index, len(SUBCOLUMNS)) + return TrackerSlot(CHANNEL_AXIS[column], SUBCOLUMNS[subcolumn]) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index ee068f1a..86ce83f2 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -1,7 +1,7 @@ from typing import Optional +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.order import ( - ORDER_ROWS, OrderCursor, OrderInputState, ) @@ -31,13 +31,13 @@ def test_position_is_a_no_op_without_positions(self) -> None: def test_channel_cycles_master_then_channels_and_wraps(self) -> None: visited = [] - state = OrderInputState(cursor=OrderCursor(ORDER_ROWS[0], 0)) - for _ in range(len(ORDER_ROWS)): + state = OrderInputState(cursor=OrderCursor(CHANNEL_AXIS[0], 0)) + for _ in range(len(CHANNEL_AXIS)): visited.append(state.cursor.generator) state = state.navigate_channel(1) - assert visited == list(ORDER_ROWS) - assert state.cursor.generator == ORDER_ROWS[0] + assert visited == list(CHANNEL_AXIS) + assert state.cursor.generator == CHANNEL_AXIS[0] class TestEntry: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py new file mode 100644 index 00000000..34fb7cc8 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py @@ -0,0 +1,59 @@ +from typing import Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +_OUT_OF_RANGE = [-1, -SLOT_COUNT, SLOT_COUNT, SLOT_COUNT + 1] + + +class TestAxis: + def test_the_sample_column_leads_the_four_channels(self) -> None: + assert CHANNEL_AXIS == (None, *GeneratorName.items()) + + def test_the_axis_covers_every_column_once_over(self) -> None: + assert SLOT_COUNT == len(CHANNEL_AXIS) * len(SUBCOLUMNS) + + +class TestFlatIndex: + @pytest.mark.parametrize("index", range(SLOT_COUNT)) + def test_every_index_round_trips_through_its_slot(self, index: int) -> None: + assert slot_from_flat(index).flat_index == index + + def test_the_axis_maps_onto_the_whole_index_range(self) -> None: + indices = { + TrackerSlot(generator, subcolumn).flat_index for generator in CHANNEL_AXIS for subcolumn in SUBCOLUMNS + } + + assert indices == set(range(SLOT_COUNT)) + + def test_the_sample_columns_instrument_opens_the_axis(self) -> None: + assert TrackerSlot(None, SubColumn.INSTRUMENT).flat_index == 0 + + +class TestColumnBase: + @pytest.mark.parametrize("generator", CHANNEL_AXIS) + def test_every_base_starts_a_whole_column(self, generator: Optional[GeneratorName]) -> None: + """Kind alignment rests on this: an offset from any base addresses the same subcolumn.""" + assert column_slot_base(generator) % len(SUBCOLUMNS) == 0 + + @pytest.mark.parametrize("generator", CHANNEL_AXIS) + def test_a_base_addresses_its_columns_first_subcolumn(self, generator: Optional[GeneratorName]) -> None: + assert slot_from_flat(column_slot_base(generator)) == TrackerSlot(generator, SUBCOLUMNS[0]) + + +class TestBounds: + @pytest.mark.parametrize("index", _OUT_OF_RANGE) + def test_an_index_off_the_axis_is_rejected(self, index: int) -> None: + """A selection clips at the edge, so a slot outside the axis is a caller's mistake.""" + with pytest.raises(IndexError): + slot_from_flat(index) From f29265df8fb27e90ba3ac6ef681a146eec974dfc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 15:33:31 +0200 Subject: [PATCH 03/28] Fixed: an explicit zero transpose reading as an empty one --- src/sampletones_core/utils/display.py | 13 ++++-- .../sampletones_core/utils/test_display.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index c9a3b45b..fe330b6e 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -70,9 +70,14 @@ def display_volume(value: Optional[int]) -> str: def display_transpose(value: Optional[int]) -> str: - if value is None or value == 0: + """Render a transpose as a signed two-digit offset, or ``...`` for an empty one. + + An explicit zero reads ``+00``, since a row storing it resets the channel's + transpose to the sample's own pitch, while an empty cell keeps whatever + transpose is already in force. + """ + if value is None: return NOTE_BLANK - sign = PLUS if value > 0 else MINUS - abs_value = abs(value) - return f"{sign}{abs_value:02X}" + sign = PLUS if value >= 0 else MINUS + return f"{sign}{abs(value):02X}" diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py index 57faf551..c342f7e1 100644 --- a/tests/unit/sampletones_core/utils/test_display.py +++ b/tests/unit/sampletones_core/utils/test_display.py @@ -1,17 +1,22 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from unittest.mock import Mock +import pytest + from sampletones_core.constants.enums import GeneratorName from sampletones_core.project import Project from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.utils.display import ( + NOTE_BLANK, NOTE_OFF, display_command, display_id, display_sample, display_sample_label, + display_transpose, + display_volume, ) @@ -121,3 +126,41 @@ def test_note_off_renders_dashes(self) -> None: ) == NOTE_OFF ) + + +_TRANSPOSE_CASES = [ + (5, "+05"), + (-5, "-05"), + (26, "+1A"), + (-26, "-1A"), +] + + +class TestDisplayTranspose: + @pytest.mark.parametrize(("value", "expected"), _TRANSPOSE_CASES) + def test_signed_offset_is_two_hexadecimal_digits(self, value: int, expected: str) -> None: + assert display_transpose(value) == expected + + def test_explicit_zero_reads_as_a_zero_offset(self) -> None: + """A row storing zero resets the channel's transpose, so the cell shows the reset.""" + assert display_transpose(0) == "+00" + + def test_absent_transpose_is_placeholder(self) -> None: + assert display_transpose(None) == NOTE_BLANK + + def test_zero_and_absent_read_apart(self) -> None: + assert display_transpose(0) != display_transpose(None) + + @pytest.mark.parametrize("value", [None, 0, 5, -5, 26, -26]) + def test_every_rendering_is_the_same_width(self, value: Optional[int]) -> None: + """The grid lays transpose out in a fixed field, so every value fills it exactly.""" + assert len(display_transpose(value)) == len(NOTE_BLANK) + + +class TestDisplayVolume: + def test_silent_volume_reads_as_zero(self) -> None: + """Volume already tells a stored zero apart from an empty cell; this pins it.""" + assert display_volume(0) == "0" + + def test_absent_volume_is_placeholder(self) -> None: + assert display_volume(None) == "." From 00239dd66e2898a262fa3779e02f62c63c00f575 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:02:17 +0200 Subject: [PATCH 04/28] Fixed: channel disagreement indicator --- .../view_model/sequencer/aggregate.py | 9 +- .../view_model/sequencer/tracker.py | 40 +++---- src/sampletones_shared/utils/agreement.py | 59 ++++++++++ .../view_model/sequencer/test_tracker.py | 20 +++- .../utils/test_agreement.py | 101 ++++++++++++++++++ 5 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 src/sampletones_shared/utils/agreement.py create mode 100644 tests/unit/sampletones_shared/utils/test_agreement.py diff --git a/src/sampletones_application/view_model/sequencer/aggregate.py b/src/sampletones_application/view_model/sequencer/aggregate.py index edd67d55..69892e6a 100644 --- a/src/sampletones_application/view_model/sequencer/aggregate.py +++ b/src/sampletones_application/view_model/sequencer/aggregate.py @@ -1,6 +1,7 @@ from typing import Set from sampletones_shared.constants.symbols import MIXED +from sampletones_shared.utils.agreement import Agreement def aggregate_labels(values: Set[str], *, default: str) -> str: @@ -10,10 +11,4 @@ def aggregate_labels(values: Set[str], *, default: str) -> str: ``default`` (no relevant cells), a single shared value is shown verbatim, and any disagreement collapses to :data:`MIXED`. """ - if not values: - return default - - if len(values) == 1: - return next(iter(values)) - - return MIXED + return Agreement.collapse(values).resolve(absent=default, mixed=MIXED) diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index addac95c..54012c51 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -5,7 +5,6 @@ from sampletones_application.view_model.sequencer.aggregate import aggregate_labels from sampletones_core.constants.enums import GeneratorName from sampletones_core.utils.display import ( - NOTE_OFF, display_id, display_transpose, display_volume, @@ -41,52 +40,39 @@ class SequencerRowViewModel(BaseModel, frozen=True): @property def subcolumn_generators(self) -> FrozenSet[GeneratorName]: - """Channels the sample column's transpose/volume span. + """Channels every sample column summary spans. - Transpose and volume exist independently of an instrument, so on a row with - no sample they fall back to every channel; otherwise they track the - sample's channels exactly like the instrument does. + A sample governs the channels its reconstruction covers, so its subcolumns + summarise exactly those. Transpose and volume exist independently of an + instrument, so a row with no sample spans every channel. """ return self.relevant_generators or frozenset(self.cells) @property def sample_instrument(self) -> str: - """The sample column's note value. - - A referenced sample wins: the column shows its position (or :data:`MIXED` when the sample - spans more channels than it occupies here). With no sample present, the column reads ``--`` - only when every channel is a note-off; any other mix — including a half-cut row of some - note-off and some blank — reads as empty. - """ - if self.relevant_generators: - return self._aggregate(self.relevant_generators, lambda cell: cell.instrument, display_id(None)) - - if self.cells and all(cell.instrument == NOTE_OFF for cell in self.cells.values()): - return NOTE_OFF - - return display_id(None) + return self._aggregate(lambda cell: cell.instrument, display_id(None)) @property def sample_transpose(self) -> str: - return self._aggregate(self.subcolumn_generators, lambda cell: cell.transpose, display_transpose(None)) + return self._aggregate(lambda cell: cell.transpose, display_transpose(None)) @property def sample_volume(self) -> str: - return self._aggregate(self.subcolumn_generators, lambda cell: cell.volume, display_volume(None)) + return self._aggregate(lambda cell: cell.volume, display_volume(None)) def _aggregate( self, - generators: FrozenSet[GeneratorName], select: Callable[[SequencerCellViewModel], str], default: str, ) -> str: - """Summarise one subcolumn across the given channels. + """Summarise one subcolumn across the channels the sample column spans. - The summary holds a value only when every channel agrees on it, so a sample - missing from one of its channels (an empty cell there) reads as - :data:`MIXED`. With no channels the empty default is shown. + The summary holds a value only where every channel agrees on it, so + :data:`MIXED` marks each way they can differ: a sample missing from one of + its channels, a transpose set on some of them, or a row cut on some and + blank on the rest. A row with no cells at all shows the empty default. """ - values: Set[str] = {select(self.cells[generator]) for generator in generators} + values: Set[str] = {select(self.cells[generator]) for generator in self.subcolumn_generators} return aggregate_labels(values, default=default) diff --git a/src/sampletones_shared/utils/agreement.py b/src/sampletones_shared/utils/agreement.py new file mode 100644 index 00000000..68abcf84 --- /dev/null +++ b/src/sampletones_shared/utils/agreement.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Hashable +from dataclasses import dataclass +from typing import FrozenSet, Generic, Iterable, TypeVar + +ValueT = TypeVar("ValueT", bound=Hashable) + + +@dataclass(frozen=True) +class Agreement(Generic[ValueT]): + """Whether a group of sources holds one value in common. + + Three outcomes are kept apart: the group is empty, every source holds the same + value, or the sources hold differing ones. Reporting the agreed value separately + from the fact of agreement is what lets an absent value count as agreement — a + transpose every channel leaves empty is a value they share. + """ + + distinct: FrozenSet[ValueT] + + @classmethod + def collapse(cls, values: Iterable[ValueT]) -> Agreement[ValueT]: + return cls(distinct=frozenset(values)) + + @property + def is_absent(self) -> bool: + return not self.distinct + + @property + def is_unanimous(self) -> bool: + return len(self.distinct) == 1 + + @property + def is_mixed(self) -> bool: + return len(self.distinct) > 1 + + @property + def value(self) -> ValueT: + """The value every source holds. + + Raises: + ValueError: if the group is empty or its sources differ, so that no + single value describes them. + """ + if not self.is_unanimous: + raise ValueError(f"Agreement over {len(self.distinct)} distinct values holds no single value") + + return next(iter(self.distinct)) + + def resolve(self, *, absent: ValueT, mixed: ValueT) -> ValueT: + """The agreed value, or the stand-in named for the outcome that reached instead.""" + if self.is_absent: + return absent + + if self.is_mixed: + return mixed + + return self.value diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 462e7bae..e9dd5c3a 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -146,13 +146,29 @@ class AggregateCase(BaseRegularTestCase): expected_volume=_EMPTY_VOLUME, ), AggregateCase( - label="partial_note_off_reads_as_empty", + label="half_cut_row_is_mixed", cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)), relevant_generators=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_instrument=MIXED, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), + AggregateCase( + label="zero_transpose_beside_an_empty_one_is_mixed", + cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))), + relevant_generators=frozenset(), + expected_instrument=_EMPTY_INSTRUMENT, + expected_transpose=MIXED, + expected_volume=_EMPTY_VOLUME, + ), + AggregateCase( + label="zero_transpose_shared_by_every_channel_reads_as_zero", + cells={generator: _cell(transpose=display_transpose(0)) for generator in GeneratorName.items()}, + relevant_generators=frozenset(), + expected_instrument=_EMPTY_INSTRUMENT, + expected_transpose=display_transpose(0), + expected_volume=_EMPTY_VOLUME, + ), ) @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) diff --git a/tests/unit/sampletones_shared/utils/test_agreement.py b/tests/unit/sampletones_shared/utils/test_agreement.py new file mode 100644 index 00000000..5429883f --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_agreement.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_shared.utils.agreement import Agreement +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +_ABSENT = -1 +_MIXED = -2 + + +class TestAgreementOutcomes(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class OutcomeCase(BaseRegularTestCase): + values: Tuple[Optional[int], ...] + expected_absent: bool + expected_unanimous: bool + expected_mixed: bool + expected_resolved: Optional[int] + + test_cases = ( + OutcomeCase( + label="no_sources_are_absent", + values=(), + expected_absent=True, + expected_unanimous=False, + expected_mixed=False, + expected_resolved=_ABSENT, + ), + OutcomeCase( + label="one_source_is_unanimous", + values=(5,), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=5, + ), + OutcomeCase( + label="repeated_value_is_unanimous", + values=(5, 5, 5), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=5, + ), + OutcomeCase( + label="differing_values_are_mixed", + values=(5, 7), + expected_absent=False, + expected_unanimous=False, + expected_mixed=True, + expected_resolved=_MIXED, + ), + OutcomeCase( + label="every_source_absent_is_unanimous_on_absence", + values=(None, None), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=None, + ), + OutcomeCase( + label="absence_beside_a_value_is_mixed", + values=(None, 5), + expected_absent=False, + expected_unanimous=False, + expected_mixed=True, + expected_resolved=_MIXED, + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_outcome_flags_and_resolution(self, case: OutcomeCase) -> None: + agreement: Agreement[Optional[int]] = Agreement.collapse(case.values) + + assert agreement.is_absent is case.expected_absent + assert agreement.is_unanimous is case.expected_unanimous + assert agreement.is_mixed is case.expected_mixed + assert agreement.resolve(absent=_ABSENT, mixed=_MIXED) == case.expected_resolved + + +class TestAgreementValue: + def test_unanimous_absence_reports_absence_as_the_agreed_value(self) -> None: + """The outcome and the agreed value are read apart, so ``None`` can be what they share.""" + agreement: Agreement[Optional[int]] = Agreement.collapse((None, None)) + + assert agreement.is_unanimous + assert agreement.value is None + + def test_no_sources_have_no_agreed_value(self) -> None: + with pytest.raises(ValueError): + Agreement.collapse(()).value + + def test_differing_sources_have_no_agreed_value(self) -> None: + with pytest.raises(ValueError): + Agreement.collapse((5, 7)).value + + def test_order_of_sources_leaves_the_agreement_equal(self) -> None: + assert Agreement.collapse((5, 7)) == Agreement.collapse((7, 5)) From e7576c58663e1f7c8e9208a642a98706371c6a46 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:25:04 +0200 Subject: [PATCH 05/28] Moved: sample column dispatch into the tracker logic --- .../coordinators/tabs/sequencer.py | 102 +------ .../logic/sequencer/tracker.py | 225 +++++++++++++--- .../coordinators/tabs/test_sequencer.py | 20 -- .../logic/sequencer/test_tracker.py | 254 ++++++++++++++++++ 4 files changed, 454 insertions(+), 147 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index ea6fac18..25a6f41b 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -89,7 +89,6 @@ SequencerSettingsViewModel, ) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel -from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, HistoryDetailSegment, @@ -97,7 +96,6 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import SampleToNESError @@ -303,23 +301,23 @@ def _wire_module_callbacks(self) -> None: def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_clear_row = self._undoable( HistoryAction.CLEAR_ROW, - self._on_clear_row, + self._sequencer_tracker_logic.clear_cell, detail=self._history_detail.clear_row, ) self._sequencer_tracker_panel.on_clear_subcolumn = self._undoable( HistoryAction.CLEAR_SUBCOLUMN, - self._on_clear_subcolumn, + self._sequencer_tracker_logic.clear_cell_subcolumn, detail=self._history_detail.clear_subcolumn, ) self._sequencer_tracker_panel.on_set_row = self._undoable( HistoryAction.EDIT_ROW, - self._on_set_row, + self._sequencer_tracker_logic.write_cell, detail=self._history_detail.edit_row, coalesce=self._edit_row_key, ) self._sequencer_tracker_panel.on_set_note_off = self._undoable( HistoryAction.NOTE_OFF, - self._on_set_note_off, + self._sequencer_tracker_logic.cut_note, detail=self._history_detail.note_off, coalesce=self._cell_key, ) @@ -328,13 +326,13 @@ def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame self._sequencer_tracker_panel.on_adjust_transpose = self._undoable( HistoryAction.ADJUST_TRANSPOSE, - self._on_adjust_transpose, + self._sequencer_tracker_logic.adjust_cell_transpose, detail=self._history_detail.adjust_transpose, coalesce=self._adjustment_key, ) self._sequencer_tracker_panel.on_adjust_volume = self._undoable( HistoryAction.ADJUST_VOLUME, - self._on_adjust_volume, + self._sequencer_tracker_logic.adjust_cell_volume, detail=self._history_detail.adjust_volume, coalesce=self._adjustment_key, ) @@ -1026,94 +1024,6 @@ def _replace_target_label(self) -> Optional[str]: def _dispatch_edit_sample(self, sample_id: str) -> None: self._on_edit_sample_requested(sample_id) - def _on_clear_row( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - if generator is None: - self._sequencer_tracker_logic.clear_all_generators(row_index) - else: - self._sequencer_tracker_logic.clear_row(generator, row_index) - - def _on_clear_subcolumn( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - instrument = subcolumn is SubColumn.INSTRUMENT - transpose = subcolumn is SubColumn.TRANSPOSE - volume = subcolumn is SubColumn.VOLUME - if generator is None: - if instrument: - self._sequencer_tracker_logic.clear_subcolumn_all_generators( - row_index, - instrument=True, - ) - else: - self._sequencer_tracker_logic.clear_sample_subcolumn( - row_index, - transpose=transpose, - volume=volume, - ) - else: - self._sequencer_tracker_logic.clear_subcolumn( - generator, - row_index, - instrument=instrument, - transpose=transpose, - volume=volume, - ) - - def _on_set_row( - self, - row_index: int, - generator: Optional[GeneratorName], - sample_id: Optional[str], - transpose: Optional[int], - volume: Optional[int], - ) -> None: - if generator is None: - if sample_id is not None: - self._sequencer_tracker_logic.set_sample_instrument( - row_index, - sample_id, - ) - elif transpose is not None or volume is not None: - self._sequencer_tracker_logic.set_sample_subcolumn( - row_index, - transpose=transpose, - volume=volume, - ) - else: - command = ( - Instrument( - sample_id=sample_id, - generator_name=generator, - ) - if sample_id is not None - else None - ) - self._sequencer_tracker_logic.set_row( - generator, - row_index, - command=command, - transpose=transpose, - volume=volume, - ) - - def _on_set_note_off( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - """Writes a note-off: to one channel, or across every channel from the sample column.""" - if generator is None: - self._sequencer_tracker_logic.set_note_off_all_generators(row_index) - else: - self._sequencer_tracker_logic.set_note_off(generator, row_index) - def _on_tracker_play_from_row(self, row_index: int) -> None: """Starts playback from the right-clicked row of the frame the tracker is showing.""" self._song_player_logic.play_from( diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker.py index 7fe714e5..ec99b48f 100644 --- a/src/sampletones_application/logic/sequencer/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker.py @@ -4,6 +4,7 @@ from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( SequencerCellViewModel, SequencerRowViewModel, @@ -38,6 +39,10 @@ class SequencerTrackerLogic(CallbackMixin): translates raw panel events into :class:`ProjectController` mutations. The controller's change events are wired (by the coordinator) back to the push methods here, so a single mutation round-trips into a refreshed view. + + Cell-level edits take an ``Optional[GeneratorName]`` naming the column they + address: a generator reaches that channel alone, while ``None`` addresses the + sample column and spreads the edit over the channels that column governs. """ def __init__(self, project_controller: ProjectController) -> None: @@ -66,38 +71,51 @@ def build_grid(self) -> SequencerTrackerViewModel: frame_count = song.order_length() frame_index = self._clamp_frame(frame_count) - patterns: Dict[GeneratorName, Pattern] = {} - if frame_count > 0: - for generator in GeneratorName.items(): - index = song.order[frame_index].get(generator) - pattern = song.pattern(generator, index) if index is not None else None - if pattern is not None: - patterns[generator] = pattern - - row_count = self._frame_row_count(patterns, song.rows_per_pattern) if frame_count > 0 else 0 - rows = tuple(self._build_row(index, patterns) for index in range(row_count)) + patterns = self._frame_patterns() + rows = tuple(self._build_row(index, patterns) for index in range(self.frame_row_count())) return SequencerTrackerViewModel( frame_index=frame_index, frame_count=frame_count, rows=rows, ) - def _frame_row_count( - self, - patterns: Dict[GeneratorName, Pattern], - rows_per_pattern: int, - ) -> int: - """Rows to show for the current frame. + def frame_row_count(self) -> int: + """Rows the current frame holds, the height a whole-frame edit spans. - Empty (None) slots contribute no pattern, so a frame whose channels are all - empty falls back to ``rows_per_pattern`` blank rows — keeping the frame - editable so the first keystroke can auto-create a pattern for that channel. + A frame is as tall as its longest pattern. Empty (None) slots contribute no + pattern, so a frame whose channels are all empty falls back to + ``rows_per_pattern`` blank rows — keeping the frame editable so the first + keystroke can auto-create a pattern for that channel. Until the order holds + its first frame, the count is zero. """ - lengths = [pattern.length for pattern in patterns.values()] + song = self._controller.project.song + if song.order_length() == 0: + return 0 + + lengths = [pattern.length for pattern in self._frame_patterns().values()] if lengths: return max(lengths) - return rows_per_pattern + return song.rows_per_pattern + + def _frame_patterns(self) -> Dict[GeneratorName, Pattern]: + """The patterns the current frame's channels point at. + + A channel contributes an entry once its slot names a pattern the song holds, + so the result covers exactly the channels carrying content at this frame. + """ + song = self._controller.project.song + if self._frame_index >= song.order_length(): + return {} + + patterns: Dict[GeneratorName, Pattern] = {} + for generator in GeneratorName.items(): + index = song.order[self._frame_index].get(generator) + pattern = song.pattern(generator, index) if index is not None else None + if pattern is not None: + patterns[generator] = pattern + + return patterns def push_settings(self) -> None: self.call(self.on_settings_changed, self.settings) @@ -123,6 +141,143 @@ def set_tempo(self, tempo: int) -> None: def set_speed(self, speed: int) -> None: self._controller.set_speed(speed) + def clear_cell( + self, + row_index: int, + generator: Optional[GeneratorName], + ) -> None: + if generator is None: + self.clear_all_generators(row_index) + else: + self.clear_row(generator, row_index) + + def clear_cell_subcolumn( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> None: + """Empties one subcolumn of a cell. + + From the sample column an instrument reaches every channel, since the sample + it names is the row's whole note, while transpose and volume follow the + channels that column governs. + """ + instrument = subcolumn is SubColumn.INSTRUMENT + transpose = subcolumn is SubColumn.TRANSPOSE + volume = subcolumn is SubColumn.VOLUME + if generator is not None: + self.clear_subcolumn( + generator, + row_index, + instrument=instrument, + transpose=transpose, + volume=volume, + ) + elif instrument: + self.clear_subcolumn_all_generators(row_index, instrument=True) + else: + self.clear_sample_subcolumn( + row_index, + transpose=transpose, + volume=volume, + ) + + def write_cell( + self, + row_index: int, + generator: Optional[GeneratorName], + sample_id: Optional[str], + transpose: Optional[int], + volume: Optional[int], + ) -> None: + """Writes the value a cell edit carries, keeping the rest of the cell as it stands. + + An edit names one subcolumn, so a sample takes the write whenever one + arrives, and an offset lands on its own otherwise. + """ + if sample_id is not None: + self.place_note(row_index, generator, sample_id) + elif transpose is not None or volume is not None: + self.set_cell_subcolumn( + row_index, + generator, + transpose=transpose, + volume=volume, + ) + + def place_note( + self, + row_index: int, + generator: Optional[GeneratorName], + sample_id: str, + ) -> None: + if generator is None: + self.set_sample_instrument(row_index, sample_id) + else: + self.set_row( + generator, + row_index, + command=Instrument( + sample_id=sample_id, + generator_name=generator, + ), + ) + + def cut_note( + self, + row_index: int, + generator: Optional[GeneratorName], + ) -> None: + if generator is None: + self.set_note_off_all_generators(row_index) + else: + self.set_note_off(generator, row_index) + + def set_cell_subcolumn( + self, + row_index: int, + generator: Optional[GeneratorName], + *, + transpose: Optional[int] = None, + volume: Optional[int] = None, + ) -> None: + if generator is None: + self.set_sample_subcolumn( + row_index, + transpose=transpose, + volume=volume, + ) + else: + self.set_row( + generator, + row_index, + transpose=transpose, + volume=volume, + ) + + def adjust_cell_transpose( + self, + row_index: int, + generator: Optional[GeneratorName], + delta: int, + ) -> None: + if generator is None: + self.adjust_sample_transpose(row_index, delta) + else: + self.adjust_transpose(generator, row_index, delta) + + def adjust_cell_volume( + self, + row_index: int, + generator: Optional[GeneratorName], + delta: int, + ) -> None: + if generator is None: + self.adjust_sample_volume(row_index, delta) + else: + self.adjust_volume(generator, row_index, delta) + def set_row( self, generator: GeneratorName, @@ -321,11 +476,12 @@ def adjust_sample_volume(self, row_index: int, delta: int) -> None: for generator in self._subcolumn_generators(row_index): self.adjust_volume(generator, row_index, delta) - def _current_row( + def row( self, generator: GeneratorName, row_index: int, ) -> Optional[Row]: + """The row stored at a cell, present while its channel holds a pattern reaching that far.""" pattern_index = self._pattern_index_at_frame(generator) if pattern_index is None: return None @@ -341,14 +497,14 @@ def _current_transpose( generator: GeneratorName, row_index: int, ) -> int: - row = self._current_row(generator, row_index) + row = self.row(generator, row_index) if row is None or row.transpose is None: return 0 return row.transpose def _current_volume(self, generator: GeneratorName, row_index: int) -> int: - row = self._current_row(generator, row_index) + row = self.row(generator, row_index) if row is None or row.volume is None: return MAX_VOLUME @@ -419,13 +575,20 @@ def _subcolumn_generators(self, row_index: int) -> List[GeneratorName]: Falls back to every channel when no sample constrains the row, mirroring :attr:`SequencerRowViewModel.subcolumn_generators`. """ - relevant = self._relevant_generators(row_index) - if not relevant: + referenced = self.referenced_generators(row_index) + if not referenced: return GeneratorName.items() - return [generator for generator in GeneratorName.items() if generator in relevant] + return [generator for generator in GeneratorName.items() if generator in referenced] + + def referenced_generators(self, row_index: int) -> FrozenSet[GeneratorName]: + """The channels spanned by the samples a row names. - def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]: + Reads the row from every channel's pattern, so it reports a sample's whole + span even where some of its cells stand empty. A row naming no sample + references no channel, which is what :meth:`relevant_generators` widens to + every channel. + """ rows: Dict[GeneratorName, Optional[Row]] = {} for generator in GeneratorName.items(): pattern_index = self._pattern_index_at_frame(generator) @@ -439,9 +602,9 @@ def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]: ) rows[generator] = pattern.rows[row_index] if pattern is not None else None - return self._relevant_generators_from_rows(rows) + return self._referenced_generators_from_rows(rows) - def _relevant_generators_from_rows( + def _referenced_generators_from_rows( self, rows: Dict[GeneratorName, Optional[Row]], ) -> FrozenSet[GeneratorName]: @@ -491,7 +654,7 @@ def _build_row( return SequencerRowViewModel( index=index, cells=cells, - relevant_generators=self._relevant_generators_from_rows(rows), + relevant_generators=self._referenced_generators_from_rows(rows), ) def _build_cell(self, row: Row) -> SequencerCellViewModel: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index cda08806..498e7f2c 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -342,26 +342,6 @@ def test_a_chosen_mode_reaches_the_player( playback_coordinator._song_player_logic.set_follow_mode.assert_called_once_with(mode) -class TestNoteOffDispatch: - def test_channel_cell_writes_note_off_to_that_channel( - self, - playback_coordinator: SequencerTabCoordinator, - ) -> None: - playback_coordinator._on_set_note_off(2, GeneratorName.PULSE1) - - playback_coordinator._sequencer_tracker_logic.set_note_off.assert_called_once_with(GeneratorName.PULSE1, 2) - playback_coordinator._sequencer_tracker_logic.set_note_off_all_generators.assert_not_called() - - def test_sample_column_cuts_every_channel( - self, - playback_coordinator: SequencerTabCoordinator, - ) -> None: - playback_coordinator._on_set_note_off(2, None) - - playback_coordinator._sequencer_tracker_logic.set_note_off_all_generators.assert_called_once_with(2) - playback_coordinator._sequencer_tracker_logic.set_note_off.assert_not_called() - - @pytest.fixture def order_ops_coordinator() -> SequencerTabCoordinator: """A coordinator with only the collaborators the order-frame handlers touch.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py index fe4d2967..44e42ace 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py @@ -6,6 +6,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME @@ -70,6 +71,259 @@ def _place_instrument( ) +class TestClearCell: + def test_a_channel_cell_clears_only_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(GeneratorName.PULSE2, 0, transpose=7) + + logic.clear_cell(0, GeneratorName.PULSE1) + + assert _row(controller, GeneratorName.PULSE1).transpose is None + assert _row(controller, GeneratorName.PULSE2).transpose == 7 + + def test_the_sample_column_clears_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_sample_subcolumn(0, transpose=5) + + logic.clear_cell(0, None) + + for generator in GeneratorName.items(): + assert _row(controller, generator).transpose is None + + +class TestClearCellSubcolumn: + def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5, volume=10) + + logic.clear_cell_subcolumn(0, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + + row = _row(controller, GeneratorName.PULSE1) + assert row.transpose is None + assert row.volume == 10 + + def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + logic.set_note_off(GeneratorName.NOISE, 0) + + logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) + + for generator in GeneratorName.items(): + assert _row(controller, generator).command is None + + def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + for generator in GeneratorName.items(): + logic.set_row(generator, 0, transpose=5) + + logic.clear_cell_subcolumn(0, None, SubColumn.TRANSPOSE) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert _row(controller, generator).transpose is None + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).transpose == 5 + + +class TestWriteCell: + def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + + logic.write_cell(0, None, sample.id, None, None) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert isinstance(_row(controller, generator).command, Instrument) + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).command is None + + def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: + """A cell re-targets the sample onto its own channel, whichever channels the sample covers.""" + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1]), + name="lead", + ) + + logic.write_cell(0, GeneratorName.NOISE, sample.id, None, None) + + command = _row(controller, GeneratorName.NOISE).command + assert isinstance(command, Instrument) + assert command.sample_id == sample.id + assert command.generator_name == GeneratorName.NOISE + assert _row(controller, GeneratorName.PULSE1).command is None + + def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.write_cell(0, None, None, 5, None) + + for generator in GeneratorName.items(): + assert _row(controller, generator).transpose == 5 + + def test_a_volume_in_a_channel_cell_leaves_the_rest_of_the_cell_standing(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + + logic.write_cell(0, GeneratorName.PULSE1, None, None, 10) + + row = _row(controller, GeneratorName.PULSE1) + assert row.transpose == 5 + assert row.volume == 10 + + def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None: + """Typing a sample index the project has no sample for creates no pattern.""" + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + logic.write_cell(0, GeneratorName.PULSE1, None, None, None) + + assert controller.project.song.order[1][GeneratorName.PULSE1] is None + + +class TestCutNote: + def test_a_channel_cell_cuts_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.cut_note(0, GeneratorName.PULSE1) + + assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff) + assert _row(controller, GeneratorName.PULSE2).command is None + + def test_the_sample_column_cuts_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.cut_note(0, None) + + for generator in GeneratorName.items(): + assert isinstance(_row(controller, generator).command, NoteOff) + + +class TestAdjustCell: + def test_a_channel_cell_shifts_only_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.adjust_cell_volume(0, GeneratorName.PULSE1, -1) + + assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1 + assert _row(controller, GeneratorName.PULSE2).volume is None + + def test_the_sample_column_shifts_the_sample_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + + logic.adjust_cell_transpose(0, None, 3) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert _row(controller, generator).transpose == 3 + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).transpose is None + + +class TestFrameRowCount: + def test_counts_the_rows_the_grid_builds(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + assert logic.frame_row_count() == len(logic.build_grid().rows) + + def test_an_empty_frame_counts_editable_rows(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + assert logic.frame_row_count() == controller.project.song.rows_per_pattern + + def test_an_order_without_frames_counts_nothing(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.remove_frame(0) + + assert logic.frame_row_count() == 0 + + +class TestRowAccess: + def test_reads_the_stored_row(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + + row = logic.row(GeneratorName.PULSE1, 0) + + assert row is not None + assert row.transpose == 5 + + def test_a_channel_without_a_pattern_has_no_row(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + assert logic.row(GeneratorName.PULSE1, 0) is None + + +class TestReferencedGenerators: + def test_one_placement_reports_the_samples_whole_span(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + _place_instrument(controller, GeneratorName.PULSE1, sample.id) + + assert logic.referenced_generators(0) == frozenset( + { + GeneratorName.PULSE1, + GeneratorName.TRIANGLE, + } + ) + + def test_a_row_naming_no_sample_references_no_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_note_off(GeneratorName.PULSE1, 0) + + assert logic.referenced_generators(0) == frozenset() + assert logic.relevant_generators(0) == GeneratorName.items() + + class TestSetNoteOff: def test_set_note_off_writes_note_off_command(self) -> None: controller = _controller() From 2bf4921fa1c04fb7c002341b86f30b50bd1da499 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:53:58 +0200 Subject: [PATCH 06/28] Added: batched project mutation notifications --- docs/development/architecture.md | 2 +- docs/development/undo.md | 9 +- .../coordinators/tabs/sequencer.py | 46 ++--- .../logic/project/batch.py | 26 +++ .../logic/project/controller.py | 157 +++++++++++++----- .../coordinators/tabs/test_sequencer.py | 29 +++- .../logic/history/test_manager.py | 26 +++ .../logic/project/test_controller.py | 125 ++++++++++++++ 8 files changed, 342 insertions(+), 78 deletions(-) create mode 100644 src/sampletones_application/logic/project/batch.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 10dbf8bf..8aa730c5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -253,7 +253,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m *Managers* own a domain object's lifecycle (load, save, close). They hold the current object, a `Session` that tracks dirty state, and fire `CallbackMixin` callbacks when the state changes. -*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. +*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. `ProjectController.batch()` widens that grain to a whole gesture: each mutation still applies the moment it is made, while the callbacks it raises wait for the scope to close and then arrive once each, so a gesture writing hundreds of rows rebuilds its subscribers once. *Logic objects* (e.g. `ConverterLogic`) orchestrate multi-step workflows within a feature area. They subscribe to services and translate service results into view model updates. diff --git a/docs/development/undo.md b/docs/development/undo.md index bf818c9f..e056846b 100644 --- a/docs/development/undo.md +++ b/docs/development/undo.md @@ -34,9 +34,14 @@ the regeneration worker's background thread. consecutive commits sharing the same action and key replace the top entry instead of appending, so a continuous interaction — a graph drag, repeated edits of one cell — records a single entry. Any undo, redo, or jump breaks - the run, so a state the user navigated to is always preserved. + the run, so a state the user navigated to is always preserved. `_undoable` + opens `ProjectController.batch()` inside the transaction, so one gesture is + one entry and one round of view notifications alike. - **Detection — the controller.** `ProjectController._touch()` fires `on_mutation` - on every fine-grained mutation. `HistoryManager.handle_mutation` counts those + on every fine-grained mutation, as it lands — a batch defers the view + notifications and the dirty stamp, leaving this signal immediate so the check + below sees each mutation inside the transaction that caused it. + `HistoryManager.handle_mutation` counts those inside a transaction and rejects any that occur outside one: under strict deployment it raises `UntrackedMutationError`; otherwise it self-heals by recording the mutation as its own entry. This makes completeness a checkable diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 25a6f41b..4a789f8a 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -565,6 +565,11 @@ def _undoable( receives, and ``coalesce`` computes the gesture's target key from them: consecutive gestures sharing the same action and target collapse into a single entry. + + The gesture is batched inside its transaction, so however many rows it + writes, the panels rebuild once — and they rebuild before the entry that + undoes them is recorded, because the snapshot reads the project rather + than the views. """ def wrapped( @@ -573,7 +578,14 @@ def wrapped( ) -> None: description = detail(*args, **kwargs) if detail is not None else () key = coalesce(*args, **kwargs) if coalesce is not None else None - with self._history.transaction(action, detail=description, coalesce=key): + with ( + self._history.transaction( + action, + detail=description, + coalesce=key, + ), + self._project_controller.batch(), + ): callback(*args, **kwargs) return wrapped @@ -1031,38 +1043,6 @@ def _on_tracker_play_from_row(self, row_index: int) -> None: row_index, ) - def _on_adjust_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - """Shifts transpose: one channel, or across the sample column's channels.""" - if generator is None: - self._sequencer_tracker_logic.adjust_sample_transpose(row_index, delta) - else: - self._sequencer_tracker_logic.adjust_transpose( - generator, - row_index, - delta, - ) - - def _on_adjust_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - """Shifts volume: one channel, or across the sample column's channels.""" - if generator is None: - self._sequencer_tracker_logic.adjust_sample_volume(row_index, delta) - else: - self._sequencer_tracker_logic.adjust_volume( - generator, - row_index, - delta, - ) - def _on_samples_changed( self, view_model: SequencerSamplesViewModel, diff --git a/src/sampletones_application/logic/project/batch.py b/src/sampletones_application/logic/project/batch.py new file mode 100644 index 00000000..1ffc444d --- /dev/null +++ b/src/sampletones_application/logic/project/batch.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from sampletones_shared.types.callback import VoidCallback + + +@dataclass +class MutationBatch: + """The open batch's accumulating state. + + Bundles the nesting ``depth`` of coalesced ``batch()`` scopes, the + ``announcements`` the mutations raised in the order they first arose, and + whether the project still needs its dirty ``stamp``. Keeping these together + holds one gesture's deferred notifications in lockstep. The presence of a + ``MutationBatch`` instance is itself the signal that a batch is open, and + nesting a scope increments its ``depth``. + """ + + depth: int = 1 + announcements: List[Optional[VoidCallback]] = field(default_factory=list) + stamped: bool = False + + def record(self, announcement: Optional[VoidCallback]) -> None: + """Keeps an announcement for the flush, once per distinct signal.""" + if announcement not in self.announcements: + self.announcements.append(announcement) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 5e80a04e..fd6b4b95 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -1,5 +1,6 @@ +from contextlib import contextmanager from pathlib import Path -from typing import Optional +from typing import Iterator, Optional from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE @@ -13,6 +14,7 @@ from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.callbacks import CallbackMixin +from .batch import MutationBatch from .manager import ProjectManager @@ -24,10 +26,14 @@ class ProjectController(CallbackMixin): and the observer signal always happen together. - Each mutation kind fires a distinct callback so that subscribers can respond precisely to the specific change. + - :meth:`batch` widens that grain to a whole gesture: its mutations still + apply one at a time, while the dirty stamp and the observer signals they + raise arrive once each, on the way out. """ def __init__(self, project_manager: ProjectManager) -> None: self._project_manager = project_manager + self._batch: Optional[MutationBatch] = None self.on_project_replaced: Optional[VoidCallback] = None self.on_info_changed: Optional[VoidCallback] = None @@ -70,6 +76,29 @@ def sample_count(self) -> int: def is_dirty(self) -> bool: return self._project_manager.is_dirty + @contextmanager + def batch(self) -> Iterator[None]: + """Groups every mutation of one gesture into a single round of notifications. + + A gesture that writes many rows — pasting a block of cells, spreading a + sample across the channels it covers — leaves each mutation applying the + moment it is made, while the dirty stamp and the observer signals it raises + wait for the scope to close and then arrive once each, in the order they + first arose. Subscribers therefore rebuild their views once per gesture + instead of once per row. + + Nested scopes join the outermost one, and the flush runs on scope exit even + when the gesture raises: the mutations that already landed are part of the + live project, so their subscribers hear about them. The history's mutation + signal stays immediate (see :meth:`_touch`), and the lifecycle signals — + a project replaced, a project saved — are unaffected. + """ + self._begin_batch() + try: + yield + finally: + self._flush_batch() + def new(self) -> None: self._project_manager.new() self.call(self.on_project_replaced) @@ -106,57 +135,57 @@ def export_request(self) -> ProjectExport: def mark_updated(self) -> None: self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def set_title(self, title: str) -> None: self.project.info.title = title self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_author(self, author: str) -> None: self.project.info.author = author self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_comment(self, comment: str) -> None: self.project.info.comment = comment self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_tempo(self, tempo: int) -> None: self.project.settings.tempo = tempo self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_speed(self, speed: int) -> None: self.project.settings.speed = speed self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_first_highlight(self, first_highlight: int) -> None: self.project.settings.first_highlight = first_highlight self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_second_highlight(self, second_highlight: int) -> None: self.project.settings.second_highlight = second_highlight self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_nes_frequency(self, nes_frequency: int) -> None: self.project.settings.nes_frequency = nes_frequency self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_sample_rate(self, sample_rate: int) -> None: self.project.settings.sample_rate = sample_rate self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_rows_per_pattern(self, rows_per_pattern: int) -> None: self.song.resize_patterns(rows_per_pattern) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: """Embeds a reconstruction as a project sample, detaching its local source-audio origin. @@ -168,7 +197,7 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: sample = Sample(name=name, reconstruction=reconstruction) self.project.samples.append(sample) self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) return sample def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstruction) -> None: @@ -181,19 +210,19 @@ def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstr reconstruction.detach_source() self.project.samples[sample_id].reconstruction = reconstruction self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def rename_sample(self, sample_id: str, name: str) -> None: self.project.samples[sample_id].name = name self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def set_sample_loop(self, sample_id: str, loop: bool) -> None: self.project.samples[sample_id].loop = loop self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) def is_sample_used(self, sample_id: str) -> bool: return self.song.references_sample(sample_id) @@ -202,8 +231,8 @@ def remove_sample(self, sample_id: str) -> None: self.project.samples.pop(sample_id) self.song.clear_sample_references(sample_id) self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def duplicate_sample(self, sample_id: str) -> Sample: """Appends an independent copy of a sample (same name and loop flag). @@ -214,7 +243,7 @@ def duplicate_sample(self, sample_id: str) -> Sample: clone = self.project.samples[sample_id].clone() self.project.samples.append(clone) self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) return clone def move_sample(self, sample_id: str, to_index: int) -> None: @@ -226,13 +255,13 @@ def move_sample(self, sample_id: str, to_index: int) -> None: """ self.project.samples.move(sample_id, to_index) self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def add_pattern(self, generator: GeneratorName) -> int: index = self.song.add_pattern(generator) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) return index def clone_pattern( @@ -242,7 +271,7 @@ def clone_pattern( ) -> int: clone_index = self.song.clone_pattern(generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) return clone_index def remove_pattern( @@ -252,7 +281,7 @@ def remove_pattern( ) -> None: self.song.remove_pattern(generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def _clamp_transpose(self, transpose: Optional[int]) -> Optional[int]: if transpose is None: @@ -315,7 +344,7 @@ def set_row( row, ) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def update_row( self, @@ -372,12 +401,12 @@ def clear_row( def append_frame(self) -> None: self.song.append_frame() self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def insert_frame(self, position: int) -> None: self.song.insert_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def set_order_entry( self, @@ -387,41 +416,89 @@ def set_order_entry( ) -> None: self.song.set_order_entry(position, generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def remove_frame(self, position: int) -> None: self.song.remove_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def move_frame(self, from_position: int, to_position: int) -> None: self.song.move_frame(from_position, to_position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def duplicate_frame(self, position: int) -> None: self.song.duplicate_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def clone_frame(self, position: int) -> None: self.song.clone_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def clear_frame(self, position: int) -> None: self.song.clear_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) - def _touch(self) -> None: - """Stamps the project as modified and signals the mutation to the history. + def _begin_batch(self) -> None: + if self._batch is None: + self._batch = MutationBatch() + return + + self._batch.depth += 1 - ``on_mutation`` is invoked through a direct ``None`` check so mutations stay - silent in history-free contexts (tests, tools), where the hook is intentionally - unwired and :meth:`CallbackMixin.call` would log a warning for each one. + def _flush_batch(self) -> None: + """Delivers the outermost batch's stamp and announcements, each exactly once. + + The batch is closed before anything is delivered, so a subscriber that reads + the project — or mutates it further — sees a controller that notifies + immediately again. """ + if self._batch is None: + return + + self._batch.depth -= 1 + if self._batch.depth > 0: + return + + batch = self._batch + self._batch = None + if batch.stamped: + self._stamp() + + for announcement in batch.announcements: + self.call(announcement) + + def _announce(self, announcement: Optional[VoidCallback]) -> None: + """Signals a change to its subscribers, or keeps it for the open batch's flush.""" + if self._batch is not None: + self._batch.record(announcement) + return + + self.call(announcement) + + def _stamp(self) -> None: + """Records the project as carrying unsaved changes, once per batch while one is open.""" + if self._batch is not None: + self._batch.stamped = True + return + self.project.info.touch() self._project_manager.mark_updated() + + def _touch(self) -> None: + """Stamps the project as modified and signals the mutation to the history. + + ``on_mutation`` fires for every mutation as it lands, batch or no batch, so the + history keeps seeing each one inside the transaction that caused it — that + immediacy is what its completeness check rests on. It is invoked through a + direct ``None`` check so mutations stay silent in history-free contexts (tests, + tools), where the hook is intentionally unwired and :meth:`CallbackMixin.call` + would log a warning for each one. + """ + self._stamp() if self.on_mutation is not None: self.on_mutation() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 498e7f2c..798ebb45 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -758,9 +758,15 @@ def test_label_is_absent_without_a_selection( @pytest.fixture def history_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the history collaborator wired.""" + """A coordinator with the two collaborators an undoable gesture reaches. + + The history is a mock, so a test reads the transaction a gesture opens; the + controller is real, so a test reads the notifications the gesture's mutations + actually produce. + """ instance = object.__new__(SequencerTabCoordinator) instance._history = MagicMock() + instance._project_controller = ProjectController(ProjectManager()) return instance @@ -1223,6 +1229,25 @@ def test_wrapped_call_passes_computed_coalesce_key( coalesce=("tempo",), ) + def test_wrapped_call_announces_one_song_change_for_the_whole_gesture( + self, + history_coordinator: SequencerTabCoordinator, + ) -> None: + controller = history_coordinator._project_controller + announcements: List[str] = [] + controller.on_song_changed = lambda: announcements.append("song") + initial_length = controller.order_length + + def append_frames(count: int) -> None: + for _ in range(count): + controller.append_frame() + + wrapped = history_coordinator._undoable(HistoryAction.EDIT_ROW, append_frames) + wrapped(3) + + assert controller.order_length == initial_length + 3 + assert announcements == ["song"] + @pytest.fixture def view_coordinator() -> SequencerTabCoordinator: diff --git a/tests/unit/sampletones_application/logic/history/test_manager.py b/tests/unit/sampletones_application/logic/history/test_manager.py index 0e18b1cc..dc6cc06f 100644 --- a/tests/unit/sampletones_application/logic/history/test_manager.py +++ b/tests/unit/sampletones_application/logic/history/test_manager.py @@ -92,6 +92,22 @@ def test_nested_transactions_coalesce_into_one_entry( assert len(history.entries) == 2 assert history.entries[-1].action is HistoryAction.ADD_SAMPLE + def test_batched_edit_commits_one_entry( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + original = controller.project.settings.tempo + + with history.transaction(HistoryAction.SET_TEMPO), controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + + assert len(history.entries) == 2 + + history.undo() + assert controller.project.settings.tempo == original + def test_exception_inside_transaction_commits_partial_gesture( self, history_factory: HistoryFactory, @@ -447,6 +463,16 @@ def test_untracked_mutation_raises_under_strict( with pytest.raises(UntrackedMutationError): controller.set_tempo(120) + def test_batched_mutation_outside_a_transaction_raises_under_strict( + self, + history_factory: HistoryFactory, + ) -> None: + """A batch defers the notifications a gesture raises, never the mutations it records.""" + controller, _ = history_factory(strict=True) + + with pytest.raises(UntrackedMutationError), controller.batch(): + controller.set_tempo(120) + def test_untracked_mutation_self_heals_when_lenient( self, history_factory: HistoryFactory, diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 4f0c996a..bde1c642 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -2,6 +2,7 @@ from typing import Callable, List import numpy as np +import pytest from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -583,3 +584,127 @@ def test_in_place_reconstruction_edit_is_visible_through_project( stored = controller.project.sample(sample.id).reconstruction assert stored.get_generator_instructions(GeneratorName.PULSE1) == new_instructions + + +class TestBatch: + def test_a_batch_announces_one_song_change_for_many_rows(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_song_changed = lambda: emitted.append("song") + pattern_index = controller.song.order[0][GeneratorName.PULSE1] + + with controller.batch(): + for row_index in range(8): + controller.set_row( + GeneratorName.PULSE1, + pattern_index, + row_index, + volume=15, + ) + + assert emitted == ["song"] + + def test_a_mutation_applies_before_its_announcement_arrives(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + assert controller.project.settings.tempo == 150 + assert emitted == [] + + assert emitted == ["settings"] + + def test_each_kind_of_change_announces_once_in_the_order_it_first_arose(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + controller.on_song_changed = lambda: emitted.append("song") + controller.on_info_changed = lambda: emitted.append("info") + + with controller.batch(): + controller.set_tempo(150) + controller.append_frame() + controller.set_title("Demo") + controller.set_speed(4) + controller.append_frame() + + assert emitted == ["settings", "song", "info"] + + def test_the_dirty_stamp_lands_once_for_the_whole_batch(self) -> None: + project_manager = ProjectManager() + controller = ProjectController(project_manager) + stamps: List[str] = [] + project_manager.session.on_state_changed = lambda: stamps.append("state") + + with controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + assert controller.is_dirty is False + + assert stamps == ["state"] + assert controller.is_dirty is True + + def test_every_mutation_signals_the_history_as_it_lands(self) -> None: + controller = _controller() + mutations: List[str] = [] + controller.on_mutation = lambda: mutations.append("mutation") + + with controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + assert mutations == ["mutation", "mutation"] + + assert mutations == ["mutation", "mutation"] + + def test_nested_batches_announce_on_the_outermost_exit(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + with controller.batch(): + controller.set_speed(4) + + assert emitted == [] + + assert emitted == ["settings"] + + def test_a_batch_that_raises_still_announces_what_landed(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with pytest.raises(RuntimeError), controller.batch(): + controller.set_tempo(150) + raise RuntimeError("boom") + + assert emitted == ["settings"] + assert controller.project.settings.tempo == 150 + assert controller.is_dirty is True + + def test_a_batch_without_mutations_announces_nothing(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + controller.on_song_changed = lambda: emitted.append("song") + + with controller.batch(): + pass + + assert emitted == [] + assert controller.is_dirty is False + + def test_announcements_resume_immediately_after_a_batch(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + + controller.set_speed(4) + + assert emitted == ["settings", "settings"] From dfb9ddc1b4ca6266ab01ad20bcee5b51aecb5e69 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 20:59:14 +0200 Subject: [PATCH 07/28] Added: tracker/order selection --- .../categories/elements/settings.py | 12 + .../layout/tabs/sequencer/tracker/tracker.py | 8 +- .../logic/sequencer/{ => tracker}/tracker.py | 0 src/sampletones_application/tags/general.py | 1 + .../ui/elements/table/cells.py | 11 + .../ui/elements/table/drag.py | 21 ++ .../ui/panels/sequencer/input/order.py | 87 ++++- .../ui/panels/sequencer/input/state.py | 98 ++++- .../ui/panels/sequencer/order.py | 230 +++++++++++- .../ui/panels/sequencer/tracker.py | 253 ++++++++++++- .../ui/themes/dpg_constants.py | 1 + .../ui/themes/inline.py | 19 + .../utils/gui/shortcuts/ids.py | 24 ++ .../view_model/sequencer/region.py | 121 ++++++ .../keybindings/default.yaml | 12 + src/sampletones_config/keybindings/macos.yaml | 12 + src/sampletones_config/lang/en.yaml | 12 + .../layout/tabs/sequencer/tracker.yaml | 2 + src/sampletones_config/palettes/dark.yaml | 1 + src/sampletones_config/palettes/light.yaml | 1 + src/sampletones_config/palettes/studio.yaml | 1 + .../theme/tables/order.yaml | 5 + .../theme/tables/pattern.yaml | 15 +- .../sequencer/{ => tracker}/test_tracker.py | 0 .../sequencer/input/test_order_input.py | 70 ++++ .../sequencer/input/test_tracker_input.py | 97 +++++ .../ui/panels/sequencer/test_panel_escape.py | 30 ++ .../panels/sequencer/test_selection_drag.py | 355 ++++++++++++++++++ .../panels/sequencer/test_selection_keys.py | 181 +++++++++ .../utils/gui/shortcuts/test_scheme.py | 9 +- .../view_model/sequencer/test_region.py | 92 +++++ 31 files changed, 1740 insertions(+), 41 deletions(-) rename src/sampletones_application/logic/sequencer/{ => tracker}/tracker.py (100%) create mode 100644 src/sampletones_application/ui/elements/table/drag.py create mode 100644 src/sampletones_application/view_model/sequencer/region.py rename tests/unit/sampletones_application/logic/sequencer/{ => tracker}/test_tracker.py (100%) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py create mode 100644 tests/unit/sampletones_application/view_model/sequencer/test_region.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index f9da5d03..8b73997b 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -89,6 +89,12 @@ class KeybindingActionElements(AbstractElement): ORDER_NEXT_CHANNEL = "order_next_channel" ORDER_FIRST_POSITION = "order_first_position" ORDER_LAST_POSITION = "order_last_position" + ORDER_EXTEND_SELECTION_UP = "order_extend_selection_up" + ORDER_EXTEND_SELECTION_DOWN = "order_extend_selection_down" + ORDER_EXTEND_SELECTION_LEFT = "order_extend_selection_left" + ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" + ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" + ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" ORDER_MOVE_FRAME_LEFT = "order_move_frame_left" ORDER_MOVE_FRAME_RIGHT = "order_move_frame_right" ORDER_MOVE_FRAME_TO_START = "order_move_frame_to_start" @@ -111,6 +117,12 @@ class KeybindingActionElements(AbstractElement): TRACKER_NEXT_COLUMN = "tracker_next_column" TRACKER_FIRST_ROW = "tracker_first_row" TRACKER_LAST_ROW = "tracker_last_row" + TRACKER_EXTEND_SELECTION_UP = "tracker_extend_selection_up" + TRACKER_EXTEND_SELECTION_DOWN = "tracker_extend_selection_down" + TRACKER_EXTEND_SELECTION_LEFT = "tracker_extend_selection_left" + TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" + TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" + TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 27b8023d..07c908cd 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -4,14 +4,20 @@ class TrackerLayout(BaseModel, extra="forbid", frozen=True): - """The tracker's row counts, column widths and tint strengths. + """The tracker's row counts, cell sizes and tint strengths. The grouping the rows are tinted by is the project's own metre, read from its highlights, so this model carries the geometry alone. + + A row states its height rather than growing to the text in it, because the grid's tints are + drawn by the cells: a cell that stands exactly as tall as its row lets a selection, a hover + and the cursor cover the row edge to edge. """ rows: int page_size: int + row_height: int + header_height: int subcolumn_widths: SubcolumnWidths channel_column_tint: float muted_text_fraction: float diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py similarity index 100% rename from src/sampletones_application/logic/sequencer/tracker.py rename to src/sampletones_application/logic/sequencer/tracker/tracker.py diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 7cd592fb..d1814485 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -668,6 +668,7 @@ SUF_HANDLER_NODE = compose_tag("handler", "node") SUF_HANDLER_DETAIL_TOOLTIP = compose_tag("handler", "detail_tooltip") SUF_HANDLER_HEADER = compose_tag("handler", "header") +SUF_HANDLER_DRAG = compose_tag("handler", "drag") SUF_LABEL = "label" SUF_PATH = "path" SUF_TEXT = "text" diff --git a/src/sampletones_application/ui/elements/table/cells.py b/src/sampletones_application/ui/elements/table/cells.py index 379ad571..7943de02 100644 --- a/src/sampletones_application/ui/elements/table/cells.py +++ b/src/sampletones_application/ui/elements/table/cells.py @@ -35,6 +35,7 @@ class EditableCells(Generic[KeyT]): def __init__(self) -> None: self._widgets: Dict[KeyT, Sender] = {} + self._keys: Dict[Sender, KeyT] = {} self._values: Dict[KeyT, str] = {} @property @@ -44,14 +45,24 @@ def values(self) -> Dict[KeyT, str]: def reset(self, values: Dict[KeyT, str]) -> None: """Drops the widget references and reseeds the value cache for a rebuild.""" self._widgets = {} + self._keys = {} self._values = dict(values) def register(self, key: KeyT, widget: Sender) -> None: self._widgets[key] = widget + self._keys[widget] = key def widget(self, key: KeyT) -> Optional[Sender]: return self._widgets.get(key) + def key(self, widget: Sender) -> Optional[KeyT]: + """The cell a widget stands for, which is what a handler reporting an item needs. + + DearPyGui hands an item handler the widget it fired for, so the cache is read from + both sides: a panel looks a widget up here rather than reading its user data back. + """ + return self._keys.get(widget) + def reconcile(self, values: Dict[KeyT, str], render: Callable[[KeyT], str]) -> None: """Updates only the cells whose label changed since the last reconcile.""" for key, value in values.items(): diff --git a/src/sampletones_application/ui/elements/table/drag.py b/src/sampletones_application/ui/elements/table/drag.py new file mode 100644 index 00000000..bde55c5e --- /dev/null +++ b/src/sampletones_application/ui/elements/table/drag.py @@ -0,0 +1,21 @@ +from collections.abc import Hashable +from dataclasses import dataclass +from typing import Generic, TypeVar + +KeyT = TypeVar("KeyT", bound=Hashable) + + +@dataclass +class DragGesture(Generic[KeyT]): + """The press a drag selection grows from. + + ``origin`` is the cell the button went down on, which is the end a plain drag anchors its + selection at. ``extends`` records that the press held Shift, so the drag carries the + selection already on the grid instead of starting a new one. ``moved`` states that the + pointer has reached another cell, which is what tells a drag apart from a click: until it + is set, the press is still a click and the selection is left alone. + """ + + origin: KeyT + extends: bool + moved: bool = False diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 868f2910..1c2b6a01 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -5,6 +5,7 @@ from pydantic.dataclasses import dataclass from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import OrderRegion from sampletones_core.constants.enums import GeneratorName INDEX_DIGITS: Final[int] = 2 @@ -25,18 +26,85 @@ def _parse(pending: str) -> Optional[int]: @dataclass class OrderInputState: - """Edit cursor and pending hex entry for the order table. + """Edit cursor, pending hex entry and selection anchor for the order table. The order has no subcolumns, so a cell holds a single pattern index; typing accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index. - Navigation moves along positions (columns) or channels/master (rows). + Navigation moves along positions (columns) or channels/master (rows). The anchor is where a + range selection was started and the cursor is its other end, so the two together are the + block a copy or a paste acts on. """ cursor: Optional[OrderCursor] = None pending: str = "" + anchor: Optional[OrderCursor] = None def reset_pending(self) -> OrderInputState: - return OrderInputState(cursor=self.cursor, pending="") + """Drops a partial entry, leaving the cursor and any selection where they stand. + + The anchor survives because this runs before every move, the extending ones included: + each gesture then decides whether to hold the selection or collapse it. + """ + return OrderInputState(cursor=self.cursor, pending="", anchor=self.anchor) + + def collapse(self) -> OrderInputState: + """Drops the selection, leaving the cursor's own cell as the whole target.""" + return OrderInputState(cursor=self.cursor, pending=self.pending) + + @property + def region(self) -> Optional[OrderRegion]: + """The block a selection covers, once one has been started.""" + if self.cursor is None or self.anchor is None: + return None + + anchor_row = CHANNEL_AXIS.index(self.anchor.generator) + cursor_row = CHANNEL_AXIS.index(self.cursor.generator) + return OrderRegion( + first_row=min(anchor_row, cursor_row), + last_row=max(anchor_row, cursor_row), + first_position=min(self.anchor.position, self.cursor.position), + last_position=max(self.anchor.position, self.cursor.position), + ) + + def extend_to(self, cursor: OrderCursor) -> OrderInputState: + """Carries the moving end of the selection to ``cursor``, anchoring it where it began. + + A selection that has not been started yet takes the cell the cursor stands on as its + anchor, so the first extending gesture selects the cell it came from as well as the one + it reaches. + """ + return OrderInputState( + cursor=cursor, + pending="", + anchor=self.anchor if self.anchor is not None else self.cursor, + ) + + def extend_position( + self, + value: int, + position_count: int, + absolute: bool = False, + ) -> OrderInputState: + """Carries the selection's moving end to another position of the same row.""" + if self.cursor is None or position_count == 0: + return self + + new_position = value if absolute else self.cursor.position + value + new_position = max(0, min(new_position, position_count - 1)) + return self.extend_to(OrderCursor(self.cursor.generator, new_position)) + + def extend_channel(self, value: int) -> OrderInputState: + """Carries the selection's moving end across the channel axis, stopping at either end. + + A selection covers a run of the table, so the walk stops at the master row and at the last + channel rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = CHANNEL_AXIS.index(self.cursor.generator) + row = max(0, min(current + value, len(CHANNEL_AXIS) - 1)) + return self.extend_to(OrderCursor(CHANNEL_AXIS[row], self.cursor.position)) def navigate_position( self, @@ -73,7 +141,15 @@ def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]: if len(pending) < INDEX_DIGITS: return OrderInputState(cursor=self.cursor, pending=pending), None - return self.reset_pending(), _parse(pending) + return self._after_entry(), _parse(pending) + + def _after_entry(self) -> OrderInputState: + """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. + + Typing writes the one cell the cursor stands on, so it takes the selection down to that + cell instead of leaving a range for the next gesture to act on. + """ + return self.collapse().reset_pending() def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: if not self.pending or self.cursor is None: @@ -82,4 +158,5 @@ def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS)) def cancel(self) -> OrderInputState: - return self.reset_pending() + """Drops a partial entry and any selection, which is what Escape asks of the table.""" + return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index d642e07a..51e4f2ef 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -10,6 +10,7 @@ ClearAction, EditAction, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import ( SLOT_COUNT, SUBCOLUMNS, @@ -65,11 +66,89 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: @dataclass class TrackerInputState: + """Edit cursor, pending entry and selection anchor for the tracker grid. + + The anchor is where a range selection was started; the cursor is its other end, so the two + together are the region a block operation acts on. Every plain move builds a state without + one, which is what makes a move collapse a selection to the cell it lands in. + """ + cursor: Optional[TrackerCursor] = None pending: str = "" + anchor: Optional[TrackerCursor] = None def reset_pending(self) -> TrackerInputState: - return TrackerInputState(cursor=self.cursor, pending="") + """Drops a partial entry, leaving the cursor and any selection where they stand. + + The anchor survives because this runs before every move, the extending ones included: + each gesture then decides whether to hold the selection or collapse it. + """ + return TrackerInputState(cursor=self.cursor, pending="", anchor=self.anchor) + + def collapse(self) -> TrackerInputState: + """Drops the selection, leaving the cursor's own cell as the whole target.""" + return TrackerInputState(cursor=self.cursor, pending=self.pending) + + @property + def region(self) -> Optional[TrackerRegion]: + """The block a selection covers, once one has been started.""" + if self.cursor is None or self.anchor is None: + return None + + anchor_slot = TrackerSlot(self.anchor.generator, self.anchor.subcolumn).flat_index + cursor_slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + return TrackerRegion( + first_row=min(self.anchor.row, self.cursor.row), + last_row=max(self.anchor.row, self.cursor.row), + first_slot=min(anchor_slot, cursor_slot), + last_slot=max(anchor_slot, cursor_slot), + ) + + def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: + """Carries the moving end of the selection to ``cursor``, anchoring it where it began. + + A selection that has not been started yet takes the cell the cursor stands on as its + anchor, so the first extending gesture selects the cell it came from as well as the one + it reaches. + """ + return TrackerInputState( + cursor=cursor, + pending="", + anchor=self.anchor if self.anchor is not None else self.cursor, + ) + + def extend_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + """Carries the selection's moving end to another row of the same slot.""" + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return self.extend_to( + TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ) + ) + + def extend_slot(self, value: int) -> TrackerInputState: + """Carries the selection's moving end along the flat slot axis, stopping at either end. + + A selection covers a run of the grid, so the walk stops at the first and the last slot + rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) + return self.extend_to(TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn)) def navigate_row( self, @@ -146,7 +225,7 @@ def type_char( return self, None if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: - return self.reset_pending(), self._note_off_action(self.cursor) + return self._after_entry(), self._note_off_action(self.cursor) if self.cursor.subcolumn is SubColumn.TRANSPOSE: return self._type_transpose_char(char) @@ -160,7 +239,15 @@ def type_char( return TrackerInputState(cursor=self.cursor, pending=pending), None action = _parse(self.cursor, pending) - return self.reset_pending(), action + return self._after_entry(), action + + def _after_entry(self) -> TrackerInputState: + """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. + + Typing writes the one cell the cursor stands on, so it takes the selection down to that + cell instead of leaving a range for the next gesture to act on. + """ + return self.collapse().reset_pending() def _note_off_action(self, cursor: TrackerCursor) -> EditAction: return EditAction( @@ -203,7 +290,7 @@ def _type_transpose_char( return TrackerInputState(cursor=self.cursor, pending=pending), None action = _parse(self.cursor, pending) - return self.reset_pending(), action + return self._after_entry(), action def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: if not self.pending or self.cursor is None: @@ -234,4 +321,5 @@ def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: return self.reset_pending(), action def cancel(self) -> TrackerInputState: - return self.reset_pending() + """Drops a partial entry and any selection, which is what Escape asks of the grid.""" + return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 3e497806..ec7a9917 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Final, Optional, Tuple +from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -12,6 +12,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_DRAG, SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY, ) @@ -36,6 +37,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label +from sampletones_application.ui.elements.table.drag import DragGesture from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -60,7 +62,7 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -132,12 +134,15 @@ def __init__( self._position_count: int = 0 self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() + self._selection: FrozenSet[OrderKey] = frozenset() + self._drag: Optional[DragGesture[OrderKey]] = None self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None self._playing_position: Optional[int] = None self._cell_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_REGISTRY) self._label_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_HEADER) + self._drag_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_DRAG) self._label_rows: Dict[Sender, Optional[GeneratorName]] = {} self._entry_theme: int = 0 self._muted_entry_theme: int = 0 @@ -311,10 +316,17 @@ def _register_handlers(self) -> None: with dpg.item_handler_registry(tag=self._cell_handler_tag): dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) with dpg.item_handler_registry(tag=self._label_handler_tag): dpg.add_item_clicked_handler(callback=self._on_label_right_clicked) + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + def update_order(self, view_model: SequencerOrderTrackerViewModel) -> None: """Reconciles the order table; rebuilds only when the position count changes.""" cell_values = self._compute_cell_values(view_model) @@ -365,6 +377,7 @@ def deselect_cell(self) -> None: self._clear_cursor_highlight() self._clear_column_highlight() self._input_state = OrderInputState() + self._repaint_selection() self._update_caret() if cursor is not None: @@ -446,11 +459,15 @@ def _rebuild_table( state (and any highlight keyed by column index) dangling, which corrupted the heap. Replacing the table item wholesale sidesteps that: the cursor and column highlights die with the old table, so nothing references freed - columns. + columns. The selected cells go with them, and :meth:`_restore_cursor` brings the + cursor back on its own — a table of another width is a table a region no longer + describes. """ dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) self._highlighted = None self._highlighted_column = None + self._selection = frozenset() + self._drag = None self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -702,6 +719,34 @@ def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: ) self._highlighted = cursor + def _selected_cells(self) -> FrozenSet[OrderKey]: + """Every cell the selection covers, clipped to the positions the table holds.""" + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[OrderKey] = set() + for generator in region.generators: + for position in region.positions: + if position < self._position_count: + keys.add((generator, position)) + + return frozenset(keys) + + def _repaint_selection(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left. + + A selected cell is drawn by the selectable's own selected state, which the order table's + theme colours, so a repaint reaches only the cells whose membership actually changed. + """ + selected = self._selected_cells() + for key in self._selection ^ selected: + widget = self._order.widget(key) + if widget is not None: + dpg.set_value(widget, key in selected) + + self._selection = selected + def _clear_cursor_highlight(self) -> None: if self._highlighted is None: return @@ -768,6 +813,7 @@ def _apply_state( if old is None or old.position != new.position: self.call(self.on_frame_selected, new.position) + self._repaint_selection() self._update_caret() self._refresh_remove_enabled() @@ -800,17 +846,126 @@ def _on_cell_clicked( _app_data: bool, user_data: OrderKey, ) -> None: + """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. + + The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is + released here and its membership dropped: the repaint that follows is what states whether + the cell the user clicked belongs to the selection. + + A drag that comes back to the cell it started from ends on a click, and that click is the + end of the drag rather than a gesture of its own, so it leaves the selection standing. + """ dpg.set_value(sender, False) - self._committed_state() + self._selection -= {user_data} + if self._drag is not None and self._drag.moved: + self._drag = None + self._repaint_selection() + return + + state = self._committed_state() generator, position = user_data - self._apply_state( - OrderInputState( - cursor=OrderCursor( - generator, - position, - ) + cursor = OrderCursor(generator, position) + if Modifier.SHIFT in capture_modifiers(): + self._apply_state(state.extend_to(cursor)) + return + + self._apply_state(OrderInputState(cursor=cursor)) + + def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: + """Carries the selection to the cell under a held pointer, which is what drags a range out. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the table's own geometry while the held cell names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._drag is None: + origin = self._order.key(app_data) + if origin is None: + return + + self._drag = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), ) - ) + return + + reached = self._cell_at() + if reached is None or (reached == self._drag.origin and not self._drag.moved): + return + + self._drag.moved = True + state = self._committed_state() + if not self._drag.extends: + state = OrderInputState(cursor=OrderCursor(*self._drag.origin)) + + self._apply_state(state.extend_to(OrderCursor(*reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._drag = None + + def _cell_at(self) -> Optional[OrderKey]: + """The cell the pointer stands on, clamped to the table the order lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last channel or the last position selects up to it rather than stopping there. + """ + left, top = dpg.get_mouse_pos(local=False) + position = self._position_at(left) + if position is None: + return None + + return (self._generator_at(top), position) + + def _generator_at(self, top: float) -> Optional[GeneratorName]: + """Which channel row stands at a height, the master row reading ``None``. + + The master row stands apart from the channels beneath it, so the walk asks each row where + it was drawn and takes the first one reaching past the pointer. + """ + for generator in CHANNEL_AXIS: + widget = self._order.widget((generator, 0)) + if widget is None: + continue + + _, row_top = dpg.get_item_rect_min(widget) + _, row_height = dpg.get_item_rect_size(widget) + if top < row_top + row_height: + return generator + + return CHANNEL_AXIS[-1] + + def _position_at(self, left: float) -> Optional[int]: + """Which position stands at a width, counted from the first cell's left edge. + + Every position column is the same width, so the count is arithmetic once two of them + state the pitch; an order of a single position holds every width there is. + """ + first = self._cell_left(0) + if first is None: + return None + + following = self._cell_left(1) + if following is None: + return 0 + + position = int((left - first) // (following - first)) + return max(0, min(position, self._position_count - 1)) + + def _cell_left(self, position: int) -> Optional[float]: + """Where a position column's cells begin, in the coordinates the viewport is drawn in.""" + widget = self._order.widget((None, position)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + return float(cell_left) def _on_cell_right_clicked( self, @@ -974,6 +1129,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._move_cursor(shortcut_id): return True + if self._extend_selection(shortcut_id): + return True + if self._edit_cell(shortcut_id): return True @@ -999,12 +1157,36 @@ def _move_cursor(self, shortcut_id: ShortcutId) -> bool: return True + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.ORDER_EXTEND_SELECTION_UP: + self._extend_channel(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_DOWN: + self._extend_channel(1) + case ShortcutId.ORDER_EXTEND_SELECTION_LEFT: + self._extend_position(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_RIGHT: + self._extend_position(1) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_FIRST_POSITION: + self._extend_to_position(0) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_LAST_POSITION: + self._extend_to_position(self._position_count - 1) + case _: + return False + + return True + def _edit_cell(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. - A cancel with nothing typed leaves the press to the application, so Escape stops playback - while the table holds a cursor. + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the table holds a cursor. """ match shortcut_id: case ShortcutId.ORDER_CLEAR_CELL: @@ -1014,7 +1196,7 @@ def _edit_cell(self, shortcut_id: ShortcutId) -> bool: self._clear_cell() self._move_position(-1) case ShortcutId.ORDER_CANCEL_ENTRY: - if not self._input_state.pending: + if not self._input_state.pending and self._input_state.anchor is None: return False self._apply_state(self._input_state.cancel()) @@ -1075,6 +1257,26 @@ def _jump_position(self, index: int) -> None: def _move_channel(self, delta: int) -> None: self._apply_state(self._committed_state().navigate_channel(delta)) + def _extend_position(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_position( + delta, + self._position_count, + ), + ) + + def _extend_to_position(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_position( + index, + self._position_count, + absolute=True, + ), + ) + + def _extend_channel(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_channel(delta)) + def _committed_state(self) -> OrderInputState: state, index = self._input_state.commit_partial() if index is not None: diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index f54d0e28..30277167 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Final, Optional, Tuple +from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -10,6 +10,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_DRAG, SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY, ) @@ -29,6 +30,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragGesture from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -55,6 +57,7 @@ from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, + create_label_selectable_theme, create_selectable_text_theme, ) from sampletones_application.ui.themes.registry import ThemeRegistry @@ -67,7 +70,7 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -83,6 +86,11 @@ from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( SequencerRowViewModel, @@ -142,6 +150,7 @@ def __init__( self._item_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_PANEL, SUF_HANDLER_REGISTRY) self._cell_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_REGISTRY) self._header_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_HEADER) + self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG) self._rows: Dict[Optional[int], Sender] = {} self._header_columns: Dict[Sender, Optional[GeneratorName]] = {} @@ -154,11 +163,14 @@ def __init__( self._painted_row: Optional[int] = None self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() + self._selection: FrozenSet[CellKey] = frozenset() + self._drag: Optional[DragGesture[CellKey]] = None self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 self._header_theme: int = 0 self._muted_header_theme: int = 0 + self._column_label_theme: int = 0 self._current_samples: Optional[SequencerSamplesViewModel] = None self._current_channels: Optional[SequencerChannelsViewModel] = None @@ -284,10 +296,17 @@ def _setup_handlers(self) -> None: with dpg.item_handler_registry(tag=self._cell_handler_tag): dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) with dpg.item_handler_registry(tag=self._header_handler_tag): dpg.add_item_clicked_handler(callback=self._on_header_right_clicked) + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + self._router.register( self._on_key_pressed, priority=PRIORITY_PANEL, @@ -338,6 +357,7 @@ def _create_header_themes(self) -> None: header.hovered, header.active, ) + self._column_label_theme = create_label_selectable_theme(self._layout.colors.label) def _create_tracker_view(self, parent: str) -> None: """Builds the tracker card and the empty table its rows are filled into. @@ -445,8 +465,14 @@ def _rebuild_table( A table repopulated this frame reports the scroll extent of the body it replaced, so the reveal is repeated a frame later, when DearPyGui has measured the rows now in it. + + The frame the grid stands on has a row count of its own, so a selection is taken down to + its cursor: the cells it covered belong to the body being replaced. """ dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) + self._input_state = self._input_state.collapse() + self._selection = frozenset() + self._drag = None self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -649,9 +675,18 @@ def _build_header_row(self) -> None: self._add_header_selectable(row_id, generator) def _add_header_label_cell(self, row_id: Sender) -> None: - """Places the row-number column's label, which names a column the user reads only.""" + """Places the row-number column's label, which names a column the user reads only. + + It is laid out as a selectable like the labels beside it, so it takes the header's + height and sits on their line; its own theme leaves it reading as text. + """ label_cell = dpg.add_table_cell(parent=row_id) - dpg.add_text(self._lbl_col_row, parent=label_cell) + label = dpg.add_selectable( + parent=label_cell, + label=self._lbl_col_row, + height=self._layout.tracker.header_height, + ) + dpg.bind_item_theme(label, self._column_label_theme) def _add_header_selectable( self, @@ -669,6 +704,7 @@ def _add_header_selectable( selectable = dpg.add_selectable( parent=header_cell, label=self._column_labels[generator], + height=self._layout.tracker.header_height, user_data=generator, callback=self._on_header_clicked, ) @@ -706,6 +742,7 @@ def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None: selectable = dpg.add_selectable( parent=number_cell, label=display_id(row_index), + height=self._layout.tracker.row_height, user_data=row_index, callback=self._on_row_number_clicked, ) @@ -749,6 +786,7 @@ def _add_subcolumn_selectable( parent=group, label=self._render_cell(key), width=self._subcolumn_widths[subcolumn], + height=self._layout.tracker.row_height, user_data=key, callback=self._on_cell_clicked, ) @@ -765,6 +803,7 @@ def _update_cursor(self) -> None: else: self._input_state = TrackerInputState() + self._repaint_selection() self._update_caret() def deselect_cell(self) -> None: @@ -772,6 +811,7 @@ def deselect_cell(self) -> None: if cursor is not None: self._input_state = TrackerInputState() self._remove_cell_highlight(cursor.row, cursor.generator) + self._repaint_selection() self._update_caret() @@ -798,6 +838,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: if new_pos != old_pos and new_cursor is not None: self.call(self.on_cell_selected) + self._repaint_selection() self._update_caret() def update_samples(self, view_model: SequencerSamplesViewModel) -> None: @@ -968,6 +1009,40 @@ def _apply_cell_highlight( color=self._layout.colors.cell_cursor.rgba, ) + def _selected_cells(self) -> FrozenSet[CellKey]: + """Every cell the selection covers, clipped to the rows the shown frame holds. + + A region names rows of the grid rather than widgets, so a row past the end of a shorter + frame is left out: the selection reaches as far as the pattern does. + """ + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[CellKey] = set() + for row_index in region.rows: + if row_index >= self._current_row_count: + continue + + for slot in region.slots: + keys.add((row_index, slot.generator, slot.subcolumn)) + + return frozenset(keys) + + def _repaint_selection(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left. + + A selected cell is drawn by the selectable's own selected state, which the pattern table's + theme colours, so a repaint reaches only the cells whose membership actually changed. + """ + selected = self._selected_cells() + for key in self._selection ^ selected: + widget = self._editable_cells.widget(key) + if widget is not None: + dpg.set_value(widget, key in selected) + + self._selection = selected + def _remove_cell_highlight( self, row_index: int, @@ -991,14 +1066,118 @@ def _on_cell_clicked( _app_data: bool, user_data: Tuple[int, Optional[GeneratorName], SubColumn], ) -> None: + """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. + + The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is + released here and its membership dropped: the repaint that follows is what states whether + the cell the user clicked belongs to the selection. + + A drag that comes back to the cell it started from ends on a click, and that click is the + end of the drag rather than a gesture of its own, so it leaves the selection standing. + """ dpg.set_value(sender, False) - self._committed_state() + self._selection -= {user_data} + if self._drag is not None and self._drag.moved: + self._drag = None + self._repaint_selection() + return + + state = self._committed_state() row_index, generator, subcolumn = user_data - new_state = TrackerInputState( - cursor=TrackerCursor(row_index, generator, subcolumn), - pending="", - ) - self._apply_state(new_state) + cursor = TrackerCursor(row_index, generator, subcolumn) + if Modifier.SHIFT in capture_modifiers(): + self._apply_state(state.extend_to(cursor)) + return + + self._apply_state(TrackerInputState(cursor=cursor, pending="")) + + def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: + """Carries the selection to the cell under a held pointer, which is what drags a range out. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the grid's own geometry while the held cell names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._drag is None: + origin = self._editable_cells.key(app_data) + if origin is None: + return + + self._drag = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), + ) + return + + reached = self._cell_at() + if reached is None or (reached == self._drag.origin and not self._drag.moved): + return + + self._drag.moved = True + state = self._committed_state() + if not self._drag.extends: + state = TrackerInputState(cursor=TrackerCursor(*self._drag.origin)) + + self._apply_state(state.extend_to(TrackerCursor(*reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._drag = None + + def _cell_at(self) -> Optional[CellKey]: + """The cell the pointer stands on, clamped to the grid the shown frame lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last row or the last column selects up to it rather than stopping where the grid ends. + """ + left, top = dpg.get_mouse_pos(local=False) + row_index = self._row_at(top) + slot = self._slot_at(left) + if row_index is None or slot is None: + return None + + return (row_index, slot.generator, slot.subcolumn) + + def _row_at(self, top: float) -> Optional[int]: + """Which pattern row stands at a height, counted from the first row's top edge. + + Every row is the height the layout states, so the count is arithmetic: the rows the + grid holds are evenly pitched whether or not they are scrolled into view. + """ + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + row_index = int((top - first) // self._layout.tracker.row_height) + return max(0, min(row_index, self._current_row_count - 1)) + + def _slot_at(self, left: float) -> Optional[TrackerSlot]: + """Which subcolumn stands at a width, taken from where the first row's cells are drawn. + + The subcolumns differ in width and the columns stand apart, so the walk asks each cell + where it was drawn and takes the first one reaching past the pointer. + """ + if self._current_row_count == 0: + return None + + for index in range(SLOT_COUNT): + slot = slot_from_flat(index) + widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + cell_width, _ = dpg.get_item_rect_size(widget) + if left < cell_left + cell_width: + return slot + + return slot_from_flat(SLOT_COUNT - 1) def _on_header_clicked( self, @@ -1243,6 +1422,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._move_cursor(shortcut_id): return True + if self._extend_selection(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1273,12 +1455,36 @@ def _move_cursor(self, shortcut_id: ShortcutId) -> bool: return True + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.TRACKER_EXTEND_SELECTION_UP: + self._extend_row(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_DOWN: + self._extend_row(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_LEFT: + self._extend_slot(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_RIGHT: + self._extend_slot(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_FIRST_ROW: + self._extend_to_row(0) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_LAST_ROW: + self._extend_to_row(self._current_row_count - 1) + case _: + return False + + return True + def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. - A cancel with nothing typed leaves the press to the application, so Escape stops playback - while the grid holds a cursor. + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the grid holds a cursor. """ match shortcut_id: case ShortcutId.TRACKER_CLEAR_ROW: @@ -1288,7 +1494,7 @@ def _edit_row(self, shortcut_id: ShortcutId) -> bool: self._clear_row() self._move_row(-1) case ShortcutId.TRACKER_CANCEL_ENTRY: - if not self._input_state.pending: + if not self._input_state.pending and self._input_state.anchor is None: return False self._apply_state(self._input_state.cancel()) @@ -1320,6 +1526,27 @@ def _jump_to_row(self, index: int) -> None: ) self._scroll_cursor_into_view() + def _extend_row(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_row( + delta, + self._current_row_count, + ) + ) + + def _extend_to_row(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_row( + index, + self._current_row_count, + absolute=True, + ) + ) + self._scroll_cursor_into_view() + + def _extend_slot(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_slot(delta)) + def _move_subcolumn(self, delta: int) -> None: self._apply_state(self._committed_state().navigate_subcolumn(delta)) diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index 701b0614..5eed2b85 100644 --- a/src/sampletones_application/ui/themes/dpg_constants.py +++ b/src/sampletones_application/ui/themes/dpg_constants.py @@ -103,6 +103,7 @@ "PopupRounding": dpg.mvStyleVar_PopupRounding, "ScrollbarRounding": dpg.mvStyleVar_ScrollbarRounding, "ScrollbarSize": dpg.mvStyleVar_ScrollbarSize, + "SelectableTextAlign": dpg.mvStyleVar_SelectableTextAlign, "TabRounding": dpg.mvStyleVar_TabRounding, "WindowBorderSize": dpg.mvStyleVar_WindowBorderSize, "WindowPadding": dpg.mvStyleVar_WindowPadding, diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index 30ce8d06..de518002 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -4,6 +4,7 @@ from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.faded import FadedColor def create_selectable_text_theme(color: BaseColor) -> int: @@ -31,6 +32,24 @@ def create_header_selectable_theme( ) +def create_label_selectable_theme(color: BaseColor) -> int: + """Builds a theme for a selectable that carries a label rather than a gesture. + + Every header wash takes the label's own colour at zero alpha, so the cell reads as plain + text while it keeps the layout a selectable lays out with, which is what lets it line up + with the clickable labels beside it. + """ + washed_out = FadedColor(color=color, fraction=0.0) + return _create_selectable_theme( + { + dpg.mvThemeCol_Text: color, + dpg.mvThemeCol_Header: washed_out, + dpg.mvThemeCol_HeaderHovered: washed_out, + dpg.mvThemeCol_HeaderActive: washed_out, + }, + ) + + def _create_selectable_theme(colors: Dict[int, BaseColor]) -> int: """Builds a theme carrying ``colors`` for a selectable in both enabled states. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index f830b80b..38971428 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -95,6 +95,18 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ORDER_NEXT_CHANNEL = ("OrderNextChannel", ShortcutCategory.ORDER) ORDER_FIRST_POSITION = ("OrderFirstPosition", ShortcutCategory.ORDER) ORDER_LAST_POSITION = ("OrderLastPosition", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_UP = ("OrderExtendSelectionUp", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_DOWN = ("OrderExtendSelectionDown", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_LEFT = ("OrderExtendSelectionLeft", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_RIGHT = ("OrderExtendSelectionRight", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = ( + "OrderExtendSelectionToFirstPosition", + ShortcutCategory.ORDER, + ) + ORDER_EXTEND_SELECTION_TO_LAST_POSITION = ( + "OrderExtendSelectionToLastPosition", + ShortcutCategory.ORDER, + ) ORDER_MOVE_FRAME_LEFT = ("OrderMoveFrameLeft", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_RIGHT = ("OrderMoveFrameRight", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_TO_START = ("OrderMoveFrameToStart", ShortcutCategory.ORDER) @@ -117,6 +129,18 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_NEXT_COLUMN = ("TrackerNextColumn", ShortcutCategory.TRACKER) TRACKER_FIRST_ROW = ("TrackerFirstRow", ShortcutCategory.TRACKER) TRACKER_LAST_ROW = ("TrackerLastRow", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_UP = ("TrackerExtendSelectionUp", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_DOWN = ("TrackerExtendSelectionDown", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_LEFT = ("TrackerExtendSelectionLeft", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_RIGHT = ("TrackerExtendSelectionRight", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = ( + "TrackerExtendSelectionToFirstRow", + ShortcutCategory.TRACKER, + ) + TRACKER_EXTEND_SELECTION_TO_LAST_ROW = ( + "TrackerExtendSelectionToLastRow", + ShortcutCategory.TRACKER, + ) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py new file mode 100644 index 00000000..63cac2f1 --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -0,0 +1,121 @@ +from typing import Optional, Self, Tuple + +from pydantic import BaseModel, Field, model_validator + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) +from sampletones_core.constants.enums import GeneratorName + + +class TrackerCell(BaseModel, frozen=True): + """The tracker cell a block is written from: a row, and the column it starts in. + + A block carries the subcolumn offsets it was read at, so the cell it is anchored to names a + row and a column while the block supplies the rest. That is why a cell states no subcolumn: + the anchor decides where a block lands, and the block decides which kind of value goes where. + """ + + row: int = Field(ge=0) + generator: Optional[GeneratorName] + + +class OrderCell(BaseModel, frozen=True): + """The order cell a block is written from: a channel row, and the position it starts in.""" + + generator: Optional[GeneratorName] + position: int = Field(ge=0) + + +class GridRegion(BaseModel, frozen=True): + """The rows a selection covers, shared by both sequencer grids. + + Both bounds are inclusive, so a region always covers the cell it was started from and the + smallest one covers exactly that cell. A producer orders the bounds it was given, which is + what makes a selection dragged upwards name the same region as one dragged down to the same + pair of cells. + """ + + first_row: int = Field(ge=0) + last_row: int = Field(ge=0) + + @model_validator(mode="after") + def _validate_rows(self) -> Self: + if self.last_row < self.first_row: + raise ValueError(f"A region's rows end at {self.last_row}, before they begin at {self.first_row}") + + return self + + @property + def row_count(self) -> int: + return self.last_row - self.first_row + 1 + + @property + def rows(self) -> range: + return range(self.first_row, self.last_row + 1) + + +class TrackerRegion(GridRegion, frozen=True): + """A rectangle of the tracker grid: pattern rows crossed with a run of slots. + + The slots are indices into the flattened axis :data:`SLOT_COUNT` spans, so one region reaches + across the sample column and the channels alike and names the subcolumn it begins and ends on. + That is what lets a selection start midway through a cell: its edges are subcolumns. + """ + + first_slot: int = Field(ge=0, lt=SLOT_COUNT) + last_slot: int = Field(ge=0, lt=SLOT_COUNT) + + @model_validator(mode="after") + def _validate_slots(self) -> Self: + if self.last_slot < self.first_slot: + raise ValueError(f"A region's slots end at {self.last_slot}, before they begin at {self.first_slot}") + + return self + + @property + def slot_count(self) -> int: + return self.last_slot - self.first_slot + 1 + + @property + def slots(self) -> Tuple[TrackerSlot, ...]: + """The slots the region covers, each as the column and subcolumn it addresses.""" + return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + + +class OrderRegion(GridRegion, frozen=True): + """A rectangle of the order table: channel rows crossed with a run of positions. + + The rows are indices into :data:`CHANNEL_AXIS`, so row ``0`` is the master row and the + channels follow it in the order the table lays them out. + """ + + first_row: int = Field(ge=0, lt=len(CHANNEL_AXIS)) + last_row: int = Field(ge=0, lt=len(CHANNEL_AXIS)) + first_position: int = Field(ge=0) + last_position: int = Field(ge=0) + + @model_validator(mode="after") + def _validate_positions(self) -> Self: + if self.last_position < self.first_position: + raise ValueError( + f"A region's positions end at {self.last_position}, before they begin at {self.first_position}" + ) + + return self + + @property + def position_count(self) -> int: + return self.last_position - self.first_position + 1 + + @property + def positions(self) -> range: + return range(self.first_position, self.last_position + 1) + + @property + def generators(self) -> Tuple[Optional[GeneratorName], ...]: + """The rows the region covers, each as the channel it addresses, master reading ``None``.""" + return tuple(CHANNEL_AXIS[row] for row in self.rows) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 02946c4f..aef2c047 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -66,6 +66,12 @@ bindings: OrderNextChannel: {combination: "Down"} OrderFirstPosition: {combination: "Home"} OrderLastPosition: {combination: "End"} + OrderExtendSelectionUp: {combination: "Shift+Up"} + OrderExtendSelectionDown: {combination: "Shift+Down"} + OrderExtendSelectionLeft: {combination: "Shift+Left"} + OrderExtendSelectionRight: {combination: "Shift+Right"} + OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} + OrderExtendSelectionToLastPosition: {combination: "Shift+End"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home"} @@ -89,6 +95,12 @@ bindings: TrackerNextColumn: {combination: "Tab"} TrackerFirstRow: {combination: "Home"} TrackerLastRow: {combination: "End"} + TrackerExtendSelectionUp: {combination: "Shift+Up"} + TrackerExtendSelectionDown: {combination: "Shift+Down"} + TrackerExtendSelectionLeft: {combination: "Shift+Left"} + TrackerExtendSelectionRight: {combination: "Shift+Right"} + TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} + TrackerExtendSelectionToLastRow: {combination: "Shift+End"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index c0e3e2f0..51a9bbbc 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -66,6 +66,12 @@ bindings: OrderNextChannel: {combination: "Down"} OrderFirstPosition: {combination: "Home", aliases: ["Cmd+Left"]} OrderLastPosition: {combination: "End", aliases: ["Cmd+Right"]} + OrderExtendSelectionUp: {combination: "Shift+Up"} + OrderExtendSelectionDown: {combination: "Shift+Down"} + OrderExtendSelectionLeft: {combination: "Shift+Left"} + OrderExtendSelectionRight: {combination: "Shift+Right"} + OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} + OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home", aliases: ["Cmd+Alt+Left"]} @@ -89,6 +95,12 @@ bindings: TrackerNextColumn: {combination: "Tab"} TrackerFirstRow: {combination: "Home", aliases: ["Cmd+Up"]} TrackerLastRow: {combination: "End", aliases: ["Cmd+Down"]} + TrackerExtendSelectionUp: {combination: "Shift+Up"} + TrackerExtendSelectionDown: {combination: "Shift+Down"} + TrackerExtendSelectionLeft: {combination: "Shift+Left"} + TrackerExtendSelectionRight: {combination: "Shift+Right"} + TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} + TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ca4faa1b..319798b8 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -772,6 +772,12 @@ settings.keybindings.label.order_previous_channel: "Previous channel" settings.keybindings.label.order_next_channel: "Next channel" settings.keybindings.label.order_first_position: "First position" settings.keybindings.label.order_last_position: "Last position" +settings.keybindings.label.order_extend_selection_up: "Extend selection up" +settings.keybindings.label.order_extend_selection_down: "Extend selection down" +settings.keybindings.label.order_extend_selection_left: "Extend selection left" +settings.keybindings.label.order_extend_selection_right: "Extend selection right" +settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" +settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" settings.keybindings.label.order_move_frame_left: "Move frame left" settings.keybindings.label.order_move_frame_right: "Move frame right" settings.keybindings.label.order_move_frame_to_start: "Move frame to the start" @@ -793,6 +799,12 @@ settings.keybindings.label.tracker_previous_column: "Previous column" settings.keybindings.label.tracker_next_column: "Next column" settings.keybindings.label.tracker_first_row: "First row" settings.keybindings.label.tracker_last_row: "Last row" +settings.keybindings.label.tracker_extend_selection_up: "Extend selection up" +settings.keybindings.label.tracker_extend_selection_down: "Extend selection down" +settings.keybindings.label.tracker_extend_selection_left: "Extend selection left" +settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" +settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" +settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 34c6001a..ffc48a35 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -1,5 +1,7 @@ rows: 64 page_size: 16 +row_height: 29 +header_height: 30 channel_column_tint: 0.09 muted_text_fraction: 0.45 subcolumn_widths: diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 1a83491e..6d8d9580 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#ffffff14" tracker_bar_row: "#ffffff28" + block_selection: "#b98af360" pattern_highlight: "#ffffff40" cell_cursor: "#4fa6ffb0" cursor_row: "#4fa6ff3a" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 355c44f4..e72dc7ff 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#00000016" tracker_bar_row: "#0000002c" + block_selection: "#6b4ea840" pattern_highlight: "#00000018" cell_cursor: "#0b4c8c60" cursor_row: "#0b4c8c24" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index e97db657..a92a7027 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#ffffff14" tracker_bar_row: "#ffffff26" + block_selection: "#b98af360" pattern_highlight: "#ffffff40" cell_cursor: "#66bbffa0" cursor_row: "#ffffff18" diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml index 3e34fb31..71a8af6e 100644 --- a/src/sampletones_config/theme/tables/order.yaml +++ b/src/sampletones_config/theme/tables/order.yaml @@ -10,3 +10,8 @@ components: - type: color key: HeaderActive value: .transparent + - item_type: Selectable + entries: + - type: color + key: Header + value: .block_selection diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index 5f9ccc2a..1b0d5512 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -7,7 +7,7 @@ components: - type: style key: CellPadding x: 3 - y: 4 + y: 0 - type: color key: HeaderHovered value: .overlay/0.25 @@ -20,6 +20,19 @@ components: - type: color key: TableRowBgAlt value: .table_row + - item_type: Selectable + entries: + - type: style + key: ItemSpacing + x: 0 + y: 0 + - type: style + key: SelectableTextAlign + x: 0 + y: 0.5 + - type: color + key: Header + value: .block_selection - item_type: All entries: - type: color diff --git a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py similarity index 100% rename from tests/unit/sampletones_application/logic/sequencer/test_tracker.py rename to tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 86ce83f2..4ae8875a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -7,6 +7,8 @@ ) from sampletones_core.constants.enums import GeneratorName +POSITION_COUNT = 8 + def _state( generator: Optional[GeneratorName] = GeneratorName.PULSE1, @@ -40,6 +42,74 @@ def test_channel_cycles_master_then_channels_and_wraps(self) -> None: assert state.cursor.generator == CHANNEL_AXIS[0] +class TestSelection: + """Shift-extended moves grow a region from the cell the selection was started on.""" + + def test_a_state_without_an_anchor_covers_no_region(self) -> None: + assert _state().region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(position=2).extend_position(1, POSITION_COUNT) + + region = extended.region + assert region is not None + assert (region.first_position, region.last_position) == (2, 3) + assert region.position_count == 2 + + def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None: + leftwards = _state(position=3).extend_position(-1, POSITION_COUNT).region + rightwards = _state(position=2).extend_position(1, POSITION_COUNT).region + + assert leftwards == rightwards + + def test_extending_channels_reaches_from_master_down(self) -> None: + extended = _state(generator=None).extend_channel(2) + + region = extended.region + assert region is not None + assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + + def test_extending_channels_stops_at_either_end_of_the_axis(self) -> None: + """A selection covers a run of the table, so its reach stops where plain navigation wraps.""" + first = _state(generator=CHANNEL_AXIS[0]).extend_channel(-1) + last = _state(generator=CHANNEL_AXIS[-1]).extend_channel(1) + + assert first.cursor == OrderCursor(CHANNEL_AXIS[0], 0) + assert last.cursor == OrderCursor(CHANNEL_AXIS[-1], 0) + + def test_a_plain_move_collapses_the_selection(self) -> None: + moved = _state(position=1).extend_position(2, POSITION_COUNT).navigate_position(1, POSITION_COUNT) + + assert moved.anchor is None + assert moved.region is None + + def test_a_plain_channel_move_collapses_the_selection(self) -> None: + moved = _state().extend_position(1, POSITION_COUNT).navigate_channel(1) + + assert moved.region is None + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + held = _state(pending="5").extend_position(1, POSITION_COUNT).reset_pending() + + assert held.region is not None + + def test_typing_an_index_collapses_the_selection(self) -> None: + selected = _state(position=1).extend_position(2, POSITION_COUNT) + + partial, first = selected.type_char("0") + committed, index = partial.type_char("2") + + assert first is None + assert index == 2 + assert committed.region is None + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index cd12a80c..34644f67 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -5,6 +5,8 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +ROW_COUNT = 64 + def _state( subcolumn: SubColumn, @@ -37,6 +39,101 @@ def test_minus_in_transpose_is_a_sign_not_note_off(self) -> None: assert new_state.pending.startswith("-") +class TestSelection: + """Shift-extended moves grow a region from the cell the selection was started on.""" + + def test_a_state_without_an_anchor_covers_no_region(self) -> None: + assert _state(SubColumn.INSTRUMENT).region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT) + + region = extended.region + assert region is not None + assert (region.first_row, region.last_row) == (4, 5) + assert region.row_count == 2 + + def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: + """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" + upwards = _state(SubColumn.INSTRUMENT, row=5).extend_row(-1, ROW_COUNT).region + downwards = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).region + + assert upwards == downwards + + def test_a_further_extend_keeps_the_original_anchor(self) -> None: + extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT) + + region = extended.region + assert region is not None + assert (region.first_row, region.last_row) == (4, 8) + + def test_extending_slots_reaches_across_the_column_boundary(self) -> None: + extended = _state(SubColumn.VOLUME, generator=None).extend_slot(1) + + region = extended.region + assert region is not None + assert (region.first_slot, region.last_slot) == (2, 3) + assert extended.cursor is not None + assert extended.cursor.generator is GeneratorName.PULSE1 + assert extended.cursor.subcolumn is SubColumn.INSTRUMENT + + def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None: + """A selection covers a run of the grid, so its reach stops where plain navigation wraps.""" + first = _state(SubColumn.INSTRUMENT, generator=None).extend_slot(-1) + last = _state(SubColumn.VOLUME, generator=GeneratorName.NOISE).extend_slot(1) + + assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT) + assert last.cursor == TrackerCursor(0, GeneratorName.NOISE, SubColumn.VOLUME) + + def test_a_plain_move_collapses_the_selection(self) -> None: + moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT) + + assert moved.anchor is None + assert moved.region is None + + def test_a_plain_column_move_collapses_the_selection(self) -> None: + moved = _state(SubColumn.INSTRUMENT).extend_row(2, ROW_COUNT).navigate_column_by(1) + + assert moved.region is None + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + """Every move commits what was typed first, the extending ones included.""" + held = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).reset_pending() + + assert held.region is not None + + def test_typing_a_value_collapses_the_selection(self) -> None: + selected = _state(SubColumn.VOLUME, row=4).extend_row(2, ROW_COUNT) + + typed, action = selected.type_char("7") + + assert action is not None + assert typed.region is None + + def test_a_note_off_collapses_the_selection(self) -> None: + selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + + typed, action = selected.type_char("-") + + assert action is not None + assert action.note_off is True + assert typed.region is None + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + + def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: + selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT) + + collapsed = selected.collapse() + + assert collapsed.cursor == selected.cursor + assert collapsed.region is None + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index aa62eea8..20e499de 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py @@ -41,6 +41,21 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" + def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection is state the grid holds, so Escape takes it down before it reaches Stop.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._current_row_count = 64 + panel._input_state = TrackerInputState( + cursor=TrackerCursor(4, None, SubColumn.INSTRUMENT), + anchor=TrackerCursor(2, None, SubColumn.INSTRUMENT), + ) + applied: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", applied.append) + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].region is None + class TestOrderEscapeYieldsToGlobalStop: """With no partial cell edit to cancel, the order table lets Escape fall through to global Stop.""" @@ -61,3 +76,18 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" + + def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection is state the table holds, so Escape takes it down before it reaches Stop.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._position_count = 8 + panel._input_state = OrderInputState( + cursor=OrderCursor(None, 3), + anchor=OrderCursor(None, 1), + ) + applied: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", applied.append) + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].region is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py new file mode 100644 index 00000000..bd79ee36 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -0,0 +1,355 @@ +from typing import List, Optional, Tuple + +import pytest + +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import ( + OrderCursor, + OrderInputState, +) +from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey +from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.modifiers import Modifier +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +ROW_COUNT = 64 +POSITION_COUNT = 8 +ORIGIN_WIDGET = 101 +ORIGIN_CELL: CellKey = (2, GeneratorName.PULSE1, SubColumn.TRANSPOSE) +ORIGIN_ENTRY: OrderKey = (None, 1) + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: + return layout_config.tabs.sequencer + + +def _hold_modifiers( + monkeypatch: pytest.MonkeyPatch, + module: str, + shift: bool, +) -> None: + monkeypatch.setattr( + f"sampletones_application.ui.panels.sequencer.{module}.capture_modifiers", + lambda: {Modifier.SHIFT} if shift else set(), + ) + + +def _tracker( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[CellKey], + shift: bool = False, +) -> Tuple[GUISequencerTrackerPanel, List[TrackerInputState]]: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._input_state = TrackerInputState() + panel._current_row_count = ROW_COUNT + panel._drag = None + panel._editable_cells = EditableCells() + panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_cell_at", lambda: reached) + _hold_modifiers(monkeypatch, "tracker", shift) + return panel, states + + +def _order( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[OrderKey], + shift: bool = False, +) -> Tuple[GUISequencerOrderPanel, List[OrderInputState]]: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._input_state = OrderInputState() + panel._position_count = POSITION_COUNT + panel._drag = None + panel._order = EditableCells() + panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) + + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_cell_at", lambda: reached) + _hold_modifiers(monkeypatch, "order", shift) + return panel, states + + +class TestEditableCellKeys: + """A cell cache answers from both sides, because a handler reports the widget it fired for.""" + + def test_a_registered_widget_reads_back_as_its_key(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + + assert cells.key(ORIGIN_WIDGET) == ORIGIN_CELL + assert cells.widget(ORIGIN_CELL) == ORIGIN_WIDGET + + def test_a_rebuild_drops_both_directions(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + cells.reset({}) + + assert cells.key(ORIGIN_WIDGET) is None + assert cells.widget(ORIGIN_CELL) is None + + def test_an_unknown_widget_names_no_cell(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + + assert cells.key(ORIGIN_WIDGET) is None + + +class TestTrackerDrag: + """A press carries the selection with the pointer, and a press that stays put stays a click.""" + + def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.origin == ORIGIN_CELL + assert panel._drag.moved is False + assert states == [] + + def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states == [] + + def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.TRIANGLE, SubColumn.VOLUME) + panel, states = _tracker(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.moved is True + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=5, + first_slot=4, + last_slot=11, + ) + + def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + panel._input_state = TrackerInputState( + cursor=TrackerCursor(20, GeneratorName.NOISE, SubColumn.VOLUME), + anchor=TrackerCursor(30, GeneratorName.NOISE, SubColumn.VOLUME), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == TrackerCursor(*ORIGIN_CELL) + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=5, + first_slot=4, + last_slot=4, + ) + + def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached, shift=True) + panel._input_state = TrackerInputState( + cursor=TrackerCursor(9, GeneratorName.PULSE1, SubColumn.TRANSPOSE), + anchor=TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE) + + def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + monkeypatch.setattr(panel, "_cell_at", lambda: ORIGIN_CELL) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=2, + first_slot=4, + last_slot=4, + ) + + def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET + 1) + + assert panel._drag is None + assert states == [] + + def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_pointer_pressed(0, 0) + + assert panel._drag is None + + def test_the_click_ending_a_drag_leaves_the_selection_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A drag returning to its own cell releases there, and that release reports a click.""" + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + panel._selection = frozenset({ORIGIN_CELL}) + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + "sampletones_application.ui.panels.sequencer.tracker.dpg.set_value", + lambda widget, value: None, + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + applied = len(states) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) + + assert len(states) == applied + assert panel._drag is None + + +class TestTrackerDragHitTest: + """The row under the pointer is counted from the first row, and clipped to the rows there are.""" + + def test_each_row_answers_for_its_own_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + height = sequencer_layout.tracker.row_height + assert panel._row_at(100.0) == 0 + assert panel._row_at(100.0 + height - 1) == 0 + assert panel._row_at(100.0 + height) == 1 + assert panel._row_at(100.0 + 3 * height + 2) == 3 + + def test_a_pointer_past_an_edge_reads_as_the_edge( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + assert panel._row_at(-500.0) == 0 + assert panel._row_at(100_000.0) == ROW_COUNT - 1 + + def test_a_grid_awaiting_its_rows_answers_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = 0 + monkeypatch.setattr(panel, "_row_top", lambda index: None) + + assert panel._row_at(100.0) is None + + +class TestOrderDrag: + """The order table reads a drag the same way, over its channels and positions.""" + + def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _order(monkeypatch, reached=ORIGIN_ENTRY) + + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.origin == ORIGIN_ENTRY + assert states == [] + + def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].region == OrderRegion( + first_row=0, + last_row=2, + first_position=1, + last_position=4, + ) + + def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached, shift=True) + panel._input_state = OrderInputState( + cursor=OrderCursor(GeneratorName.NOISE, 6), + anchor=OrderCursor(GeneratorName.NOISE, 6), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6) + + def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = _order(monkeypatch, reached=ORIGIN_ENTRY) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_pointer_pressed(0, 0) + + assert panel._drag is None + + def test_the_click_ending_a_drag_leaves_the_selection_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + panel._selection = frozenset({ORIGIN_ENTRY}) + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + "sampletones_application.ui.panels.sequencer.order.dpg.set_value", + lambda widget, value: None, + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + applied = len(states) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) + + assert len(states) == applied + assert panel._drag is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py new file mode 100644 index 00000000..22a7af00 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -0,0 +1,181 @@ +from typing import List, Optional + +import pytest + +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import ( + OrderCursor, + OrderInputState, +) +from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +ROW_COUNT = 64 +POSITION_COUNT = 8 +CURSOR_ROW = 4 +CURSOR_POSITION = 2 + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +def _tracker( + generator: Optional[GeneratorName] = GeneratorName.PULSE1, + subcolumn: SubColumn = SubColumn.INSTRUMENT, +) -> GUISequencerTrackerPanel: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._current_row_count = ROW_COUNT + return panel + + +def _order(generator: Optional[GeneratorName] = GeneratorName.PULSE1) -> GUISequencerOrderPanel: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._position_count = POSITION_COUNT + return panel + + +def _tracker_states( + monkeypatch: pytest.MonkeyPatch, + panel: GUISequencerTrackerPanel, +) -> List[TrackerInputState]: + """The states a gesture applies, with the scroll a jump asks for left out.""" + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + return states + + +def _order_states( + monkeypatch: pytest.MonkeyPatch, + panel: GUISequencerOrderPanel, +) -> List[OrderInputState]: + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + return states + + +class TestTrackerSelectionKeys: + """Shift held with a cursor key selects instead of moving, over the grid the cursor stands in.""" + + def test_shift_down_selects_two_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Down")) is True + assert states[-1].region == TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 1, + first_slot=3, + last_slot=3, + ) + + def test_shift_up_selects_the_row_above(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Up")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (CURSOR_ROW - 1, CURSOR_ROW) + + def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Right")) is True + region = states[-1].region + assert region is not None + assert region.slots == ( + TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ) + + def test_shift_end_selects_to_the_last_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+End")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (CURSOR_ROW, ROW_COUNT - 1) + + def test_shift_home_selects_to_the_first_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Home")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (0, CURSOR_ROW) + + def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Down")) is True + assert states[-1].region is None + + +class TestOrderSelectionKeys: + """Shift held with a cursor key selects instead of moving, over the table the cursor stands in.""" + + def test_shift_right_selects_two_positions(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Right")) is True + assert states[-1].region == OrderRegion( + first_row=1, + last_row=1, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + + def test_shift_up_selects_up_to_the_master_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Up")) is True + region = states[-1].region + assert region is not None + assert region.generators == (None, GeneratorName.PULSE1) + + def test_shift_end_selects_to_the_last_position(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+End")) is True + region = states[-1].region + assert region is not None + assert (region.first_position, region.last_position) == (CURSOR_POSITION, POSITION_COUNT - 1) + + def test_shift_home_selects_to_the_first_position(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Home")) is True + region = states[-1].region + assert region is not None + assert (region.first_position, region.last_position) == (0, CURSOR_POSITION) + + def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Right")) is True + assert states[-1].region is None diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index f8b3578d..4a690e0e 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -139,8 +139,13 @@ def test_each_category_answers_a_shared_combination_with_its_own_action( assert shipped.action(ShortcutCategory.DIALOG, _press("Esc")) is ShortcutId.DIALOG_CANCEL def test_a_modifier_the_combination_omits_leaves_the_press_unnamed(self, shipped: ShortcutScheme) -> None: - """A binding names the modifiers held with it, so Shift+Left is not the plain Left move.""" - assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Left")) is None + """A binding names the modifiers held with it, so Ctrl+Up is not the plain Up move.""" + assert shipped.action(ShortcutCategory.ORDER, _press("Ctrl+Up")) is None + + def test_a_modifier_a_binding_does_name_reaches_its_own_action(self, shipped: ShortcutScheme) -> None: + """Shift+Up selects where Up moves, which is one combination reaching each of two actions.""" + assert shipped.action(ShortcutCategory.ORDER, _press("Up")) is ShortcutId.ORDER_PREVIOUS_CHANNEL + assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Up")) is ShortcutId.ORDER_EXTEND_SELECTION_UP class TestClaimant: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py new file mode 100644 index 00000000..3ea7fb3d --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -0,0 +1,92 @@ +import pytest +from pydantic import ValidationError + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import ( + OrderRegion, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + + +class TestTrackerRegion: + def test_a_single_cell_region_covers_that_cell(self) -> None: + region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4) + + assert region.row_count == 1 + assert region.slot_count == 1 + assert tuple(region.rows) == (3,) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + + def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None: + """A region's edges are subcolumns, so a run reaches across a column boundary mid-cell.""" + region = TrackerRegion(first_row=0, last_row=0, first_slot=2, last_slot=3) + + assert region.slots == ( + TrackerSlot(None, SubColumn.VOLUME), + TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ) + + def test_a_region_spans_the_whole_axis(self) -> None: + region = TrackerRegion(first_row=0, last_row=63, first_slot=0, last_slot=SLOT_COUNT - 1) + + assert region.row_count == 64 + assert region.slot_count == SLOT_COUNT + + def test_inverted_rows_are_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=5, last_row=2, first_slot=0, last_slot=0) + + def test_inverted_slots_are_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=0, last_row=0, first_slot=5, last_slot=2) + + @pytest.mark.parametrize("slot", [-1, SLOT_COUNT]) + def test_a_slot_off_the_axis_is_rejected(self, slot: int) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=0, last_row=0, first_slot=slot, last_slot=slot) + + def test_a_negative_row_is_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=-1, last_row=0, first_slot=0, last_slot=0) + + +class TestOrderRegion: + def test_a_single_cell_region_covers_that_cell(self) -> None: + region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2) + + assert region.row_count == 1 + assert region.position_count == 1 + assert region.generators == (None,) + assert tuple(region.positions) == (2,) + + def test_the_rows_read_as_the_channels_they_address(self) -> None: + region = OrderRegion(first_row=0, last_row=2, first_position=0, last_position=0) + + assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + + def test_a_region_spans_the_whole_channel_axis(self) -> None: + region = OrderRegion( + first_row=0, + last_row=len(CHANNEL_AXIS) - 1, + first_position=0, + last_position=7, + ) + + assert region.generators == CHANNEL_AXIS + assert region.position_count == 8 + + def test_inverted_positions_are_rejected(self) -> None: + with pytest.raises(ValidationError): + OrderRegion(first_row=0, last_row=0, first_position=5, last_position=2) + + def test_a_row_off_the_channel_axis_is_rejected(self) -> None: + with pytest.raises(ValidationError): + OrderRegion( + first_row=0, + last_row=len(CHANNEL_AXIS), + first_position=0, + last_position=0, + ) From 77cfa0586929232f8840213a7d9203279662dadc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 21:00:24 +0200 Subject: [PATCH 08/28] Added: tracker block copy --- .../categories/elements/settings.py | 1 + .../coordinators/tabs/sequencer.py | 23 +- .../logic/sequencer/clipboard.py | 27 ++ .../logic/sequencer/tracker/__init__.py | 10 + .../logic/sequencer/tracker/block.py | 50 ++++ .../logic/sequencer/tracker/reader.py | 109 +++++++ .../ui/panels/sequencer/input/state.py | 21 ++ .../ui/panels/sequencer/tracker.py | 28 ++ .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 1 + tests/conftest.py | 28 +- tests/suite/sequencer.py | 40 +++ .../coordinators/tabs/test_sequencer.py | 73 +++++ .../logic/sequencer/tracker/__init__.py | 0 .../logic/sequencer/tracker/test_reader.py | 280 ++++++++++++++++++ .../logic/sequencer/tracker/test_tracker.py | 72 ++--- .../sequencer/input/test_tracker_input.py | 20 ++ .../ui/panels/sequencer/test_block_keys.py | 80 +++++ .../panels/sequencer/test_selection_drag.py | 4 +- .../sequencer/test_tracker_navigation.py | 4 +- 22 files changed, 791 insertions(+), 83 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/clipboard.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/block.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/reader.py create mode 100644 tests/suite/sequencer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 8b73997b..e4257bc6 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -123,6 +123,7 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" + TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 4a789f8a..182fdedf 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -21,6 +21,7 @@ from sampletones_application.logic.reconstruction.browser_manager import BrowserManager from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic +from sampletones_application.logic.sequencer.clipboard import SequencerClipboard from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) @@ -33,7 +34,10 @@ from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic -from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters from sampletones_application.services.song_player.player import SongPlayerService @@ -82,6 +86,7 @@ HistoryEntryViewModel, HistoryViewModel, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -178,6 +183,8 @@ def __init__( ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) + self._clipboard: SequencerClipboard = SequencerClipboard() + self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -261,6 +268,7 @@ def _wire_callbacks(self) -> None: self._wire_tracker_callbacks() self._wire_channels_callbacks() self._wire_order_callbacks() + self._wire_block_callbacks() self._wire_samples_callbacks() self._wire_browser_callbacks() self._wire_playback_callbacks() @@ -434,6 +442,19 @@ def _wire_order_callbacks(self) -> None: ) self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused + def _wire_block_callbacks(self) -> None: + """Connects the grids' block gestures to the clipboard they copy into. + + A copy reads the project and leaves it as it stands, so it is wired straight through + instead of through :meth:`_undoable`: a transaction over it would record an entry the + history has nothing to restore for. + """ + self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block + + def _on_tracker_copy_block(self, region: TrackerRegion) -> None: + """Puts the tracker's selected block on the clipboard, for a paste to replay.""" + self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard.py new file mode 100644 index 00000000..0d18f9e5 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard.py @@ -0,0 +1,27 @@ +from typing import Optional + +from sampletones_application.logic.sequencer.tracker import TrackerBlock + + +class SequencerClipboard: + """Holds the block each sequencer grid last copied, one slot per grid. + + Separate slots are what keep a paste in the grid it belongs to: the tracker reads only what a + tracker copied, so a block never has to be asked which grid it came from. + + A slot outlives the project it was filled from, because a project is replaced on every undo + and redo as well as on opening a document, and a copy the reader made is theirs to keep across + all of it. A note naming a sample the project in place lacks is settled where the block is + written. + """ + + def __init__(self) -> None: + self._tracker_block: Optional[TrackerBlock] = None + + @property + def tracker_block(self) -> Optional[TrackerBlock]: + """The block the tracker last copied, present once a copy has been made.""" + return self._tracker_block + + def store_tracker_block(self, block: TrackerBlock) -> None: + self._tracker_block = block diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py new file mode 100644 index 00000000..976a0d99 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -0,0 +1,10 @@ +from .block import BlockNote, TrackerBlock +from .reader import TrackerBlockReader +from .tracker import SequencerTrackerLogic + +__all__ = [ + "BlockNote", + "SequencerTrackerLogic", + "TrackerBlock", + "TrackerBlockReader", +] diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py new file mode 100644 index 00000000..899b2837 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple, Union + +from sampletones_core.project.instruments.note_off import NoteOff + +BlockNote = Union[str, NoteOff] +BlockKey = Tuple[int, int] + + +@dataclass(frozen=True) +class TrackerBlock: + """A rectangle of tracker values, addressed by the offsets it was read at. + + A key is a row offset paired with a slot offset. The row offset counts down from the block's + first row; the slot offset is measured from the base of the column the block begins in, so it + stays a multiple of three apart from the column it is replayed against and each value keeps + the kind of subcolumn it was read from. + + A cell reaches the block in one of three states, and the maps hold them apart: a key carrying + a value writes that value, a key carrying ``None`` writes emptiness, and an absent key states + that the block says nothing about that cell — which is how a sample column its channels + disagree over stays transparent to whatever it is pasted onto. + + Notes, transposes and volumes are kept in maps of their own so the kind of a value is + structural. It also fixes the order a write takes: every note lands before the transposes and + volumes sharing its row, which matters where a sample-column note clears the channels around + it. + + A note names a sample by id rather than by instrument, so it carries a pitch and leaves the + channel to whichever column it is written into. + """ + + row_count: int + first_slot: int + last_slot: int + notes: Dict[BlockKey, Optional[BlockNote]] + transposes: Dict[BlockKey, Optional[int]] + volumes: Dict[BlockKey, Optional[int]] + + @property + def slot_count(self) -> int: + return self.last_slot - self.first_slot + 1 + + @property + def slots(self) -> range: + return range(self.first_slot, self.last_slot + 1) + + @property + def rows(self) -> range: + return range(self.row_count) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py new file mode 100644 index 00000000..8bac8910 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -0,0 +1,109 @@ +from collections.abc import Hashable +from typing import Callable, Dict, Optional, TypeVar + +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_shared.utils.agreement import Agreement + +from .block import BlockKey, BlockNote, TrackerBlock +from .tracker import SequencerTrackerLogic + +ValueT = TypeVar("ValueT", bound=Hashable) + + +class TrackerBlockReader: + """Reads a selected region of the shown frame into a block a paste can replay. + + The block is anchored at the column the region begins in, so it carries offsets rather than + grid coordinates and lands wherever it is written by the kind of each subcolumn. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def read(self, region: TrackerRegion) -> TrackerBlock: + """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" + base = column_slot_base(slot_from_flat(region.first_slot).generator) + return TrackerBlock( + row_count=region.row_count, + first_slot=region.first_slot - base, + last_slot=region.last_slot - base, + notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), + transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), + volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), + ) + + def _read_subcolumn( + self, + region: TrackerRegion, + base: int, + subcolumn: SubColumn, + select: Callable[[Optional[Row]], ValueT], + ) -> Dict[BlockKey, ValueT]: + """The values one kind of subcolumn holds across a region, keyed by the offsets it stands at. + + A cell holding a definite value keeps it, an empty one keeps its emptiness, and a cell + whose channels disagree leaves its key out — which is what carries the sample column's + mixed reading over as a value the paste passes by. + """ + values: Dict[BlockKey, ValueT] = {} + for row_offset, row_index in enumerate(region.rows): + for position, slot in enumerate(region.slots): + if slot.subcolumn is not subcolumn: + continue + + agreement = self._agree(row_index, slot.generator, select) + if agreement.is_unanimous: + values[(row_offset, region.first_slot + position - base)] = agreement.value + + return values + + def _agree( + self, + row_index: int, + generator: Optional[GeneratorName], + select: Callable[[Optional[Row]], ValueT], + ) -> Agreement[ValueT]: + """What a column holds at a cell: a channel's own value, or the one its channels share. + + A channel column answers for itself, so it is a group of one and always agrees. The sample + column answers for the channels it governs, which is the group its display summarises too, + so a block states about a cell exactly what the grid it came from shows there. + """ + if generator is not None: + return Agreement.collapse([select(self._tracker.row(generator, row_index))]) + + return Agreement.collapse( + select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_generators(row_index) + ) + + @staticmethod + def _note_of(row: Optional[Row]) -> Optional[BlockNote]: + """The note a row carries: the id of the sample it names, or the cut it holds. + + A sample is taken by id so the note keeps its pitch and takes the channel of whichever + column it is written into. + """ + match row.command if row is not None else None: + case Instrument() as instrument: + return instrument.sample_id + case NoteOff() as note_off: + return note_off + case None: + return None + + @staticmethod + def _transpose_of(row: Optional[Row]) -> Optional[int]: + return row.transpose if row is not None else None + + @staticmethod + def _volume_of(row: Optional[Row]) -> Optional[int]: + return row.volume if row is not None else None diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 51e4f2ef..b89e11a0 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -104,6 +104,27 @@ def region(self) -> Optional[TrackerRegion]: last_slot=max(anchor_slot, cursor_slot), ) + @property + def target_region(self) -> Optional[TrackerRegion]: + """The region a block gesture acts on: the selection, or the cursor's own cell. + + A cursor with nothing selected stands on a block of one cell, so copying reaches the cell + the reader is working in and needs no selection made first. + """ + if self.region is not None: + return self.region + + if self.cursor is None: + return None + + slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + return TrackerRegion( + first_row=self.cursor.row, + last_row=self.cursor.row, + first_slot=slot, + last_slot=slot, + ) + def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 30277167..50736240 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -80,6 +80,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -114,6 +115,7 @@ OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnCopyBlockCallback = Callable[[TrackerRegion], None] VOLUME_FINE_STEP: Final[int] = 1 @@ -183,6 +185,7 @@ def __init__( self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None self.on_adjust_transpose: Optional[OnAdjustCallback] = None self.on_adjust_volume: Optional[OnAdjustCallback] = None + self.on_copy_block: Optional[OnCopyBlockCallback] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -1425,6 +1428,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._block_action(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1479,6 +1485,28 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures.""" + match shortcut_id: + case ShortcutId.TRACKER_COPY_BLOCK: + self._copy_block() + case _: + return False + + return True + + def _copy_block(self) -> None: + """Hands the selected block out to be copied, the cell under the cursor standing for itself. + + A partial entry is committed first, so the block carries the value the reader has just + finished typing. + """ + state = self._committed_state() + self._apply_state(state) + region = state.target_region + if region is not None: + self.call(self.on_copy_block, region) + def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 38971428..7210de0d 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -141,6 +141,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "TrackerExtendSelectionToLastRow", ShortcutCategory.TRACKER, ) + TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index aef2c047..cd6f581f 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -101,6 +101,7 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} + TrackerCopyBlock: {combination: "Ctrl+C"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 51a9bbbc..b6a8cea6 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -101,6 +101,7 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} + TrackerCopyBlock: {combination: "Cmd+C"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 319798b8..a388681d 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -805,6 +805,7 @@ settings.keybindings.label.tracker_extend_selection_left: "Extend selection left settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" +settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/tests/conftest.py b/tests/conftest.py index eefcf7ac..49d3471d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,11 @@ -from pathlib import Path from typing import Callable, Iterator, TypeAlias -import numpy as np import pytest from sampletones_application.utils.gui.palette.palette import PaletteBindings -from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName -from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction +from tests.suite.sequencer import sample_reconstruction ReconstructionFactory: TypeAlias = Callable[[], Reconstruction] @@ -29,27 +26,6 @@ def palette_bindings() -> Iterator[None]: @pytest.fixture def reconstruction_factory() -> ReconstructionFactory: def build() -> Reconstruction: - length = 64 - instructions = [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - return Reconstruction.create( - approximation=np.zeros(length, dtype=np.float32), - approximations={ - GeneratorName.PULSE1: np.zeros( - length, - dtype=np.float32, - ) - }, - instructions={GeneratorName.PULSE1: instructions}, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) + return sample_reconstruction([GeneratorName.PULSE1]) return build diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py new file mode 100644 index 00000000..656f2587 --- /dev/null +++ b/tests/suite/sequencer.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Final, Sequence + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction + +SAMPLE_LENGTH: Final[int] = 64 + + +def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction: + """A reconstruction carrying one instruction on each of ``generators``. + + The channels a reconstruction covers are what a sample governs in the sequencer, so this is + the knob a sequencer test turns: the audio itself is silent, since what is under test is which + channels a sample reaches and not how it sounds. + """ + instructions = { + generator: [ + PulseInstruction( + on=True, + pitch=60, + volume=8, + duty_cycle=0, + ) + ] + for generator in generators + } + approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} + return Reconstruction.create( + approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), + approximations=approximations, + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 798ebb45..13864e48 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -19,6 +19,11 @@ ALL_CHANNELS, SequencerChannelsLogic, ) +from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module @@ -26,8 +31,11 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import SampleSelection +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, @@ -1310,3 +1318,68 @@ def test_player_returns_the_guarded_wrapper( exposure_coordinator: SequencerTabCoordinator, ) -> None: assert isinstance(exposure_coordinator.player, GuardedPlayer) + + +PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, +) + + +@pytest.fixture +def block_coordinator() -> SequencerTabCoordinator: + """A coordinator whose copy path is real, from the tracker logic through to the clipboard. + + A real manager observes the same controller production wires it to, so a test reads the + entries a gesture actually records. + """ + instance = object.__new__(SequencerTabCoordinator) + controller = ProjectController(ProjectManager()) + history = HistoryManager(controller, budget=10, strict=True) + controller.on_mutation = history.handle_mutation + instance._project_controller = controller + instance._history = history + instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) + instance._clipboard = SequencerClipboard() + instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) + return instance + + +class TestBlockCopy: + def test_a_copy_fills_the_clipboard_with_the_block_it_covers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + with coordinator._history.transaction(HistoryAction.EDIT_ROW): + coordinator._sequencer_tracker_logic.set_cell_subcolumn( + 0, + GeneratorName.PULSE1, + transpose=5, + ) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + block = coordinator._clipboard.tracker_block + assert block is not None + assert block.transposes[(0, 1)] == 5 + + def test_a_copy_leaves_the_history_stack_as_it_stands( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A gesture that only reads the project records nothing, where the edit beside it does.""" + coordinator = block_coordinator + edit = coordinator._undoable( + HistoryAction.EDIT_ROW, + coordinator._sequencer_tracker_logic.write_cell, + ) + edit(0, GeneratorName.PULSE1, None, 5, None) + recorded = len(coordinator._history.entries) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + assert recorded > 0 + assert len(coordinator._history.entries) == recorded diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py b/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py new file mode 100644 index 00000000..bcf080ba --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -0,0 +1,280 @@ +from typing import Optional, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff +from tests.suite.sequencer import sample_reconstruction + + +def _key(subcolumn: SubColumn, row_offset: int = 0) -> Tuple[int, int]: + """Where a subcolumn's value stands in a block read from the column it belongs to. + + Offsets run from the base of that column, so a subcolumn's own place in the column is the + slot offset it reaches the block at. + """ + return (row_offset, SUBCOLUMNS.index(subcolumn)) + + +@pytest.fixture +def controller() -> ProjectController: + """A controller over a fresh project, which the samples a test places are added to.""" + return ProjectController(ProjectManager()) + + +@pytest.fixture +def logic(controller: ProjectController) -> SequencerTrackerLogic: + """The tracker logic the reader takes every value through.""" + return SequencerTrackerLogic(controller) + + +@pytest.fixture +def reader(logic: SequencerTrackerLogic) -> TrackerBlockReader: + return TrackerBlockReader(logic) + + +def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: + return TrackerSlot(generator, subcolumn).flat_index + + +def _cell( + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, +) -> TrackerRegion: + """The region one subcolumn of one cell covers.""" + slot = _slot(generator, subcolumn) + return TrackerRegion( + first_row=row_index, + last_row=row_index, + first_slot=slot, + last_slot=slot, + ) + + +def _column( + generator: Optional[GeneratorName], + *, + last_row: int = 0, +) -> TrackerRegion: + """The region one whole column covers, down to ``last_row``.""" + return TrackerRegion( + first_row=0, + last_row=last_row, + first_slot=_slot(generator, SubColumn.INSTRUMENT), + last_slot=_slot(generator, SubColumn.VOLUME), + ) + + +class TestChannelColumn: + """A channel answers for itself, so every one of its cells reaches the block definite.""" + + def test_a_cell_carries_the_values_it_holds( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1]), + name="lead", + ) + logic.place_note(0, GeneratorName.PULSE1, sample.id) + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5, volume=3) + + block = reader.read(_column(GeneratorName.PULSE1)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5 + assert block.volumes[_key(SubColumn.VOLUME)] == 3 + + def test_an_empty_cell_carries_its_emptiness( + self, + reader: TrackerBlockReader, + ) -> None: + """An untouched channel holds no pattern at all, which reads as the empty cell it shows.""" + block = reader.read(_column(GeneratorName.NOISE)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None + + def test_a_cut_cell_carries_the_cut( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, GeneratorName.PULSE1) + + block = reader.read(_cell(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + + def test_a_zero_transpose_carries_as_the_value_it_is( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """An explicit zero resets the channel's transpose, so it is a value and not an absence.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE2, transpose=0) + + block = reader.read(_cell(0, GeneratorName.PULSE2, SubColumn.TRANSPOSE)) + + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 0 + + def test_rows_past_the_pattern_read_empty( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """A region reaching past the rows a pattern holds takes emptiness from beyond its end.""" + logic.set_rows_per_pattern(2) + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=4) + + block = reader.read(_column(GeneratorName.PULSE1, last_row=3)) + + assert block.row_count == 4 + assert block.volumes[_key(SubColumn.VOLUME)] == 4 + assert block.volumes[_key(SubColumn.VOLUME, 2)] is None + assert block.volumes[_key(SubColumn.VOLUME, 3)] is None + + +class TestSampleColumn: + """The sample column answers for the channels it governs, agreeing or reading as nothing.""" + + def test_a_value_every_governed_channel_shares_carries_over( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.place_note(0, None, sample.id) + logic.set_cell_subcolumn(0, None, transpose=7) + + block = reader.read(_column(None)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7 + + def test_a_note_carries_as_the_sample_it_names( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The channels hold instruments of their own, and the block keeps the sample they share.""" + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="chord", + ) + logic.place_note(0, None, sample.id) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + + def test_a_column_its_channels_disagree_over_leaves_its_key_out( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """No sample governs the row, so the column spans every channel and only one holds a value.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5) + + block = reader.read(_cell(0, None, SubColumn.TRANSPOSE)) + + assert _key(SubColumn.TRANSPOSE) not in block.transposes + + def test_a_half_cut_row_leaves_its_note_out( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, GeneratorName.PULSE1) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert _key(SubColumn.INSTRUMENT) not in block.notes + + def test_a_wholly_cut_row_carries_the_cut( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, None) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + + def test_an_untouched_row_carries_its_emptiness( + self, + reader: TrackerBlockReader, + ) -> None: + """Every channel is equally empty, which is a reading they agree on.""" + block = reader.read(_column(None)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None + + +class TestExtent: + """A block states the rectangle it was read from, whatever the cells in it turned out to hold.""" + + def test_a_mixed_edge_column_keeps_its_place_in_the_block( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The last slot reads as nothing, and the extent is what still states the block reaches it.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2) + + block = reader.read( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(None, SubColumn.VOLUME), + ) + ) + + assert (block.first_slot, block.last_slot) == (0, 2) + assert block.slot_count == 3 + assert _key(SubColumn.VOLUME) not in block.volumes + + def test_the_offsets_are_measured_from_the_column_the_block_begins_in( + self, + reader: TrackerBlockReader, + ) -> None: + """A block beginning midway through a column keeps that column's base as its own zero. + + The offsets stay a whole column apart from the kind they address, which is what lands + each value in a subcolumn of its own kind wherever the block is written. + """ + block = reader.read( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), + last_slot=_slot(GeneratorName.TRIANGLE, SubColumn.INSTRUMENT), + ) + ) + + assert (block.first_slot, block.last_slot) == (1, 3) + assert set(block.transposes) == {(0, 1)} + assert set(block.volumes) == {(0, 2)} + assert set(block.notes) == {(0, 3)} diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 44e42ace..52987c99 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -1,52 +1,20 @@ -from pathlib import Path -from typing import List - -import numpy as np - from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME -from sampletones_core.instructions import PulseInstruction from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row -from sampletones_core.reconstructions import Reconstruction from sampletones_shared.constants.symbols import MIXED - -_LENGTH = 64 +from tests.suite.sequencer import sample_reconstruction def _controller() -> ProjectController: return ProjectController(ProjectManager()) -def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: - instructions = { - generator: [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - for generator in generators - } - approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators} - return Reconstruction.create( - approximation=np.zeros(_LENGTH, dtype=np.float32), - approximations=approximations, - instructions=instructions, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) - - def _row( controller: ProjectController, generator: GeneratorName, @@ -110,7 +78,7 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -125,7 +93,7 @@ def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> No controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -146,7 +114,7 @@ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) @@ -163,7 +131,7 @@ def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) @@ -241,7 +209,7 @@ def test_the_sample_column_shifts_the_sample_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -303,7 +271,7 @@ def test_one_placement_reports_the_samples_whole_span(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -351,7 +319,7 @@ def test_fills_only_used_generators(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) @@ -370,7 +338,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) stale = controller.add_sample( - _reconstruction([GeneratorName.PULSE2]), + sample_reconstruction([GeneratorName.PULSE2]), name="bass", ) pattern_index = controller.project.song.order[0][GeneratorName.PULSE2] @@ -386,7 +354,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: ) lead = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, lead.id) @@ -400,7 +368,7 @@ def test_none_sample_clears_the_whole_row(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -418,7 +386,7 @@ def test_synchronises_across_relevant_channels_even_without_instrument( controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -459,7 +427,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -505,7 +473,7 @@ def test_clamps_to_max_transpose(self) -> None: def test_preserves_instrument_and_volume(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") _place_instrument(controller, GeneratorName.PULSE1, sample.id) logic.adjust_volume(GeneratorName.PULSE1, 0, -1) @@ -549,7 +517,7 @@ def test_sample_transpose_shifts_only_relevant_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -566,7 +534,7 @@ def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -582,7 +550,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -595,7 +563,7 @@ def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -609,7 +577,7 @@ def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -623,7 +591,7 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 34644f67..48289f08 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -2,6 +2,7 @@ from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -134,6 +135,25 @@ def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: assert collapsed.region is None +class TestTargetRegion: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + region = _state(SubColumn.TRANSPOSE, row=4).target_region + + assert region is not None + assert (region.first_row, region.last_row) == (4, 4) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + + assert selected.target_region == selected.region + + def test_a_grid_with_no_cursor_targets_nothing(self) -> None: + assert TrackerInputState().target_region is None + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py new file mode 100644 index 00000000..4fbbe407 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -0,0 +1,80 @@ +from typing import List, Optional + +import pytest + +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +ROW_COUNT = 64 +CURSOR_ROW = 4 + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +def _panel( + monkeypatch: pytest.MonkeyPatch, + regions: List[TrackerRegion], + *, + generator: Optional[GeneratorName] = GeneratorName.PULSE1, + subcolumn: SubColumn = SubColumn.INSTRUMENT, +) -> GUISequencerTrackerPanel: + """A tracker panel reporting the blocks it copies, with its grid left unbuilt. + + Applying a state draws into DearPyGui, which has no table here, so the draw is left out and + the gesture is read from the regions the copy hook receives. + """ + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._current_row_count = ROW_COUNT + panel.on_copy_block = regions.append + monkeypatch.setattr(panel, "_apply_state", lambda state: None) + return panel + + +class TestTrackerCopyKey: + def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert regions == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + + def test_a_cursor_alone_copies_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert regions[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert regions[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + + def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions) + panel._input_state = TrackerInputState() + + assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert regions == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index bd79ee36..b776b479 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -2,14 +2,14 @@ import pytest +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.paths import ( BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY, PALETTES_DIRECTORY, ) -from sampletones_application.layout.config import LayoutConfig -from sampletones_application.layout.loader import load_layout_config from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index e755baf8..31af3d6f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -341,10 +341,10 @@ def test_a_note_key_types_into_the_cell_under_the_cursor(self, monkeypatch: pyte assert states[-1].pending == "C" def test_a_modified_key_reaches_the_application(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Ctrl+C carries no tracker action, so cell entry keeps the plain key alone.""" + """Ctrl+D opens the display settings, so cell entry keeps the plain hex key alone.""" panel = _panel() states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert panel._on_key_pressed(_press("Ctrl+D")) is False assert states == [] From a6c3a0ec148d6a9c7400b137a6614b747d01b29e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 21:39:29 +0200 Subject: [PATCH 09/28] Added: tracker block cut, paste and delete --- .../categories/elements/settings.py | 2 + .../coordinators/tabs/sequencer.py | 33 +- .../logic/history/action.py | 3 + .../logic/sequencer/history_detail.py | 32 ++ .../logic/sequencer/tracker/__init__.py | 2 + .../logic/sequencer/tracker/tracker.py | 4 + .../logic/sequencer/tracker/writer.py | 144 ++++++ .../ui/panels/sequencer/tracker.py | 45 +- .../utils/gui/shortcuts/ids.py | 2 + .../keybindings/default.yaml | 2 + src/sampletones_config/keybindings/macos.yaml | 2 + src/sampletones_config/lang/en.yaml | 5 + tests/suite/sequencer.py | 225 ++++++++- .../coordinators/tabs/test_sequencer.py | 108 ++++- .../logic/sequencer/test_history_detail.py | 90 ++-- .../logic/sequencer/tracker/test_writer.py | 441 ++++++++++++++++++ .../ui/panels/sequencer/test_block_keys.py | 122 ++++- 17 files changed, 1196 insertions(+), 66 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/tracker/writer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index e4257bc6..74a7c16d 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -124,6 +124,8 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" TRACKER_COPY_BLOCK = "tracker_copy_block" + TRACKER_CUT_BLOCK = "tracker_cut_block" + TRACKER_PASTE_BLOCK = "tracker_paste_block" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 182fdedf..39b3bc2d 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -37,6 +37,7 @@ from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, + TrackerBlockWriter, ) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters @@ -86,7 +87,7 @@ HistoryEntryViewModel, HistoryViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -185,6 +186,7 @@ def __init__( self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) + self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -447,14 +449,41 @@ def _wire_block_callbacks(self) -> None: A copy reads the project and leaves it as it stands, so it is wired straight through instead of through :meth:`_undoable`: a transaction over it would record an entry the - history has nothing to restore for. + history has nothing to restore for. The three gestures that do write are whole ones, each + recording the single entry that takes the grid back to where it stood. """ self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block + self._sequencer_tracker_panel.on_cut_block = self._undoable( + HistoryAction.CUT_BLOCK, + self._cut_tracker_block, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_delete_block = self._undoable( + HistoryAction.DELETE_BLOCK, + self._tracker_block_writer.clear, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_paste_block = self._undoable( + HistoryAction.PASTE_BLOCK, + self._paste_tracker_block, + detail=self._history_detail.tracker_paste, + ) def _on_tracker_copy_block(self, region: TrackerRegion) -> None: """Puts the tracker's selected block on the clipboard, for a paste to replay.""" self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + def _cut_tracker_block(self, region: TrackerRegion) -> None: + """Takes the block a region covers onto the clipboard, then empties what it covered.""" + self._on_tracker_copy_block(region) + self._tracker_block_writer.clear(region) + + def _paste_tracker_block(self, cell: TrackerCell) -> None: + """Writes the block the tracker last copied at a cell, while a copy has been made.""" + block = self._clipboard.tracker_block + if block is not None: + self._tracker_block_writer.write(block, cell) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 9a40d523..6a607474 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -15,6 +15,9 @@ class HistoryAction(AbstractElement): CLEAR_SUBCOLUMN = "clear_subcolumn" ADJUST_TRANSPOSE = "adjust_transpose" ADJUST_VOLUME = "adjust_volume" + CUT_BLOCK = "cut_block" + PASTE_BLOCK = "paste_block" + DELETE_BLOCK = "delete_block" ADD_FRAME = "add_frame" REMOVE_FRAME = "remove_frame" DUPLICATE_FRAME = "duplicate_frame" diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 354e92a3..4b850bdd 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -2,6 +2,7 @@ from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, @@ -20,6 +21,7 @@ Segments = HistoryDetail _ARROW: Final[str] = ">" +_RANGE: Final[str] = "-" _SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: "i", SubColumn.TRANSPOSE: "t", @@ -154,6 +156,18 @@ def adjust_volume( segments.append(self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME)) return tuple(segments) + def tracker_block(self, region: TrackerRegion) -> Segments: + """Reads as the frame, the channels a block spans and the rows it covers.""" + return ( + self._frame(self._tracker_logic.frame_index), + self._channel(self._region_generators(region)), + self._row_range(region.first_row, region.last_row), + ) + + def tracker_paste(self, cell: TrackerCell) -> Segments: + """Reads as the cell a block was written from, the one place a paste chooses.""" + return self._location(cell.row, cell.generator, GeneratorName.items()) + def add_frame(self, position: int) -> Segments: return (self._frame(position + 1),) @@ -304,6 +318,24 @@ def _row(self, index: int) -> HistoryDetailSegment: role=HistoryDetailRole.ROW, ) + def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment: + """Reads a span of rows as one row token, a single row standing as its own index.""" + if first_row == last_row: + return self._row(first_row) + + return HistoryDetailSegment( + text=f"{display_id(first_row)}{_RANGE}{display_id(last_row)}", + role=HistoryDetailRole.ROW, + ) + + def _region_generators(self, region: TrackerRegion) -> List[GeneratorName]: + """The channels a region reaches, the sample column standing for every one it governs.""" + covered = {slot.generator for slot in region.slots} + if None in covered: + return GeneratorName.items() + + return [generator for generator in GeneratorName.items() if generator in covered] + def _channel(self, generators: List[GeneratorName]) -> HistoryDetailSegment: return HistoryDetailSegment( text=abbreviate_generator_names(generators), diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py index 976a0d99..5e3c9ecc 100644 --- a/src/sampletones_application/logic/sequencer/tracker/__init__.py +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -1,10 +1,12 @@ from .block import BlockNote, TrackerBlock from .reader import TrackerBlockReader from .tracker import SequencerTrackerLogic +from .writer import TrackerBlockWriter __all__ = [ "BlockNote", "SequencerTrackerLogic", "TrackerBlock", "TrackerBlockReader", + "TrackerBlockWriter", ] diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index ec99b48f..9cf7c00a 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -518,6 +518,10 @@ def select_frame(self, frame_index: int) -> None: self._frame_index = frame_index self.push_tracker() + def holds_sample(self, sample_id: str) -> bool: + """Whether the project holds the sample a note names, which is what makes the note placeable.""" + return self._controller.project.samples.get(sample_id) is not None + def used_generators(self, sample_id: str) -> List[GeneratorName]: """The channels a sample provides instructions for, empty when it is unknown.""" sample = self._controller.project.samples.get(sample_id) diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py new file mode 100644 index 00000000..5849cc7a --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -0,0 +1,144 @@ +from typing import Callable, Dict, Optional, TypeVar + +from sampletones_application.logic.sequencer.tracker.block import ( + BlockKey, + BlockNote, + TrackerBlock, +) +from sampletones_application.logic.sequencer.tracker.tracker import ( + SequencerTrackerLogic, +) +from sampletones_application.view_model.sequencer.region import ( + TrackerCell, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff + +ValueT = TypeVar("ValueT") + + +class TrackerBlockWriter: + """Replays a block into the shown frame, and empties the cells a region covers. + + Every cell reaches the grid through the single-slot edit that already governs it, so a paste + lands exactly the writes a reader typing the same values by hand would make, sample column + included. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def write(self, block: TrackerBlock, cell: TrackerCell) -> None: + """Writes a block anchored at a cell, the cell supplying the column and the block the rest. + + Each kind of subcolumn is written in a pass of its own, notes first: a note through the + sample column decides the whole row, so the transposes and volumes sharing that row land + on top of the channels it settled. + """ + base = column_slot_base(cell.generator) + self._write_pass(block.notes, cell, base, self._write_note) + self._write_pass(block.transposes, cell, base, self._write_transpose) + self._write_pass(block.volumes, cell, base, self._write_volume) + + def clear(self, region: TrackerRegion) -> None: + """Empties every subcolumn a region covers, each by the rule its own column follows.""" + for row_index in region.rows: + for slot in region.slots: + self._tracker.clear_cell_subcolumn( + row_index, + slot.generator, + slot.subcolumn, + ) + + def _write_pass( + self, + values: Dict[BlockKey, ValueT], + cell: TrackerCell, + base: int, + write: Callable[[int, Optional[GeneratorName], ValueT], None], + ) -> None: + """Writes one kind of subcolumn across the block, dropping what falls outside the grid. + + Keys are taken in reading order, so a row's sample column is written before the channels + beside it and the more specific write is the one that stands. A row past the frame's last + or a slot past the last column is left out, which clips a block at the edge rather than + wrapping it around. + """ + row_count = self._tracker.frame_row_count() + for (row_offset, slot_offset), value in sorted(values.items()): + row_index = cell.row + row_offset + slot_index = base + slot_offset + if row_index >= row_count or slot_index >= SLOT_COUNT: + continue + + write(row_index, slot_from_flat(slot_index).generator, value) + + def _write_note( + self, + row_index: int, + generator: Optional[GeneratorName], + note: Optional[BlockNote], + ) -> None: + """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither. + + A sample the project no longer holds leaves the cell as it stands, so a block outliving + the project it was read from writes the notes that still name something and passes over + the rest. + """ + match note: + case NoteOff(): + self._tracker.cut_note(row_index, generator) + case str() as sample_id: + if self._tracker.holds_sample(sample_id): + self._tracker.place_note(row_index, generator, sample_id) + case None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.INSTRUMENT, + ) + + def _write_transpose( + self, + row_index: int, + generator: Optional[GeneratorName], + transpose: Optional[int], + ) -> None: + if transpose is None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.TRANSPOSE, + ) + else: + self._tracker.set_cell_subcolumn( + row_index, + generator, + transpose=transpose, + ) + + def _write_volume( + self, + row_index: int, + generator: Optional[GeneratorName], + volume: Optional[int], + ) -> None: + if volume is None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.VOLUME, + ) + else: + self._tracker.set_cell_subcolumn( + row_index, + generator, + volume=volume, + ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 50736240..040ff536 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -80,7 +80,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -115,7 +115,8 @@ OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] -OnCopyBlockCallback = Callable[[TrackerRegion], None] +OnBlockRegionCallback = Callable[[TrackerRegion], None] +OnPasteBlockCallback = Callable[[TrackerCell], None] VOLUME_FINE_STEP: Final[int] = 1 @@ -185,7 +186,10 @@ def __init__( self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None self.on_adjust_transpose: Optional[OnAdjustCallback] = None self.on_adjust_volume: Optional[OnAdjustCallback] = None - self.on_copy_block: Optional[OnCopyBlockCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -1486,17 +1490,28 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True def _block_action(self, shortcut_id: ShortcutId) -> bool: - """Acts on the selected block, reporting whether the action was one of its gestures.""" + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties what the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._copy_block() + self._region_gesture(self.on_copy_block) + case ShortcutId.TRACKER_CUT_BLOCK: + self._region_gesture(self.on_cut_block) + case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: + self._region_gesture(self.on_delete_block) + case ShortcutId.TRACKER_PASTE_BLOCK: + self._paste_block() case _: return False return True - def _copy_block(self) -> None: - """Hands the selected block out to be copied, the cell under the cursor standing for itself. + def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: + """Hands the selected block out to a gesture, the cell under the cursor standing for itself. A partial entry is committed first, so the block carries the value the reader has just finished typing. @@ -1505,7 +1520,21 @@ def _copy_block(self) -> None: self._apply_state(state) region = state.target_region if region is not None: - self.call(self.on_copy_block, region) + self.call(callback, region) + + def _paste_block(self) -> None: + """Names the cell a block is written from, which is wherever the cursor stands.""" + state = self._committed_state() + self._apply_state(state) + cursor = state.cursor + if cursor is not None: + self.call( + self.on_paste_block, + TrackerCell( + row=cursor.row, + generator=cursor.generator, + ), + ) def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 7210de0d..631f5225 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -142,6 +142,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ShortcutCategory.TRACKER, ) TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) + TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) + TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index cd6f581f..8159d5bb 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -102,6 +102,8 @@ bindings: TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} TrackerCopyBlock: {combination: "Ctrl+C"} + TrackerCutBlock: {combination: "Ctrl+X"} + TrackerPasteBlock: {combination: "Ctrl+V"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index b6a8cea6..9a018793 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -102,6 +102,8 @@ bindings: TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} TrackerCopyBlock: {combination: "Cmd+C"} + TrackerCutBlock: {combination: "Cmd+X"} + TrackerPasteBlock: {combination: "Cmd+V"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a388681d..8f0d6df1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -530,6 +530,9 @@ sequencer.history.label.clear_row: "Clear row" sequencer.history.label.clear_subcolumn: "Clear column" sequencer.history.label.adjust_transpose: "Adjust transpose" sequencer.history.label.adjust_volume: "Adjust volume" +sequencer.history.label.cut_block: "Cut selection" +sequencer.history.label.paste_block: "Paste selection" +sequencer.history.label.delete_block: "Delete selection" sequencer.history.label.add_frame: "Add frame" sequencer.history.label.remove_frame: "Remove frame" sequencer.history.label.duplicate_frame: "Duplicate frame" @@ -806,6 +809,8 @@ settings.keybindings.label.tracker_extend_selection_right: "Extend selection rig settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" settings.keybindings.label.tracker_copy_block: "Copy selection" +settings.keybindings.label.tracker_cut_block: "Cut selection" +settings.keybindings.label.tracker_paste_block: "Paste selection" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 656f2587..60f3a63c 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -1,14 +1,32 @@ from pathlib import Path -from typing import Final, Sequence +from typing import Dict, Final, List, Optional, Sequence, Tuple import numpy as np +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock +from sampletones_application.logic.sequencer.tracker.block import BlockKey +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.instructions import PulseInstruction +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import NoteCommand from sampletones_core.reconstructions import Reconstruction +from sampletones_core.utils.display import ( + BLANK, + NOTE_BLANK, + NOTE_OFF, + display_id, +) +from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS SAMPLE_LENGTH: Final[int] = 64 +COLUMN_SEPARATOR: Final[str] = "|" +UNKNOWN_SAMPLE: Final[str] = "!!" +UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds" def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction: @@ -38,3 +56,208 @@ def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction coefficient=1.0, audio_filepath=Path("/dev/null"), ) + + +def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]: + """Every row of the frame shown, each read as the four channel cells the grid draws. + + A row is written the way it appears on screen, so an expectation and a screenshot read alike. + The sample column is left out because it holds nothing of its own: it summarises these four, + and stating it again would pin the summary rather than what a gesture wrote. + """ + grid = tracker_logic.build_grid() + return tuple( + f" {COLUMN_SEPARATOR} ".join(row.cells[generator].label for generator in GeneratorName.items()) + for row in grid.rows + ) + + +def render_slots( + controller: ProjectController, + frame_index: int, +) -> str: + """The pattern each channel plays at a frame, which is what tells a blank pattern from none. + + A frame renders the same either way, so this is the reading that shows a write materialising a + pattern the channel had not held before. + """ + frame = controller.project.song.order[frame_index] + return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items()) + + +def parse_block( + rows: Sequence[str], + *, + first_subcolumn: SubColumn, + sample_ids: Sequence[str], +) -> TrackerBlock: + """Reads a block written the way the grid draws it, one line per row. + + Tokens run from ``first_subcolumn`` and cycle through the subcolumns in order, so a line + carries ``|`` at each column boundary it crosses and the bars are held against the subcolumn + the block begins on. A ``?`` states that the block says nothing about that cell, which is what + leaves it out of the maps entirely. + + A note names its sample by the position the grid prints, resolved through ``sample_ids``; + ``!!`` names a sample no project holds. + + Raises: + ValueError: if the rows differ in width, or a bar falls where no column boundary does. + """ + first_slot = SUBCOLUMNS.index(first_subcolumn) + notes: Dict[BlockKey, Optional[BlockNote]] = {} + transposes: Dict[BlockKey, Optional[int]] = {} + volumes: Dict[BlockKey, Optional[int]] = {} + lines = [_tokens(line, first_slot) for line in rows] + widths = {len(tokens) for tokens in lines} + if len(widths) != 1: + raise ValueError(f"A block's rows differ in width: {sorted(widths)}") + + for row_offset, tokens in enumerate(lines): + for offset, token in enumerate(tokens): + slot_offset = first_slot + offset + key = (row_offset, slot_offset) + if token == MIXED: + continue + + match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]: + case SubColumn.INSTRUMENT: + notes[key] = parse_note(token, sample_ids) + case SubColumn.TRANSPOSE: + transposes[key] = parse_transpose(token) + case SubColumn.VOLUME: + volumes[key] = parse_volume(token) + + return TrackerBlock( + row_count=len(rows), + first_slot=first_slot, + last_slot=first_slot + widths.pop() - 1, + notes=notes, + transposes=transposes, + volumes=volumes, + ) + + +def fill_frame( + tracker_logic: SequencerTrackerLogic, + rows: Sequence[str], + *, + sample_ids: Sequence[str], +) -> None: + """Writes a frame stated the way the grid draws it, one channel cell at a time. + + Each cell reaches its own channel, so a setup states the frame it wants while the sample + column's fan-out stays out of it — which leaves the gesture under test the only thing that + exercised it. + """ + for row_index, line in enumerate(rows): + for generator, cell in zip(GeneratorName.items(), line.split(COLUMN_SEPARATOR)): + _fill_cell( + tracker_logic, + row_index, + generator, + cell.split(), + sample_ids, + ) + + +def parse_note( + token: str, + sample_ids: Sequence[str], +) -> Optional[BlockNote]: + """The note a token names: a sample by the position it prints, a cut, or emptiness.""" + if token == display_id(None): + return None + + if token == NOTE_OFF: + return NoteOff() + + if token == UNKNOWN_SAMPLE: + return UNKNOWN_SAMPLE_ID + + return sample_ids[int(token, 16)] + + +def parse_transpose(token: str) -> Optional[int]: + if token == NOTE_BLANK: + return None + + magnitude = int(token[1:], 16) + return -magnitude if token.startswith(MINUS) else magnitude + + +def parse_volume(token: str) -> Optional[int]: + if token == BLANK: + return None + + return int(token, 16) + + +def _fill_cell( + tracker_logic: SequencerTrackerLogic, + row_index: int, + generator: GeneratorName, + tokens: Sequence[str], + sample_ids: Sequence[str], +) -> None: + """Writes the values one channel cell states, passing over a cell that states none. + + A cell is written whole where it carries anything, so the row it lands on materialises exactly + once however many of its subcolumns hold a value. + """ + note = parse_note(tokens[0], sample_ids) + transpose = parse_transpose(tokens[1]) + volume = parse_volume(tokens[2]) + if note is None and transpose is None and volume is None: + return + + tracker_logic.set_row( + generator, + row_index, + command=_command(note, generator), + transpose=transpose, + volume=volume, + ) + + +def _command( + note: Optional[BlockNote], + generator: GeneratorName, +) -> Optional[NoteCommand]: + """The command a note becomes in the channel it is written to, which is what carries its pitch.""" + match note: + case NoteOff(): + return note + case str() as sample_id: + return Instrument(sample_id=sample_id, generator_name=generator) + case None: + return None + + +def _tokens(line: str, first_slot: int) -> List[str]: + """The values a line states, checked against the columns a block starting at ``first_slot`` spans. + + Raises: + ValueError: if a bar falls where no column boundary does. + """ + groups = [group.split() for group in line.split(COLUMN_SEPARATOR)] + tokens = [token for group in groups for token in group] + widths = [len(group) for group in groups] + expected = _column_widths(first_slot, len(tokens)) + if widths != expected: + raise ValueError(f"A block line's columns hold {widths} values where its origin spans {expected}: {line!r}") + + return tokens + + +def _column_widths(first_slot: int, count: int) -> List[int]: + """How many values each column a block spans contributes, the first starting part way in.""" + widths: List[int] = [] + width = len(SUBCOLUMNS) - first_slot + remaining = count + while remaining > 0: + widths.append(min(width, remaining)) + remaining -= widths[-1] + width = len(SUBCOLUMNS) + + return widths diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 13864e48..d7f88af2 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -20,9 +20,11 @@ SequencerChannelsLogic, ) from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, + TrackerBlockWriter, ) from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN @@ -31,7 +33,7 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import SampleSelection from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel @@ -1330,10 +1332,11 @@ def test_player_returns_the_guarded_wrapper( @pytest.fixture def block_coordinator() -> SequencerTabCoordinator: - """A coordinator whose copy path is real, from the tracker logic through to the clipboard. + """A coordinator whose block path is real, from the tracker logic through to the clipboard. A real manager observes the same controller production wires it to, so a test reads the - entries a gesture actually records. + entries a gesture actually records, and the hooks are the ones ``_wire_block_callbacks`` + assigns rather than wrappers a test built to look like them. """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) @@ -1344,9 +1347,28 @@ def block_coordinator() -> SequencerTabCoordinator: instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) + instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) + instance._history_detail = SequencerHistoryDetail( + instance._sequencer_tracker_logic, + MagicMock(), + ) + instance._sequencer_tracker_panel = MagicMock() + instance._wire_block_callbacks() return instance +def _place_transpose( + coordinator: SequencerTabCoordinator, + transpose: int, +) -> None: + """Puts one value in the frame, through the same wrapper an edit reaches the history by.""" + edit = coordinator._undoable( + HistoryAction.EDIT_ROW, + coordinator._sequencer_tracker_logic.write_cell, + ) + edit(0, GeneratorName.PULSE1, None, transpose, None) + + class TestBlockCopy: def test_a_copy_fills_the_clipboard_with_the_block_it_covers( self, @@ -1372,14 +1394,84 @@ def test_a_copy_leaves_the_history_stack_as_it_stands( ) -> None: """A gesture that only reads the project records nothing, where the edit beside it does.""" coordinator = block_coordinator - edit = coordinator._undoable( - HistoryAction.EDIT_ROW, - coordinator._sequencer_tracker_logic.write_cell, - ) - edit(0, GeneratorName.PULSE1, None, 5, None) + _place_transpose(coordinator, 5) recorded = len(coordinator._history.entries) coordinator._on_tracker_copy_block(PULSE1_CELL) assert recorded > 0 assert len(coordinator._history.entries) == recorded + + +class TestBlockEdits: + """Each gesture that writes records the one entry that takes the grid back.""" + + def test_a_cut_takes_the_block_and_empties_what_it_covered( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + block = coordinator._clipboard.tracker_block + assert block is not None + assert block.transposes[(0, 1)] == 5 + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + + def test_a_cut_records_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK + + def test_a_delete_empties_the_region_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_delete_block(PULSE1_CELL) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK + + def test_a_paste_writes_the_copied_block_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE2, 1).transpose == 5 + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + def test_a_paste_with_nothing_copied_records_nothing( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A transaction over a gesture that writes nothing commits nothing, so an empty clipboard + leaves the history where it stood.""" + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + + assert len(coordinator._history.entries) == recorded diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index f1395a35..eed426e9 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -1,8 +1,6 @@ -from pathlib import Path from typing import List, Tuple from unittest.mock import MagicMock -import numpy as np import pytest from sampletones_application.logic.project.controller import ProjectController @@ -12,6 +10,8 @@ ) from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetailRole, @@ -19,12 +19,8 @@ HistoryDetailWord, HistoryDetailWordSegment, ) -from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.instructions import PulseInstruction -from sampletones_core.reconstructions import Reconstruction - -_LENGTH = 64 +from tests.suite.sequencer import sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -33,21 +29,6 @@ def _controller() -> ProjectController: return ProjectController(ProjectManager()) -def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: - instructions = { - generator: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] for generator in generators - } - approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators} - return Reconstruction.create( - approximation=np.zeros(_LENGTH, dtype=np.float32), - approximations=approximations, - instructions=instructions, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) - - def _formatter(controller: ProjectController) -> SequencerHistoryDetail: tracker_logic = SequencerTrackerLogic(controller) samples_logic = SequencerSamplesLogic( @@ -66,8 +47,8 @@ def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]: class TestTrackerDetails: def test_edit_row_single_channel_places_sample(self) -> None: controller = _controller() - controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") - target = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="bass") + controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") + target = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="bass") formatter = _formatter(controller) segments = formatter.edit_row(10, GeneratorName.PULSE1, target.id, None, None) @@ -83,7 +64,7 @@ def test_edit_row_single_channel_places_sample(self) -> None: def test_edit_row_sample_column_lists_the_samples_channels(self) -> None: controller = _controller() sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]), name="chord", ) formatter = _formatter(controller) @@ -137,6 +118,53 @@ def test_clear_subcolumn_names_the_column(self) -> None: ("v", HistoryDetailRole.VOLUME), ] + def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_block( + TrackerRegion( + first_row=4, + last_row=11, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + ) + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("Pp", HistoryDetailRole.CHANNEL), + ("04-0B", HistoryDetailRole.ROW), + ] + + def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_block( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(None, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + ) + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("PpTN", HistoryDetailRole.CHANNEL), + ("00", HistoryDetailRole.ROW), + ] + + def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_paste(TrackerCell(row=3, generator=GeneratorName.NOISE)) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("N", HistoryDetailRole.CHANNEL), + ("03", HistoryDetailRole.ROW), + ] + def test_adjust_transpose_shows_signed_delta(self) -> None: controller = _controller() formatter = _formatter(controller) @@ -205,7 +233,7 @@ def test_add_sample_shows_the_name(self) -> None: def test_remove_sample_shows_position_and_name(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.remove_sample(sample.id)) == [ @@ -215,7 +243,7 @@ def test_remove_sample_shows_position_and_name(self) -> None: def test_replace_sample_shows_position_and_both_names(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [ @@ -236,7 +264,7 @@ def test_rename_sample_shows_old_and_new(self) -> None: def test_move_sample_shows_source_position_and_destination(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.move_sample(sample.id, 5)) == [ @@ -247,7 +275,7 @@ def test_move_sample_shows_source_position_and_destination(self) -> None: def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) on_segments = formatter.set_sample_loop(sample.id, True) @@ -272,7 +300,7 @@ def test_value_wraps_a_number(self) -> None: class TestReconstructionDetails: def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") formatter = _formatter(controller) segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, FeatureKey.VOLUME) @@ -301,7 +329,7 @@ def test_every_feature_has_a_letter_and_a_colour_role( role: HistoryDetailRole, ) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") formatter = _formatter(controller) segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, feature_key) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py new file mode 100644 index 00000000..349dc460 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -0,0 +1,441 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, + TrackerBlockWriter, +) +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import ( + fill_frame, + parse_block, + render_frame, + render_slots, + sample_reconstruction, +) + +FRAME_ROWS: Final[int] = 4 +EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +LEAD: Final[str] = "00" +BASS: Final[str] = "01" + + +@dataclass(frozen=True, kw_only=True) +class Grid: + """A four-row frame with two samples, the state every paste case starts from.""" + + controller: ProjectController + logic: SequencerTrackerLogic + writer: TrackerBlockWriter + sample_ids: Tuple[str, ...] + + +@pytest.fixture +def grid() -> Grid: + """A frame short enough for a case to state whole, holding a sample over two channels and one + over a third. + + Which channels a sample governs is what the sample column fans a write out over, so the pair + covers both readings: a write that reaches some channels and clears the rest, and a note + written into a channel its own reconstruction leaves out. + """ + controller = ProjectController(ProjectManager()) + logic = SequencerTrackerLogic(controller) + logic.set_rows_per_pattern(FRAME_ROWS) + lead = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="lead", + ) + bass = controller.add_sample( + sample_reconstruction([GeneratorName.TRIANGLE]), + name="bass", + ) + return Grid( + controller=controller, + logic=logic, + writer=TrackerBlockWriter(logic), + sample_ids=(lead.id, bass.id), + ) + + +class TestPaste(BaseTestSuite): + """What a block writes where it lands, stated as the whole frame it leaves behind. + + A block carries the subcolumn offsets it was read at while the cell it is written from supplies + only a row and a column, so every case states its origin as that pair: which subcolumn the + cursor happened to stand in cannot reach the result. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + first_subcolumn: SubColumn + origin: TrackerCell + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a block keeps its own kinds wherever the cursor stands", + block=("+02 8",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=1, generator=GeneratorName.PULSE2), + expected=( + EMPTY, + ".. ... . | .. +02 8 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a sample through the sample column reaches its channels and clears the rest", + frame=(".. ... . | .. ... . | .. ... . | .. ... 5",), + block=(LEAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 ... . | 00 ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel beside the sample column overwrites what it settled", + block=(f"{LEAD} ... . | {BASS}",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "01 ... . | 00 ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a block read from the sample column writes one channel when written to one", + block=(LEAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.TRIANGLE), + expected=( + ".. ... . | .. ... . | 00 ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a mixed cell leaves its target as it stands while its neighbours clear theirs", + frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",), + block=(".. ? .",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. +03 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="an explicit zero transpose lands while an empty one clears", + frame=(".. +03 . | .. +05 . | .. ... . | .. ... .",), + block=("+00 ? | ? ...",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. +00 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a cut through the sample column cuts every channel", + frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), + block=("~~",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a note naming an absent sample writes nothing into a channel", + frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), + block=("!! ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + "00 +02 5 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a note naming an absent sample clears nothing through the sample column", + frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",), + block=("!!",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 ... . | 00 ... . | .. ... . | .. ... 5", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="an empty instrument through the sample column clears every channel", + frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",), + block=("..",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=(EMPTY, EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="an empty transpose through an ungoverned sample column clears every channel", + frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",), + block=("...",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=None), + expected=(EMPTY, EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="a transpose through a governed sample column reaches its channels alone", + frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), + block=("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 +02 . | 00 +02 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a silent volume writes zero rather than emptiness", + block=("0",), + first_subcolumn=SubColumn.VOLUME, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. ... 0 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="rows past the frame's last are dropped rather than wrapped", + block=("+01", "+02", "+03"), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=2, generator=GeneratorName.PULSE1), + expected=( + EMPTY, + EMPTY, + ".. +01 . | .. ... . | .. ... . | .. ... .", + ".. +02 . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="slots past the last column are dropped rather than wrapped", + block=(f"{LEAD} ... . | {BASS}",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.NOISE), + expected=( + ".. ... . | .. ... . | .. ... . | 00 ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a wholly mixed block leaves the frame as it stands", + frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), + block=("? ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + "00 +02 5 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a wholly empty block empties what it covers", + frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",), + block=(".. ... .",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. ... . | 00 +02 5 | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_paste( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + block = parse_block( + test_case.block, + first_subcolumn=test_case.first_subcolumn, + sample_ids=grid.sample_ids, + ) + + grid.writer.write(block, test_case.origin) + + assert render_frame(grid.logic) == test_case.expected + + +class TestSingleSlotEquivalence: + """A block of one cell writes what typing that cell writes, which is what makes a paste + explainable as the edits it is made of.""" + + def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) -> None: + block = parse_block( + ("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + pasted = render_frame(grid.logic) + + typed = _typed_grid() + typed.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=2) + + assert pasted == render_frame(typed) + + +class TestClear: + """What a delete empties, which is every subcolumn its region covers and nothing beside.""" + + def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ("00 +02 5 | 00 +03 6 | .. ... . | .. ... .",), + sample_ids=grid.sample_ids, + ) + + grid.writer.clear( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + ) + ) + + assert render_frame(grid.logic)[0] == "00 ... . | .. +03 6 | .. ... . | .. ... ." + + def test_a_region_over_the_sample_column_empties_the_channels_it_governs(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ("00 +02 5 | 00 +02 5 | .. ... . | .. ... 5",), + sample_ids=grid.sample_ids, + ) + + grid.writer.clear( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + ) + ) + + assert render_frame(grid.logic)[0] == "00 +02 . | 00 +02 . | .. ... . | .. ... 5" + + +class TestRoundTrip: + """Reading a region, emptying it and writing the block back leaves the frame it came from.""" + + def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ( + "00 +02 5 | 00 ... . | .. ... . | ~~ ... 3", + ".. ... . | 01 +00 0 | .. +07 . | .. ... .", + ), + sample_ids=grid.sample_ids, + ) + before = render_frame(grid.logic) + region = TrackerRegion( + first_row=0, + last_row=1, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + ) + block = TrackerBlockReader(grid.logic).read(region) + + grid.writer.clear(region) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + + assert render_frame(grid.logic) == before + + +class TestMaterialisation: + """A paste reaches a channel holding no pattern by giving it one, the way an edit does.""" + + def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Grid) -> None: + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + assert render_slots(grid.controller, position) == ".. .. .. .." + + block = parse_block( + ("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + + assert render_slots(grid.controller, position) == ".. 01 .. .." + assert render_frame(grid.logic)[0] == ".. ... . | .. +02 . | .. ... . | .. ... ." + + def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: Grid) -> None: + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + + block = parse_block( + ("? ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + + assert render_slots(grid.controller, position) == ".. .. .. .." + + +def _typed_grid() -> SequencerTrackerLogic: + """A second frame of the same shape, reached through the single-slot edits alone.""" + logic = SequencerTrackerLogic(ProjectController(ProjectManager())) + logic.set_rows_per_pattern(FRAME_ROWS) + return logic diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 4fbbe407..300fb5c1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -1,13 +1,15 @@ -from typing import List, Optional +from dataclasses import dataclass, field +from typing import List, Optional, Tuple import pytest +from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -17,6 +19,17 @@ CURSOR_ROW = 4 +@dataclass +class Gestures: + """What each block hook was handed, which is the whole of what a press reaches the grid with.""" + + copied: List[TrackerRegion] = field(default_factory=list) + cut: List[TrackerRegion] = field(default_factory=list) + deleted: List[TrackerRegion] = field(default_factory=list) + pasted: List[TrackerCell] = field(default_factory=list) + cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -25,33 +38,38 @@ def _press(text: str) -> KeyEvent: def _panel( monkeypatch: pytest.MonkeyPatch, - regions: List[TrackerRegion], + gestures: Gestures, *, generator: Optional[GeneratorName] = GeneratorName.PULSE1, subcolumn: SubColumn = SubColumn.INSTRUMENT, ) -> GUISequencerTrackerPanel: - """A tracker panel reporting the blocks it copies, with its grid left unbuilt. + """A tracker panel reporting the gestures it fires, with its grid left unbuilt. Applying a state draws into DearPyGui, which has no table here, so the draw is left out and - the gesture is read from the regions the copy hook receives. + each gesture is read from what its hook receives. """ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) panel._current_row_count = ROW_COUNT - panel.on_copy_block = regions.append + panel._editable_cells = EditableCells() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel class TestTrackerCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions) + gestures = Gestures() + panel = _panel(monkeypatch, gestures) panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) assert panel._on_key_pressed(_press("Ctrl+C")) is True - assert regions == [ + assert gestures.copied == [ TrackerRegion( first_row=CURSOR_ROW, last_row=CURSOR_ROW + 2, @@ -64,17 +82,89 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions, subcolumn=SubColumn.VOLUME) + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) assert panel._on_key_pressed(_press("Ctrl+C")) is True - assert regions[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) - assert regions[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert gestures.copied[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert gestures.copied[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions) + gestures = Gestures() + panel = _panel(monkeypatch, gestures) panel._input_state = TrackerInputState() assert panel._on_key_pressed(_press("Ctrl+C")) is False - assert regions == [] + assert gestures.copied == [] + + +class TestTrackerCutKey: + def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+X")) is True + assert gestures.cut == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + assert gestures.copied == [] + + +class TestTrackerPasteKey: + def test_a_paste_names_the_cell_the_cursor_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The cell carries a row and a column alone, so the subcolumn under the cursor is left + for the block to decide.""" + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=GeneratorName.PULSE1)] + + def test_the_sample_column_is_a_cell_a_block_lands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=None)] + + +class TestTrackerDeleteKey: + def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + assert gestures.cleared == [] + + def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Delete already means something without a selection, so that meaning is what it keeps.""" + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [] + assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] From e859b43d1efbb1253d979b0f5a00ab41ea5d162f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 22:48:45 +0200 Subject: [PATCH 10/28] Added: context menu operations --- CHANGELOG.md | 1 + docs/development/bugs-and-todos.md | 3 +- docs/development/sequencer-blocks.md | 131 ++++++ docs/guide/sequencer.md | 45 +- docs/index.md | 1 + .../categories/elements/sequencer.py | 8 + .../categories/elements/settings.py | 3 + .../coordinators/tabs/sequencer.py | 59 ++- .../logic/sequencer/clipboard.py | 10 + .../logic/sequencer/history_detail.py | 54 ++- .../logic/sequencer/order/__init__.py | 11 + .../logic/sequencer/order/block.py | 25 ++ .../logic/sequencer/{ => order}/order.py | 31 ++ .../logic/sequencer/order/reader.py | 55 +++ .../logic/sequencer/order/writer.py | 71 ++++ .../ui/panels/sequencer/input/order.py | 21 + .../ui/panels/sequencer/order.py | 142 ++++++- .../ui/panels/sequencer/tracker.py | 71 ++++ .../utils/gui/shortcuts/ids.py | 3 + .../view_model/sequencer/region.py | 23 + .../keybindings/default.yaml | 3 + src/sampletones_config/keybindings/macos.yaml | 3 + src/sampletones_config/lang/en.yaml | 11 + tests/suite/sequencer.py | 71 ++++ .../coordinators/tabs/test_sequencer.py | 89 +++- .../logic/sequencer/order/__init__.py | 0 .../logic/sequencer/{ => order}/test_order.py | 41 ++ .../logic/sequencer/order/test_reader.py | 205 +++++++++ .../logic/sequencer/order/test_writer.py | 392 ++++++++++++++++++ .../logic/sequencer/test_history_detail.py | 52 ++- .../sequencer/input/test_order_input.py | 19 + .../ui/panels/sequencer/test_block_keys.py | 160 ++++++- .../ui/panels/sequencer/test_block_menu.py | 327 +++++++++++++++ .../view_model/sequencer/test_region.py | 88 +++- 34 files changed, 2206 insertions(+), 23 deletions(-) create mode 100644 docs/development/sequencer-blocks.md create mode 100644 src/sampletones_application/logic/sequencer/order/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/order/block.py rename src/sampletones_application/logic/sequencer/{ => order}/order.py (74%) create mode 100644 src/sampletones_application/logic/sequencer/order/reader.py create mode 100644 src/sampletones_application/logic/sequencer/order/writer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/__init__.py rename tests/unit/sampletones_application/logic/sequencer/{ => order}/test_order.py (74%) create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/test_reader.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/test_writer.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c2950592..7114660c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Bumped the reconstruction data-version to `2.1`. * Improved Sequencer module playback. * Added song export to WAV/MP3. +* Added tracker selection operations. ## v0.3.0 [2026-07-31] diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 20ec98d2..4658b213 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -8,12 +8,11 @@ * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform -* Transpose/note pitch display duality +* Note pitch shown as a transpose offset rather than a note name ### Tracker * Basic shapes as instruments -* Selection operations on patterns and orders ### Workflow diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md new file mode 100644 index 00000000..d88b3529 --- /dev/null +++ b/docs/development/sequencer-blocks.md @@ -0,0 +1,131 @@ +# Sequencer blocks + +A **block** is a rectangle of one sequencer grid, lifted out of the song so it can be +written back somewhere else. Copy, cut, paste and delete are the four gestures over it, +and both grids — the tracker's pattern rows and the order's frames — carry the same set. + +This document states the rules those gestures follow. The layering they sit in is +[Architecture](architecture.md); the conventions the code is held to are the +[coding guidelines](guidelines.md). + +## Three vocabularies, kept apart + +A gesture crosses three representations, and each has one owner: + +| Term | Where it lives | What it names | +|------|----------------|---------------| +| **Cursor** | `ui/panels/sequencer/input/` | Where the reader is typing, plus the anchor a selection was started from | +| **Region** / **Cell** | `view_model/sequencer/region.py` | The rectangle a gesture acts on, and the single cell a paste is anchored at — grid coordinates, inclusive bounds | +| **Block** | `logic/sequencer/tracker/`, `logic/sequencer/order/` | The values themselves, keyed by offsets from the cell they were read at | + +A region names *where*; a block carries *what*. A block holds offsets rather than +coordinates, which is what lets it land anywhere it is anchored. + +Two axes underpin both grids: + +- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + GeneratorName.items()`. Index 0 + is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4 + are the channels. Both grids lay out along it, so a row index means the same thing in + either. +- **`view_model/sequencer/slot.py::TrackerSlot`** — a column paired with a subcolumn, + readable as a single flat index. Navigation and selection walk the flat index; an edit + addresses the pair. + +## A cell reaches a block in one of three states + +The state is carried by the block's map alone, so every consumer reads it the same way: + +| State | In the map | Written as | +|-------|-----------|------------| +| A value | Key present, holding it | That value | +| Empty | Key present, holding `None` | Emptiness — the target is cleared | +| Mixed | Key absent | Nothing — the target keeps what it had | + +Mixed is what an aggregate cell reads when the channels beneath it disagree, the same +`?` the grid displays. Display and clipboard route through one rule, +`sampletones_shared/utils/agreement.py::Agreement`, so a block states about a cell +exactly what the table it was read from shows there. + +Absence is also what settles the order's growth (below): a column a block says nothing +about reaches nothing. + +## Kind alignment is arithmetic + +A tracker block carries subcolumn offsets measured from `column_slot_base(column)`, and +every base is a multiple of the subcolumn count. An offset therefore addresses the same +kind of subcolumn at whichever column it is replayed against: an instrument value cannot +reach a volume slot. The paste hook takes a `TrackerCell` — a row and a column, with no +subcolumn — so the type states the rule: the anchor decides *where* a block lands and the +block decides *which kind* goes where. + +## A paste is a run of the single-cell edits + +The writers resolve every cell to a method the grid already has: +`SequencerTrackerLogic.place_note` / `cut_note` / `set_cell_subcolumn` / +`clear_cell_subcolumn`, and `SequencerOrderLogic.write_entry`. Nothing about the aggregate +column's fan-out is restated in a writer, so a pasted cell means exactly what the same +value typed by hand means. That is why each write is explainable, and why the aggregate's +rules have one home. + +Two consequences follow from the order the writes are taken in: + +- Within a position, the aggregate row is written before the channels beneath it, so a + channel cell in the same block overwrites what the aggregate settled. The more specific + write wins. +- In the tracker, notes land before the transposes and volumes sharing their row, because + placing a sample through the **Sample** column clears the channels of that row. + +## The order grows to what a paste reaches + +A block pasted past the last frame appends frames, and the rule is stated in terms of +writes rather than the block's shape: the order grows to the last position a write +actually lands at. A `?`-only overrun column appends nothing; one holding an empty cell +appends the frame it silences. Rows clipped at **Noise** take their columns' growth with +them. + +Growth runs before the first write, so one history entry covers the appended frames and +the values in them, and a single undo takes both back. Delete keeps the order's length: +emptied trailing frames stand as silent ones. + +## What a gesture acts on + +- **From the keyboard**: the selection, or — with none up — the cursor's own cell. + `target_region` on each input state is where that fallback lives, so copying one cell + needs no selection made first. +- **From a context menu**: the selection when the menu was raised inside it, and the + clicked cell otherwise (`_menu_region` on each panel, over `Region.covers`). A paste + from a menu anchors at the clicked cell; a paste from the keyboard anchors at the cursor. +- **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot + share a combination inside a shortcut category, so this branch is the route; it also + matches tracker convention. + +Copy is wired straight through rather than through `_undoable` — it mutates nothing, so a +transaction over it would record an entry with nothing to restore. Cut, delete and paste +each record exactly one entry, and none of them coalesces: a block gesture is already a +whole gesture, and folding two consecutive pastes would hide a repeat the reader performed +on purpose. + +## Dragging a range out + +Both panels read the cell under a held pointer off their own geometry, because DearPyGui +reports no hover for the cells a held pointer passes over. A drag carried past an edge +reads as the edge, so it selects up to it. + +The tracker's row lookup is arithmetic: it takes the first row's top edge and divides by +`layout.tracker.row_height`. That holds only while the rows are evenly pitched, which is +what `CellPadding.y = 0` and `ItemSpacing.y = 0` in `theme/tables/pattern.yaml` are for. +A vertical padding there would drift the lookup further down the grid. The order's +position lookup is arithmetic in the same way, taking its pitch from the first two +columns; its channel lookup walks the rows, because the master row stands apart from the +channels beneath it. + +## Accepted limitations + +- **A rebuilt table has no selection.** Both grids reconstruct their input state on + rebuild, so following playback and the rebuild after a growing paste leave the cursor + and drop the selection. The rows a region named belong to the body that was replaced. +- **The selection stays put after a paste** rather than becoming the pasted footprint. +- **Cross-project paste is lossy in the note column and exact in transpose and volume.** + A slot survives a project close, because it must survive `on_project_replaced`, which + fires on every undo; a note naming a sample the project in place lacks is left out of + the write, and the target keeps what it had. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 49361de5..28a5287d 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -36,8 +36,49 @@ the cursor row, and **Play from this frame** to start at the top of the shown fr A song plays a sequence of patterns, and the **Order** grid sets that sequence — one column per position, with a row for the master and each channel. Type an entry -to place a pattern, or right-click a frame to **Insert frame**, **Duplicate**, -**Clear frame**, **Remove**, move it, or **Play from this frame**. +to place a pattern, or right-click a frame for the rest: **Duplicate** repeats the +frame with the patterns it already plays, **Clone** gives the copy patterns of its +own so you can change it on its own, and **Insert frame**, **Clear frame**, +**Remove**, the moves, and **Play from this frame** do what they say. + +## Working on a block + +Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and +delete in one go. Hold `Shift` and press the arrow keys to reach out from the +cursor, or drag the pointer across the cells; `Shift`+click carries the selection to +the cell you click. Any plain move, and `Escape`, puts it away again. + +| Key | Action | +|-----|--------| +| `Shift`+arrows | Reach the selection out a cell at a time | +| `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) | +| `Ctrl+C` | Copy | +| `Ctrl+X` | Cut — copy, then empty what was selected | +| `Ctrl+V` | Paste, starting at the cursor | +| `Del` | Empty the selection | + +With nothing selected these act on the cell the cursor stands on, so copying one +cell needs no selection first. The same four sit on each grid's right-click menu: +raised inside a selection they act on the whole of it, raised anywhere else on the +cell you clicked. Each grid keeps its own copy, so a tracker block pastes into the +tracker and an order block into the order. + +A paste is anchored: the block starts at the cell you paste onto and lands the rest +down and to the right of it. + +In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose +lands in a transpose, a volume in a volume, whichever column you paste onto — and +whatever reaches past the last row or the last column is left out. A cell reading +`?`, where the **Sample** column's channels disagree, passes over its target and +leaves what was there; an empty cell empties it. + +In the **Order**, a block pasted past the last frame grows the song to hold it, and +one reaching past the **Noise** row stops there. The **Master** row copies the index +its channels share and reads `?` when they differ, which pasted leaves each channel +as it was. + +Emptying cells keeps the rows and frames they sit in, and every block action is one +step in the history, so a single **Undo** takes it all back. ## Playing the song diff --git a/docs/index.md b/docs/index.md index f5383290..3abf6df1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,7 @@ The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. +- [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 51db2e5d..c5df06bf 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,6 +30,10 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" + CONTEXT_COPY = "context_copy" + CONTEXT_CUT = "context_cut" + CONTEXT_PASTE = "context_paste" + CONTEXT_DELETE = "context_delete" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -62,6 +66,10 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" + CONTEXT_COPY = "context_copy" + CONTEXT_CUT = "context_cut" + CONTEXT_PASTE = "context_paste" + CONTEXT_DELETE = "context_delete" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 74a7c16d..b4a04dac 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -95,6 +95,9 @@ class KeybindingActionElements(AbstractElement): ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" + ORDER_COPY_BLOCK = "order_copy_block" + ORDER_CUT_BLOCK = "order_cut_block" + ORDER_PASTE_BLOCK = "order_paste_block" ORDER_MOVE_FRAME_LEFT = "order_move_frame_left" ORDER_MOVE_FRAME_RIGHT = "order_move_frame_right" ORDER_MOVE_FRAME_TO_START = "order_move_frame_to_start" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 39b3bc2d..20486780 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -25,7 +25,11 @@ from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) -from sampletones_application.logic.sequencer.order import SequencerOrderLogic +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) from sampletones_application.logic.sequencer.playback.playhead import ( remap_after_insert, remap_after_move, @@ -87,7 +91,12 @@ HistoryEntryViewModel, HistoryViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -187,6 +196,8 @@ def __init__( self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) + self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) + self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -451,7 +462,12 @@ def _wire_block_callbacks(self) -> None: instead of through :meth:`_undoable`: a transaction over it would record an entry the history has nothing to restore for. The three gestures that do write are whole ones, each recording the single entry that takes the grid back to where it stood. + + Each grid also asks whether its own slot holds a block, which is what a menu offering + Paste consults before it is opened. """ + self._sequencer_tracker_panel.can_paste_block = self._can_paste_tracker_block + self._sequencer_order_panel.can_paste_block = self._can_paste_order_block self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block self._sequencer_tracker_panel.on_cut_block = self._undoable( HistoryAction.CUT_BLOCK, @@ -468,6 +484,30 @@ def _wire_block_callbacks(self) -> None: self._paste_tracker_block, detail=self._history_detail.tracker_paste, ) + self._sequencer_order_panel.on_copy_block = self._on_order_copy_block + self._sequencer_order_panel.on_cut_block = self._undoable( + HistoryAction.CUT_BLOCK, + self._cut_order_block, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_delete_block = self._undoable( + HistoryAction.DELETE_BLOCK, + self._order_block_writer.clear, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_paste_block = self._undoable( + HistoryAction.PASTE_BLOCK, + self._paste_order_block, + detail=self._history_detail.order_paste, + ) + + def _can_paste_tracker_block(self) -> bool: + """Whether the tracker has a block to write, which is what its Paste item is offered on.""" + return self._clipboard.tracker_block is not None + + def _can_paste_order_block(self) -> bool: + """Whether the order has a block to write, which is what its Paste item is offered on.""" + return self._clipboard.order_block is not None def _on_tracker_copy_block(self, region: TrackerRegion) -> None: """Puts the tracker's selected block on the clipboard, for a paste to replay.""" @@ -484,6 +524,21 @@ def _paste_tracker_block(self, cell: TrackerCell) -> None: if block is not None: self._tracker_block_writer.write(block, cell) + def _on_order_copy_block(self, region: OrderRegion) -> None: + """Puts the order's selected block on the clipboard, for a paste to replay.""" + self._clipboard.store_order_block(self._order_block_reader.read(region)) + + def _cut_order_block(self, region: OrderRegion) -> None: + """Takes the block a region covers onto the clipboard, then silences what it covered.""" + self._on_order_copy_block(region) + self._order_block_writer.clear(region) + + def _paste_order_block(self, cell: OrderCell) -> None: + """Writes the block the order last copied at a cell, while a copy has been made.""" + block = self._clipboard.order_block + if block is not None: + self._order_block_writer.write(block, cell) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard.py index 0d18f9e5..5d19bd90 100644 --- a/src/sampletones_application/logic/sequencer/clipboard.py +++ b/src/sampletones_application/logic/sequencer/clipboard.py @@ -1,5 +1,6 @@ from typing import Optional +from sampletones_application.logic.sequencer.order import OrderBlock from sampletones_application.logic.sequencer.tracker import TrackerBlock @@ -17,6 +18,7 @@ class SequencerClipboard: def __init__(self) -> None: self._tracker_block: Optional[TrackerBlock] = None + self._order_block: Optional[OrderBlock] = None @property def tracker_block(self) -> Optional[TrackerBlock]: @@ -25,3 +27,11 @@ def tracker_block(self) -> Optional[TrackerBlock]: def store_tracker_block(self, block: TrackerBlock) -> None: self._tracker_block = block + + @property + def order_block(self) -> Optional[OrderBlock]: + """The block the order last copied, present once a copy has been made.""" + return self._order_block + + def store_order_block(self, block: OrderBlock) -> None: + self._order_block = block diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 4b850bdd..125cc181 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -1,8 +1,13 @@ -from typing import Dict, Final, List, Optional +from typing import Dict, Final, List, Optional, Set from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, @@ -22,6 +27,8 @@ _ARROW: Final[str] = ">" _RANGE: Final[str] = "-" + + _SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: "i", SubColumn.TRANSPOSE: "t", @@ -50,6 +57,11 @@ } +def _span(first: int, last: int) -> str: + """Reads a run of indices as the pair it lies between.""" + return f"{display_id(first)}{_RANGE}{display_id(last)}" + + class SequencerHistoryDetail: """Builds the coloured detail line for each undoable sequencer gesture. @@ -160,7 +172,7 @@ def tracker_block(self, region: TrackerRegion) -> Segments: """Reads as the frame, the channels a block spans and the rows it covers.""" return ( self._frame(self._tracker_logic.frame_index), - self._channel(self._region_generators(region)), + self._channel(self._covered_channels({slot.generator for slot in region.slots})), self._row_range(region.first_row, region.last_row), ) @@ -168,6 +180,20 @@ def tracker_paste(self, cell: TrackerCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" return self._location(cell.row, cell.generator, GeneratorName.items()) + def order_block(self, region: OrderRegion) -> Segments: + """Reads as the positions a block covers and the channels its rows reach.""" + return ( + self._frame_range(region.first_position, region.last_position), + self._channel(self._covered_channels(set(region.generators))), + ) + + def order_paste(self, cell: OrderCell) -> Segments: + """Reads as the cell a block was written from, the one place a paste chooses.""" + return ( + self._frame(cell.position), + self._channel(self._covered_channels({cell.generator})), + ) + def add_frame(self, position: int) -> Segments: return (self._frame(position + 1),) @@ -324,13 +350,27 @@ def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment: return self._row(first_row) return HistoryDetailSegment( - text=f"{display_id(first_row)}{_RANGE}{display_id(last_row)}", + text=_span(first_row, last_row), role=HistoryDetailRole.ROW, ) - def _region_generators(self, region: TrackerRegion) -> List[GeneratorName]: - """The channels a region reaches, the sample column standing for every one it governs.""" - covered = {slot.generator for slot in region.slots} + def _frame_range(self, first_position: int, last_position: int) -> HistoryDetailSegment: + """Reads a span of positions as one frame token, a single position standing as its own.""" + if first_position == last_position: + return self._frame(first_position) + + return HistoryDetailSegment( + text=_span(first_position, last_position), + role=HistoryDetailRole.FRAME, + ) + + @staticmethod + def _covered_channels(covered: Set[Optional[GeneratorName]]) -> List[GeneratorName]: + """The channels a run of columns names, an aggregate one standing for all it summarises. + + Both grids carry a column that answers for every channel — the tracker's sample column and + the order's master row — so a gesture reaching one of them reads as the whole set. + """ if None in covered: return GeneratorName.items() diff --git a/src/sampletones_application/logic/sequencer/order/__init__.py b/src/sampletones_application/logic/sequencer/order/__init__.py new file mode 100644 index 00000000..f6dd4e09 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/__init__.py @@ -0,0 +1,11 @@ +from .block import OrderBlock +from .order import SequencerOrderLogic +from .reader import OrderBlockReader +from .writer import OrderBlockWriter + +__all__ = [ + "OrderBlock", + "OrderBlockReader", + "OrderBlockWriter", + "SequencerOrderLogic", +] diff --git a/src/sampletones_application/logic/sequencer/order/block.py b/src/sampletones_application/logic/sequencer/order/block.py new file mode 100644 index 00000000..f217848f --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/block.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +BlockKey = Tuple[int, int] + + +@dataclass(frozen=True) +class OrderBlock: + """A rectangle of the order table, addressed by the offsets it was read at. + + A key is a row offset paired with a position offset, both counted from the cell the block + begins at, so a block carries its own shape and lands wherever it is anchored. + + A cell reaches the block in one of three states, and the map holds them apart: a key carrying + an index plays that pattern, a key carrying ``None`` silences the slot, and an absent key + states that the block says nothing about that cell — which is how a master row its channels + disagree over stays transparent to whatever it is pasted onto. + + Absence also settles how far a paste grows the order: a column the block says nothing about + reaches nothing, so the order ends where the last written column does. + """ + + row_count: int + position_count: int + entries: Dict[BlockKey, Optional[int]] diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order/order.py similarity index 74% rename from src/sampletones_application/logic/sequencer/order.py rename to src/sampletones_application/logic/sequencer/order/order.py index 0d7e3fb9..15d27ac5 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/order/order.py @@ -50,6 +50,37 @@ def set_master_entry(self, position: int, pattern_index: Optional[int]) -> None: for generator in GeneratorName.items(): self._controller.set_order_entry(generator, position, pattern_index) + def write_entry( + self, + generator: Optional[GeneratorName], + position: int, + pattern_index: Optional[int], + ) -> None: + """Plays a pattern index at a position, the master row settling every channel at once. + + This is the rule the table's two kinds of row follow, kept in one place so a gesture + reaching across them writes what the reader typing into each by hand would. + """ + if generator is None: + self.set_master_entry(position, pattern_index) + else: + self.set_order_entry(generator, position, pattern_index) + + def entry(self, generator: GeneratorName, position: int) -> Optional[int]: + """The pattern index a channel plays at a position, empty past the order's last frame.""" + order = self._controller.song.order + if position >= len(order): + return None + + return order[position].get(generator) + + def position_count(self) -> int: + return self._controller.order_length + + def append_frame(self) -> None: + """Adds one empty frame (all channels silent) after the order's last.""" + self._controller.append_frame() + def remove_from_order(self, position: int) -> None: self._controller.remove_frame(position) diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py new file mode 100644 index 00000000..a81f5d84 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional + +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.utils.agreement import Agreement + +from .block import BlockKey, OrderBlock +from .order import SequencerOrderLogic + + +class OrderBlockReader: + """Reads a selected region of the order table into a block a paste can replay. + + The block is anchored at the cell the region begins in, so it carries offsets rather than + table coordinates and lands wherever it is written. + """ + + def __init__(self, order_logic: SequencerOrderLogic) -> None: + self._order = order_logic + + def read(self, region: OrderRegion) -> OrderBlock: + """Takes the pattern indices a region covers, keyed by the offsets they stand at. + + A cell holding an index keeps it, a silent one keeps its silence, and a master cell whose + channels disagree leaves its key out — which carries the table's mixed reading over as a + value the paste passes by. + """ + entries: Dict[BlockKey, Optional[int]] = {} + for row_offset, generator in enumerate(region.generators): + for position_offset, position in enumerate(region.positions): + agreement = self._agree(generator, position) + if agreement.is_unanimous: + entries[(row_offset, position_offset)] = agreement.value + + return OrderBlock( + row_count=region.row_count, + position_count=region.position_count, + entries=entries, + ) + + def _agree( + self, + generator: Optional[GeneratorName], + position: int, + ) -> Agreement[Optional[int]]: + """What a row holds at a position: a channel's own index, or the one its channels share. + + A channel row answers for itself, so it is a group of one and always agrees. The master row + answers for every channel, which is the group its display summarises too, so a block states + about a cell exactly what the table it came from shows there. + """ + if generator is not None: + return Agreement.collapse([self._order.entry(generator, position)]) + + return Agreement.collapse(self._order.entry(channel, position) for channel in GeneratorName.items()) diff --git a/src/sampletones_application/logic/sequencer/order/writer.py b/src/sampletones_application/logic/sequencer/order/writer.py new file mode 100644 index 00000000..5e4a90de --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/writer.py @@ -0,0 +1,71 @@ +from typing import List, Optional, Tuple + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion + +from .block import OrderBlock +from .order import SequencerOrderLogic + +OrderWrite = Tuple[int, int, Optional[int]] + + +class OrderBlockWriter: + """Replays a block into the order table, and empties the cells a region covers. + + Every cell reaches the table through the single-entry edit that already governs it, so a paste + lands exactly the writes a reader typing the same indices by hand would make, master row + included. + """ + + def __init__(self, order_logic: SequencerOrderLogic) -> None: + self._order = order_logic + + def write(self, block: OrderBlock, cell: OrderCell) -> None: + """Writes a block anchored at a cell, the order growing to hold what it reaches past its end. + + The whole block is resolved before any of it lands, so the frames a write needs exist by the + time it reaches them and the growth belongs to the same gesture as the writes it carries. + """ + writes = self._resolve(block, cell) + self._grow(writes) + for row, position, pattern_index in writes: + self._order.write_entry(CHANNEL_AXIS[row], position, pattern_index) + + def clear(self, region: OrderRegion) -> None: + """Silences every cell a region covers, each by the rule its own row follows. + + The order keeps its length, so emptying the frames at its end leaves them standing as + silent ones rather than taking positions away from the arrangement. + """ + for generator in region.generators: + for position in region.positions: + self._order.write_entry(generator, position, None) + + def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]: + """Where each of a block's entries lands, in the reading order they are written in. + + Keys are taken in reading order, so a position's master row is written before the channels + beneath it and the more specific write is the one that stands. A row past the last channel + is left out, which clips a block at the bottom edge rather than wrapping it round to the + master row. + """ + base_row = CHANNEL_AXIS.index(cell.generator) + return [ + (base_row + row_offset, cell.position + position_offset, pattern_index) + for (row_offset, position_offset), pattern_index in sorted(block.entries.items()) + if base_row + row_offset < len(CHANNEL_AXIS) + ] + + def _grow(self, writes: List[OrderWrite]) -> None: + """Appends the frames a block reaches past the order's end. + + The order grows to the last position a write actually lands at, so a column the block says + nothing about appends no frame while one it silences appends the frame it silences. + """ + positions = [position for _, position, _ in writes] + if not positions: + return + + required = max(positions) + 1 + for _ in range(required - self._order.position_count()): + self._order.append_frame() diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 1c2b6a01..f62bbb40 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -66,6 +66,27 @@ def region(self) -> Optional[OrderRegion]: last_position=max(self.anchor.position, self.cursor.position), ) + @property + def target_region(self) -> Optional[OrderRegion]: + """The region a block gesture acts on: the selection, or the cursor's own cell. + + A cursor with nothing selected stands on a block of one cell, so copying reaches the cell + the reader is working in and needs no selection made first. + """ + if self.region is not None: + return self.region + + if self.cursor is None: + return None + + row = CHANNEL_AXIS.index(self.cursor.generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=self.cursor.position, + last_position=self.cursor.position, + ) + def extend_to(self, cursor: OrderCursor) -> OrderInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index ec7a9917..69dd1ddb 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -74,6 +74,7 @@ from sampletones_application.view_model.sequencer.order import ( SequencerOrderTrackerViewModel, ) +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion from sampletones_core.constants.enums import GeneratorName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import ColorRGBA, Sender @@ -89,6 +90,9 @@ OnSetMasterEntryCallback = Callable[[int, Optional[int]], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnBlockRegionCallback = Callable[[OrderRegion], None] +OnPasteBlockCallback = Callable[[OrderCell], None] +CanPasteBlockQuery = Callable[[], bool] MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, @@ -162,6 +166,11 @@ def __init__( self.on_set_order_entry: Optional[OnSetOrderEntryCallback] = None self.on_set_master_entry: Optional[OnSetMasterEntryCallback] = None self.on_cell_selected: Optional[VoidCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -202,6 +211,10 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) + self._lbl_context_copy = label(SequencerOrderElements.CONTEXT_COPY) + self._lbl_context_cut = label(SequencerOrderElements.CONTEXT_CUT) + self._lbl_context_paste = label(SequencerOrderElements.CONTEXT_PASTE) + self._lbl_context_delete = label(SequencerOrderElements.CONTEXT_DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -972,10 +985,10 @@ def _on_cell_right_clicked( _sender: Sender, app_data: Tuple[int, int], ) -> None: - """Opens the frame-operations menu for the right-clicked frame. + """Opens the frame-operations menu for the right-clicked cell. - The menu acts on the clicked frame directly and leaves the edit cursor (and, while - following playback, the playhead) where it is — right-clicking should not seek. + The menu acts on the clicked cell and its frame directly, and leaves the edit cursor (and, + while following playback, the playhead) where it is — right-clicking should not seek. """ mouse_button, clicked_item = app_data if mouse_button != dpg.mvMouseButton_Right: @@ -985,8 +998,8 @@ def _on_cell_right_clicked( if key is None: return - _, position = key - self._show_context_menu(position) + generator, position = key + self._show_context_menu(generator, position) def _on_label_clicked( self, @@ -1023,7 +1036,11 @@ def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None: dpg.add_separator() self._channel_switch.add_menu_items(generator, self._current_channels) - def _show_context_menu(self, position: int) -> None: + def _show_context_menu( + self, + generator: Optional[GeneratorName], + position: int, + ) -> None: with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1034,6 +1051,8 @@ def _show_context_menu(self, position: int) -> None: shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() + self._add_block_items(generator, position) + dpg.add_separator() dpg.add_menu_item( label=self._lbl_context_duplicate, shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), @@ -1081,6 +1100,67 @@ def _show_context_menu(self, position: int) -> None: position, ) + def _menu_region( + self, + generator: Optional[GeneratorName], + position: int, + ) -> OrderRegion: + """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + + A menu opened inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects the actions to reach; one opened anywhere else acts on the + cell it was raised on, the same block the cursor alone stands for. + """ + region = self._input_state.region + if region is not None and region.covers(generator, position): + return region + + row = CHANNEL_AXIS.index(generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=position, + last_position=position, + ) + + def _add_block_items( + self, + generator: Optional[GeneratorName], + position: int, + ) -> None: + """Builds the clipboard items, acting on the block the menu was raised on. + + Paste is offered once a block has been copied, and it anchors at the clicked cell, so the + menu lands a block where the pointer is while the keys land it under the cursor. Delete + prints no key of its own, because ``Del`` empties a selection while one stands and clears + the cell under the cursor otherwise. + """ + region = self._menu_region(generator, position) + cell = OrderCell( + generator=generator, + position=position, + ) + dpg.add_menu_item( + label=self._lbl_context_copy, + shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), + callback=lambda: self.call(self.on_copy_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_cut, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), + callback=lambda: self.call(self.on_cut_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_paste, + shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), + enabled=self.query(self.can_paste_block, default=False), + callback=lambda: self.call(self.on_paste_block, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_delete, + callback=lambda: self.call(self.on_delete_block, region), + ) + def _add_move_item( self, label: str, @@ -1132,6 +1212,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._block_action(shortcut_id): + return True + if self._edit_cell(shortcut_id): return True @@ -1181,6 +1264,53 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties every cell the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ + match shortcut_id: + case ShortcutId.ORDER_COPY_BLOCK: + self._region_gesture(self.on_copy_block) + case ShortcutId.ORDER_CUT_BLOCK: + self._region_gesture(self.on_cut_block) + case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: + self._region_gesture(self.on_delete_block) + case ShortcutId.ORDER_PASTE_BLOCK: + self._paste_block() + case _: + return False + + return True + + def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: + """Hands the selected block out to a gesture, the cell under the cursor standing for itself. + + A partial entry is committed first, so the block carries the index the reader has just + finished typing. + """ + state = self._committed_state() + self._apply_state(state) + region = state.target_region + if region is not None: + self.call(callback, region) + + def _paste_block(self) -> None: + """Names the cell a block is written from, which is wherever the cursor stands.""" + state = self._committed_state() + self._apply_state(state) + cursor = state.cursor + if cursor is not None: + self.call( + self.on_paste_block, + OrderCell( + generator=cursor.generator, + position=cursor.position, + ), + ) + def _edit_cell(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 040ff536..126e4dac 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -117,6 +117,7 @@ OnChannelSoloedCallback = Callable[[GeneratorName], None] OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] +CanPasteBlockQuery = Callable[[], bool] VOLUME_FINE_STEP: Final[int] = 1 @@ -190,6 +191,7 @@ def __init__( self.on_cut_block: Optional[OnBlockRegionCallback] = None self.on_delete_block: Optional[OnBlockRegionCallback] = None self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -242,6 +244,10 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + self._lbl_context_copy = label(SequencerTrackerElements.CONTEXT_COPY) + self._lbl_context_cut = label(SequencerTrackerElements.CONTEXT_CUT) + self._lbl_context_paste = label(SequencerTrackerElements.CONTEXT_PASTE) + self._lbl_context_delete = label(SequencerTrackerElements.CONTEXT_DELETE) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) @@ -1268,6 +1274,8 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() + self._add_block_items(row_index, generator, subcolumn) + dpg.add_separator() self._add_instrument_submenu(row_index, generator) dpg.add_menu_item( label=self._lbl_context_note_off, @@ -1280,6 +1288,69 @@ def _show_context_menu( dpg.add_separator() self._add_clear_items(row_index, generator, subcolumn) + def _menu_region( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> TrackerRegion: + """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + + A menu opened inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects the actions to reach; one opened anywhere else acts on the + cell it was raised on, the same block the cursor alone stands for. + """ + slot = TrackerSlot(generator, subcolumn) + region = self._input_state.region + if region is not None and region.covers(row_index, slot): + return region + + return TrackerRegion( + first_row=row_index, + last_row=row_index, + first_slot=slot.flat_index, + last_slot=slot.flat_index, + ) + + def _add_block_items( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> None: + """Builds the clipboard items, acting on the block the menu was raised on. + + Paste is offered once a block has been copied, and it anchors at the clicked cell, so the + menu lands a block where the pointer is while the keys land it under the cursor. Delete + prints no key of its own, because ``Del`` empties a selection while one stands and clears + the cell under the cursor otherwise. + """ + region = self._menu_region(row_index, generator, subcolumn) + cell = TrackerCell( + row=row_index, + generator=generator, + ) + dpg.add_menu_item( + label=self._lbl_context_copy, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), + callback=lambda: self.call(self.on_copy_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_cut, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), + callback=lambda: self.call(self.on_cut_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_paste, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), + enabled=self.query(self.can_paste_block, default=False), + callback=lambda: self.call(self.on_paste_block, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_delete, + callback=lambda: self.call(self.on_delete_block, region), + ) + def _add_instrument_submenu( self, row_index: int, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 631f5225..a14de3b2 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -107,6 +107,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "OrderExtendSelectionToLastPosition", ShortcutCategory.ORDER, ) + ORDER_COPY_BLOCK = ("OrderCopyBlock", ShortcutCategory.ORDER) + ORDER_CUT_BLOCK = ("OrderCutBlock", ShortcutCategory.ORDER) + ORDER_PASTE_BLOCK = ("OrderPasteBlock", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_LEFT = ("OrderMoveFrameLeft", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_RIGHT = ("OrderMoveFrameRight", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_TO_START = ("OrderMoveFrameToStart", ShortcutCategory.ORDER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 63cac2f1..25dbccd4 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -57,6 +57,9 @@ def row_count(self) -> int: def rows(self) -> range: return range(self.first_row, self.last_row + 1) + def covers_row(self, row: int) -> bool: + return self.first_row <= row <= self.last_row + class TrackerRegion(GridRegion, frozen=True): """A rectangle of the tracker grid: pattern rows crossed with a run of slots. @@ -85,6 +88,14 @@ def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + def covers(self, row: int, slot: TrackerSlot) -> bool: + """Whether a cell of the grid falls inside the rectangle. + + This is what a gesture raised on a cell asks to learn which block it belongs to: one + landing inside a selection acts on the whole of it, and one landing outside acts alone. + """ + return self.covers_row(row) and self.first_slot <= slot.flat_index <= self.last_slot + class OrderRegion(GridRegion, frozen=True): """A rectangle of the order table: channel rows crossed with a run of positions. @@ -119,3 +130,15 @@ def positions(self) -> range: def generators(self) -> Tuple[Optional[GeneratorName], ...]: """The rows the region covers, each as the channel it addresses, master reading ``None``.""" return tuple(CHANNEL_AXIS[row] for row in self.rows) + + def covers( + self, + generator: Optional[GeneratorName], + position: int, + ) -> bool: + """Whether a cell of the table falls inside the rectangle. + + This is what a gesture raised on a cell asks to learn which block it belongs to: one + landing inside a selection acts on the whole of it, and one landing outside acts alone. + """ + return self.covers_row(CHANNEL_AXIS.index(generator)) and position in self.positions diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 8159d5bb..c0a88d7a 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -72,6 +72,9 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} OrderExtendSelectionToLastPosition: {combination: "Shift+End"} + OrderCopyBlock: {combination: "Ctrl+C"} + OrderCutBlock: {combination: "Ctrl+X"} + OrderPasteBlock: {combination: "Ctrl+V"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 9a018793..0b70e729 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -72,6 +72,9 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} + OrderCopyBlock: {combination: "Cmd+C"} + OrderCutBlock: {combination: "Cmd+X"} + OrderPasteBlock: {combination: "Cmd+V"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home", aliases: ["Cmd+Alt+Left"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8f0d6df1..6f29a114 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -450,6 +450,10 @@ sequencer.tracker.label.column_triangle: "Triangle" sequencer.tracker.label.column_noise: "Noise" sequencer.tracker.label.context_play: "Play from here" sequencer.tracker.label.context_play_from_frame: "Play from this frame" +sequencer.tracker.label.context_copy: "Copy" +sequencer.tracker.label.context_cut: "Cut" +sequencer.tracker.label.context_paste: "Paste" +sequencer.tracker.label.context_delete: "Delete" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -483,6 +487,10 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" +sequencer.order.label.context_copy: "Copy" +sequencer.order.label.context_cut: "Cut" +sequencer.order.label.context_paste: "Paste" +sequencer.order.label.context_delete: "Delete" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" @@ -781,6 +789,9 @@ settings.keybindings.label.order_extend_selection_left: "Extend selection left" settings.keybindings.label.order_extend_selection_right: "Extend selection right" settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" +settings.keybindings.label.order_copy_block: "Copy selection" +settings.keybindings.label.order_cut_block: "Cut selection" +settings.keybindings.label.order_paste_block: "Paste selection" settings.keybindings.label.order_move_frame_left: "Move frame left" settings.keybindings.label.order_move_frame_right: "Move frame right" settings.keybindings.label.order_move_frame_to_start: "Move frame to the start" diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 60f3a63c..5ed85b86 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -4,6 +4,8 @@ import numpy as np from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.order import OrderBlock, SequencerOrderLogic +from sampletones_application.logic.sequencer.order.block import BlockKey as OrderBlockKey from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock from sampletones_application.logic.sequencer.tracker.block import BlockKey from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS @@ -85,6 +87,75 @@ def render_slots( return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items()) +def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]: + """Every channel's row of the order, each read as the pattern indices the table draws. + + A row is written the way it appears on screen, so an expectation and a screenshot read alike. + The master row is left out because it holds nothing of its own: it summarises these four, and + stating it again would pin the summary rather than what a gesture wrote. + """ + view_model = order_logic.build_order() + return tuple( + " ".join(view_model.entry_label(generator, position) for position in range(view_model.position_count)) + for generator in GeneratorName.items() + ) + + +def parse_order_block(rows: Sequence[str]) -> OrderBlock: + """Reads an order block written the way the table draws it, one line per row. + + A ``?`` states that the block says nothing about that cell, which is what leaves it out of the + map entirely, while ``..`` states the silence it writes. + + Raises: + ValueError: if the rows differ in width. + """ + entries: Dict[OrderBlockKey, Optional[int]] = {} + lines = [line.split() for line in rows] + widths = {len(tokens) for tokens in lines} + if len(widths) != 1: + raise ValueError(f"An order block's rows differ in width: {sorted(widths)}") + + for row_offset, tokens in enumerate(lines): + for position_offset, token in enumerate(tokens): + if token != MIXED: + entries[(row_offset, position_offset)] = parse_index(token) + + return OrderBlock( + row_count=len(rows), + position_count=widths.pop(), + entries=entries, + ) + + +def fill_order( + order_logic: SequencerOrderLogic, + rows: Sequence[str], +) -> None: + """Writes an order stated the way the table draws it, one channel entry at a time. + + Each entry reaches its own channel, so a setup states the arrangement it wants while the master + row's fan-out stays out of it — which leaves the gesture under test the only thing that + exercised it. The order grows to hold the positions the statement names. + """ + lines = [line.split() for line in rows] + reach = max((len(tokens) for tokens in lines), default=0) + for _ in range(reach - order_logic.position_count()): + order_logic.append_frame() + + for generator, tokens in zip(GeneratorName.items(), lines): + for position, token in enumerate(tokens): + order_logic.set_order_entry(generator, position, parse_index(token)) + + +def parse_index(token: str) -> Optional[int]: + """The pattern index a token names, an empty slot reading as none.""" + if token == display_id(None): + return None + + return int(token, 16) + + def parse_block( rows: Sequence[str], *, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d7f88af2..8c52f87b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -8,6 +8,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.playback import FollowMode +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.logic.history.action import HistoryAction @@ -21,6 +22,11 @@ ) from sampletones_application.logic.sequencer.clipboard import SequencerClipboard from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, @@ -33,7 +39,12 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.samples import SampleSelection from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel @@ -1328,6 +1339,12 @@ def test_player_returns_the_guarded_wrapper( first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, ) +PULSE1_FRAME: Final[OrderRegion] = OrderRegion( + first_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), + last_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), + first_position=0, + last_position=0, +) @pytest.fixture @@ -1342,17 +1359,23 @@ def block_coordinator() -> SequencerTabCoordinator: controller = ProjectController(ProjectManager()) history = HistoryManager(controller, budget=10, strict=True) controller.on_mutation = history.handle_mutation + controller.new() + history.reset() instance._project_controller = controller instance._history = history instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) + instance._sequencer_order_logic = SequencerOrderLogic(controller) + instance._order_block_reader = OrderBlockReader(instance._sequencer_order_logic) + instance._order_block_writer = OrderBlockWriter(instance._sequencer_order_logic) instance._history_detail = SequencerHistoryDetail( instance._sequencer_tracker_logic, MagicMock(), ) instance._sequencer_tracker_panel = MagicMock() + instance._sequencer_order_panel = MagicMock() instance._wire_block_callbacks() return instance @@ -1462,6 +1485,70 @@ def test_a_paste_writes_the_copied_block_in_one_entry( assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + +class TestOrderBlockEdits: + """The order's gestures reach the same clipboard and record the same one entry each.""" + + def test_a_copy_fills_the_clipboard_and_leaves_the_history_as_it_stands( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) + + block = coordinator._clipboard.order_block + assert block is not None + assert block.entries == {(0, 0): 0} + assert len(coordinator._history.entries) == recorded + + def test_a_cut_takes_the_block_and_silences_what_it_covered( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK + + def test_a_delete_silences_the_region_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_delete_block(PULSE1_FRAME) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK + + def test_a_paste_covers_the_frames_it_appends_and_the_entries_it_writes( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """One entry stands for the whole gesture, so an undo takes the appended frames back too.""" + coordinator = block_coordinator + coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + + assert coordinator._sequencer_order_logic.position_count() == 2 + assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + coordinator._history.undo() + + assert coordinator._sequencer_order_logic.position_count() == 1 + def test_a_paste_with_nothing_copied_records_nothing( self, block_coordinator: SequencerTabCoordinator, diff --git a/tests/unit/sampletones_application/logic/sequencer/order/__init__.py b/tests/unit/sampletones_application/logic/sequencer/order/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/logic/sequencer/test_order.py b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py similarity index 74% rename from tests/unit/sampletones_application/logic/sequencer/test_order.py rename to tests/unit/sampletones_application/logic/sequencer/order/test_order.py index 7a12b468..ec24ec9f 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_order.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py @@ -57,6 +57,47 @@ def test_remove_from_order_drops_the_frame(self) -> None: assert _order_column(logic, generator) == [None] +class TestEntryAccess: + """The reading and writing seam a block gesture goes through, which is the table's own rule.""" + + def test_write_entry_reaches_one_channel(self) -> None: + logic = _logic() + + logic.write_entry(GeneratorName.PULSE1, 0, 3) + + assert _order_column(logic, GeneratorName.PULSE1) == [3] + assert _order_column(logic, GeneratorName.TRIANGLE) == [0] + + def test_write_entry_through_the_master_row_reaches_every_channel(self) -> None: + logic = _logic() + + logic.write_entry(None, 0, 3) + + for generator in GeneratorName.items(): + assert _order_column(logic, generator) == [3] + + def test_entry_reads_the_index_a_channel_plays(self) -> None: + logic = _logic() + logic.set_order_entry(GeneratorName.NOISE, 0, 7) + + assert logic.entry(GeneratorName.NOISE, 0) == 7 + + def test_entry_past_the_last_frame_reads_as_silence(self) -> None: + logic = _logic() + + assert logic.entry(GeneratorName.NOISE, logic.position_count()) is None + + def test_append_frame_lengthens_the_order_by_one(self) -> None: + logic = _logic() + length = logic.position_count() + + logic.append_frame() + + assert logic.position_count() == length + 1 + for generator in GeneratorName.items(): + assert logic.entry(generator, length) is None + + class TestOrderFrameOps: def test_insert_frame_adds_empty_frame_at_position(self) -> None: logic = _logic() diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py new file mode 100644 index 00000000..bc4fde3c --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py @@ -0,0 +1,205 @@ +from typing import Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + SequencerOrderLogic, +) +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.constants.enums import GeneratorName +from tests.suite.sequencer import fill_order + +MASTER_ROW = CHANNEL_AXIS.index(None) +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) +NOISE_ROW = CHANNEL_AXIS.index(GeneratorName.NOISE) + + +@pytest.fixture +def logic() -> SequencerOrderLogic: + """The order logic the reader takes every entry through.""" + return SequencerOrderLogic(ProjectController(ProjectManager())) + + +@pytest.fixture +def reader(logic: SequencerOrderLogic) -> OrderBlockReader: + return OrderBlockReader(logic) + + +def _row( + generator: Optional[GeneratorName], + *, + last_position: int = 0, +) -> OrderRegion: + """The region one whole row covers, out to ``last_position``.""" + row = CHANNEL_AXIS.index(generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=0, + last_position=last_position, + ) + + +class TestChannelRow: + """A channel answers for itself, so every one of its cells reaches the block definite.""" + + def test_a_row_carries_the_indices_it_plays( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + ".. .. ..", + ".. .. ..", + ".. .. ..", + ), + ) + + block = reader.read(_row(GeneratorName.PULSE1, last_position=2)) + + assert block.entries == {(0, 0): 0, (0, 1): 1, (0, 2): 2} + + def test_a_silent_cell_carries_its_silence( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + """A slot playing nothing reads as the empty cell it shows, which a paste writes as silence.""" + fill_order( + logic, + ( + "00", + "00", + "00", + "..", + ), + ) + + block = reader.read(_row(GeneratorName.NOISE)) + + assert block.entries == {(0, 0): None} + + +class TestMasterRow: + """The master row answers for every channel, so it carries what they agree on and nothing else.""" + + def test_a_position_its_channels_share_carries_the_index( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "03", + "03", + "03", + "03", + ), + ) + + block = reader.read(_row(None)) + + assert block.entries == {(0, 0): 3} + + def test_a_position_every_channel_leaves_silent_carries_that_silence( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + """Silence is a reading the channels agree on, so it writes where a mixed cell would not.""" + fill_order( + logic, + ( + ".. ..", + ".. ..", + ".. ..", + ".. ..", + ), + ) + + block = reader.read(_row(None, last_position=1)) + + assert block.entries == {(0, 0): None, (0, 1): None} + + def test_a_position_its_channels_disagree_over_is_left_out( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 04", + "00 05", + "00 04", + "00 04", + ), + ) + + block = reader.read(_row(None, last_position=1)) + + assert block.entries == {(0, 0): 0} + + +class TestExtent: + """A block states the rectangle it was read at, which a mixed edge column cannot take away.""" + + def test_a_region_carries_the_shape_it_covers( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + "00 01 02", + "00 01 02", + "00 01 02", + ), + ) + + block = reader.read( + OrderRegion( + first_row=MASTER_ROW, + last_row=NOISE_ROW, + first_position=1, + last_position=2, + ) + ) + + assert (block.row_count, block.position_count) == (5, 2) + + def test_offsets_run_from_the_cell_the_region_begins_at( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + "00 01 03", + "00 01 02", + "00 01 02", + ), + ) + + block = reader.read( + OrderRegion( + first_row=PULSE1_ROW, + last_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), + first_position=2, + last_position=2, + ) + ) + + assert block.entries == {(0, 0): 2, (1, 0): 3} diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py new file mode 100644 index 00000000..9ee95980 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py @@ -0,0 +1,392 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import fill_order, parse_order_block, render_order + +SILENT = ".. .. .." + + +@dataclass(frozen=True, kw_only=True) +class Table: + """A three-position order, the state every paste case starts from.""" + + controller: ProjectController + logic: SequencerOrderLogic + writer: OrderBlockWriter + + +@pytest.fixture +def table() -> Table: + """An order short enough for a case to state whole, every channel silent to begin with.""" + controller = ProjectController(ProjectManager()) + logic = SequencerOrderLogic(controller) + fill_order( + logic, + ( + SILENT, + SILENT, + SILENT, + SILENT, + ), + ) + return Table( + controller=controller, + logic=logic, + writer=OrderBlockWriter(logic), + ) + + +def _row(generator: Optional[GeneratorName]) -> int: + return CHANNEL_AXIS.index(generator) + + +class TestPaste(BaseTestSuite): + """What a block writes where it lands, stated as the whole order it leaves behind. + + A block carries the offsets it was read at while the cell it is written from supplies the row + and the position it begins at, so every case states its origin as that pair. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + origin: OrderCell + expected: Tuple[str, ...] + order: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a block lands at the cell it is written from", + block=("07 08",), + origin=OrderCell(generator=GeneratorName.PULSE2, position=1), + expected=( + SILENT, + ".. 07 08", + SILENT, + SILENT, + ), + ), + TestCase( + label="a block through the master row reaches every channel", + block=("05",), + origin=OrderCell(generator=None, position=0), + expected=( + "05 .. ..", + "05 .. ..", + "05 .. ..", + "05 .. ..", + ), + ), + TestCase( + label="a channel beneath the master row overwrites what it settled", + block=( + "05", + "06", + ), + origin=OrderCell(generator=None, position=0), + expected=( + "06 .. ..", + "05 .. ..", + "05 .. ..", + "05 .. ..", + ), + ), + TestCase( + label="a block read from the master row writes one channel when written to one", + block=("05",), + origin=OrderCell(generator=GeneratorName.TRIANGLE, position=2), + expected=( + SILENT, + SILENT, + ".. .. 05", + SILENT, + ), + ), + TestCase( + label="a mixed cell leaves its target as it stands while its neighbours take theirs", + order=( + "01 02 03", + SILENT, + SILENT, + SILENT, + ), + block=("09 ? 0A",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + expected=( + "09 02 0A", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="an empty cell silences the slot it lands on", + order=( + "01 02 03", + SILENT, + SILENT, + SILENT, + ), + block=(".. ..",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + expected=( + ".. .. 03", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="the rows a block carries past the last channel are left out", + block=( + "01", + "02", + "03", + ), + origin=OrderCell(generator=GeneratorName.TRIANGLE, position=0), + expected=( + SILENT, + SILENT, + "01 .. ..", + "02 .. ..", + ), + ), + TestCase( + label="a master row written to the last channel keeps that channel alone", + block=( + "01", + "02", + ), + origin=OrderCell(generator=GeneratorName.NOISE, position=0), + expected=( + SILENT, + SILENT, + SILENT, + "01 .. ..", + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_order_after_a_paste( + self, + table: Table, + test_case: TestCase, + ) -> None: + fill_order(table.logic, test_case.order) + + table.writer.write(parse_order_block(test_case.block), test_case.origin) + + assert render_order(table.logic) == test_case.expected + + +class TestGrowth(BaseTestSuite): + """How far a paste past the order's end grows it, which is to the last position it writes at.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + origin: OrderCell + expected: Tuple[str, ...] + + test_cases = ( + TestCase( + label="a block reaching past the end appends exactly the positions it writes", + block=("01 02 03",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01 02 03", + ".. .. .. .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ), + ), + TestCase( + label="a column the block says nothing about appends no position", + block=("01 ? ?",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="a column the block silences appends the position it silences", + block=("01 ? ..",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01 .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ), + ), + TestCase( + label="the rows a block loses at the last channel take their growth with them", + block=( + "01 ?", + "? 02", + ), + origin=OrderCell(generator=GeneratorName.NOISE, position=2), + expected=( + SILENT, + SILENT, + SILENT, + ".. .. 01", + ), + ), + TestCase( + label="a wholly mixed block leaves the order the length it was", + block=("? ? ?",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + SILENT, + SILENT, + SILENT, + SILENT, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_order_after_a_paste_past_its_end( + self, + table: Table, + test_case: TestCase, + ) -> None: + table.writer.write(parse_order_block(test_case.block), test_case.origin) + + assert render_order(table.logic) == test_case.expected + + +class TestClear: + """What a delete silences, which is every cell its region covers and nothing beside.""" + + def test_a_region_silences_the_cells_it_covers(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(GeneratorName.PULSE2), + last_row=_row(GeneratorName.TRIANGLE), + first_position=0, + last_position=1, + ) + ) + + assert render_order(table.logic) == ( + "01 02 03", + ".. .. 03", + ".. .. 03", + "01 02 03", + ) + + def test_a_region_over_the_master_row_silences_every_channel(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(None), + last_row=_row(None), + first_position=1, + last_position=1, + ) + ) + + assert render_order(table.logic) == ( + "01 .. 03", + "01 .. 03", + "01 .. 03", + "01 .. 03", + ) + + def test_a_delete_leaves_the_order_the_length_it_was(self, table: Table) -> None: + """Emptying the frames at the end leaves them standing as silent ones.""" + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(None), + last_row=_row(GeneratorName.NOISE), + first_position=0, + last_position=2, + ) + ) + + assert table.logic.position_count() == 3 + + +class TestRoundTrip: + """Reading a region, silencing it and writing the block back leaves the order it came from.""" + + def test_a_block_written_back_at_its_origin_restores_the_order(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 04 03", + ".. 02 03", + "01 02 ..", + ), + ) + before = render_order(table.logic) + region = OrderRegion( + first_row=_row(GeneratorName.PULSE1), + last_row=_row(GeneratorName.NOISE), + first_position=0, + last_position=2, + ) + block = OrderBlockReader(table.logic).read(region) + + table.writer.clear(region) + table.writer.write(block, OrderCell(generator=GeneratorName.PULSE1, position=0)) + + assert render_order(table.logic) == before diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index eed426e9..835fe41e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -3,6 +3,7 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.history_detail import ( @@ -10,7 +11,12 @@ ) from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( @@ -224,6 +230,50 @@ def test_set_master_entry_lists_every_channel(self) -> None: ("05", HistoryDetailRole.VALUE), ] + def test_a_block_reads_as_the_positions_and_the_channels_it_covers(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_block( + OrderRegion( + first_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), + last_row=CHANNEL_AXIS.index(GeneratorName.TRIANGLE), + first_position=1, + last_position=4, + ) + ) + + assert _pairs(segments) == [ + ("01-04", HistoryDetailRole.FRAME), + ("pT", HistoryDetailRole.CHANNEL), + ] + + def test_a_block_reaching_the_master_row_reads_as_every_channel(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_block( + OrderRegion( + first_row=CHANNEL_AXIS.index(None), + last_row=CHANNEL_AXIS.index(None), + first_position=2, + last_position=2, + ) + ) + + assert _pairs(segments) == [ + ("02", HistoryDetailRole.FRAME), + ("PpTN", HistoryDetailRole.CHANNEL), + ] + + def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_paste(OrderCell(generator=GeneratorName.NOISE, position=3)) + + assert _pairs(segments) == [ + ("03", HistoryDetailRole.FRAME), + ("N", HistoryDetailRole.CHANNEL), + ] + class TestSampleDetails: def test_add_sample_shows_the_name(self) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 4ae8875a..1f36dc47 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -110,6 +110,25 @@ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: assert cancelled.pending == "" +class TestTarget: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + region = _state(GeneratorName.PULSE2, position=4).target_region + + assert region is not None + assert (region.first_position, region.last_position) == (4, 4) + assert region.generators == (GeneratorName.PULSE2,) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state(position=4).extend_position(2, POSITION_COUNT) + + assert selected.target_region == selected.region + + def test_a_table_with_no_cursor_targets_nothing(self) -> None: + assert OrderInputState().target_region is None + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 300fb5c1..3229aae6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -3,13 +3,24 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import ( + OrderCursor, + OrderInputState, +) from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -17,6 +28,10 @@ ROW_COUNT = 64 CURSOR_ROW = 4 +POSITION_COUNT = 8 +CURSOR_POSITION = 2 +MASTER_ROW = CHANNEL_AXIS.index(None) +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) @dataclass @@ -30,6 +45,17 @@ class Gestures: cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) +@dataclass +class OrderGestures: + """What each of the order's block hooks was handed, read the same way the tracker's are.""" + + copied: List[OrderRegion] = field(default_factory=list) + cut: List[OrderRegion] = field(default_factory=list) + deleted: List[OrderRegion] = field(default_factory=list) + pasted: List[OrderCell] = field(default_factory=list) + cleared: List[Tuple[GeneratorName, int, Optional[int]]] = field(default_factory=list) + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -62,6 +88,26 @@ def _panel( return panel +def _order_panel( + monkeypatch: pytest.MonkeyPatch, + gestures: OrderGestures, + *, + generator: Optional[GeneratorName] = GeneratorName.PULSE1, +) -> GUISequencerOrderPanel: + """An order panel reporting the gestures it fires, with its table left unbuilt.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._position_count = POSITION_COUNT + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) + monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) + return panel + + class TestTrackerCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = Gestures() @@ -168,3 +214,115 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert panel._on_key_pressed(_press("Del")) is True assert gestures.deleted == [] assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] + + +class TestOrderCopyKey: + def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert gestures.copied == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + ] + + def test_a_cursor_alone_copies_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert gestures.copied == [ + OrderRegion( + first_row=MASTER_ROW, + last_row=MASTER_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION, + ) + ] + + def test_a_table_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = OrderInputState() + + assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert gestures.copied == [] + + +class TestOrderCutKey: + def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_channel(1) + + assert panel._on_key_pressed(_press("Ctrl+X")) is True + assert gestures.cut == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW + 1, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION, + ) + ] + assert gestures.copied == [] + + +class TestOrderPasteKey: + def test_a_paste_names_the_cell_the_cursor_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [OrderCell(generator=GeneratorName.PULSE1, position=CURSOR_POSITION)] + + def test_the_master_row_is_a_cell_a_block_lands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [OrderCell(generator=None, position=CURSOR_POSITION)] + + +class TestOrderDeleteKey: + def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + ] + assert gestures.cleared == [] + + def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Delete already means something without a selection, so that meaning is what it keeps.""" + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [] + assert gestures.cleared == [(GeneratorName.PULSE1, CURSOR_POSITION, None)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py new file mode 100644 index 00000000..130d1f06 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -0,0 +1,327 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer import order as order_module +from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import ( + OrderCursor, + OrderInputState, +) +from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +CLICKED_ROW = 4 +CLICKED_POSITION = 2 +ROW_COUNT = 64 +POSITION_COUNT = 8 + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) + + +@dataclass +class MenuItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + enabled: bool + callback: Callable[[], None] + + +@dataclass +class Gestures: + """What each block hook was handed when its menu item fired.""" + + copied: List[Any] = field(default_factory=list) + cut: List[Any] = field(default_factory=list) + deleted: List[Any] = field(default_factory=list) + pasted: List[Any] = field(default_factory=list) + + +class _MenuRecorder: + """Captures the items a builder registers, in the order it registers them.""" + + def __init__(self) -> None: + self.items: List[MenuItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + MenuItem( + label=kwargs["label"], + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +def _labels(panel: Any) -> None: + panel._lbl_context_copy = "Copy" + panel._lbl_context_cut = "Cut" + panel._lbl_context_paste = "Paste" + panel._lbl_context_delete = "Delete" + + +def _tracker_panel( + gestures: Gestures, + *, + can_paste: bool = True, +) -> tracker_module.GUISequencerTrackerPanel: + """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" + panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) + _labels(panel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.can_paste_block = lambda: can_paste + return panel + + +def _order_panel( + gestures: Gestures, + *, + can_paste: bool = True, +) -> order_module.GUISequencerOrderPanel: + """An order panel whose menu builder can run with no DearPyGui context behind it.""" + panel = order_module.GUISequencerOrderPanel.__new__(order_module.GUISequencerOrderPanel) + _labels(panel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.can_paste_block = lambda: can_paste + return panel + + +@pytest.fixture +def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorder = _MenuRecorder() + monkeypatch.setattr(tracker_module.dpg, "add_menu_item", recorder.add_menu_item) + return recorder + + +@pytest.fixture +def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorder = _MenuRecorder() + monkeypatch.setattr(order_module.dpg, "add_menu_item", recorder.add_menu_item) + return recorder + + +def _selected_tracker_state() -> TrackerInputState: + """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" + state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + return state.extend_row(2, ROW_COUNT).extend_slot(2) + + +def _selected_order_state() -> OrderInputState: + """A selection running from the clicked position across two positions of Pulse 1's row.""" + state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION)) + return state.extend_position(2, POSITION_COUNT) + + +class TestTrackerMenuTarget: + def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + + region = panel._menu_region( + CLICKED_ROW + 1, + GeneratorName.PULSE1, + SubColumn.TRANSPOSE, + ) + + assert region == panel._input_state.region + + def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + + region = panel._menu_region( + CLICKED_ROW, + GeneratorName.TRIANGLE, + SubColumn.VOLUME, + ) + + assert region == TrackerRegion( + first_row=CLICKED_ROW, + last_row=CLICKED_ROW, + first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, + ) + + def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: + panel = _tracker_panel(Gestures()) + + region = panel._menu_region( + CLICKED_ROW, + None, + SubColumn.INSTRUMENT, + ) + + assert region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) + assert region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + + +class TestTrackerMenuItems: + def test_the_items_hand_out_the_block_the_menu_was_raised_on( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + gestures = Gestures() + panel = _tracker_panel(gestures) + panel._input_state = _selected_tracker_state() + selection = panel._input_state.region + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + for item in tracker_recorder.items: + item.callback() + + assert gestures.copied == [selection] + assert gestures.cut == [selection] + assert gestures.deleted == [selection] + + def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecorder) -> None: + """The cell carries a row and a column alone, so the clicked subcolumn is left to the block.""" + gestures = Gestures() + panel = _tracker_panel(gestures) + + panel._add_block_items(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + tracker_recorder.items[PASTE_ITEM].callback() + + assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)] + + def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures(), can_paste=False) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + assert tracker_recorder.items[PASTE_ITEM].enabled is False + assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] + + def test_the_section_reads_as_the_four_clipboard_actions( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] + + +class TestOrderMenuTarget: + def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + + region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION + 1) + + assert region == panel._input_state.region + + def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + + region = panel._menu_region(None, CLICKED_POSITION) + + assert region == OrderRegion( + first_row=CHANNEL_AXIS.index(None), + last_row=CHANNEL_AXIS.index(None), + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: + panel = _order_panel(Gestures()) + + region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION) + + assert region == OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + +class TestOrderMenuItems: + def test_the_items_hand_out_the_block_the_menu_was_raised_on( + self, + order_recorder: _MenuRecorder, + ) -> None: + gestures = Gestures() + panel = _order_panel(gestures) + panel._input_state = _selected_order_state() + selection = panel._input_state.region + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + for item in order_recorder.items: + item.callback() + + assert gestures.copied == [selection] + assert gestures.cut == [selection] + assert gestures.deleted == [selection] + + def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder) -> None: + gestures = Gestures() + panel = _order_panel(gestures) + + panel._add_block_items(None, CLICKED_POSITION) + order_recorder.items[PASTE_ITEM].callback() + + assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] + + def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: + panel = _order_panel(Gestures(), can_paste=False) + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + + assert order_recorder.items[PASTE_ITEM].enabled is False + assert [item.enabled for item in order_recorder.items] == [True, True, False, True] + + def test_the_section_reads_as_the_four_clipboard_actions( + self, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + + assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] + + +class TestMenuItemOrder: + """The four items keep the order the indices name, which is what the item tests read them by.""" + + def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + labels = [item.label for item in tracker_recorder.items] + assert labels[COPY_ITEM] == "Copy" + assert labels[CUT_ITEM] == "Cut" + assert labels[PASTE_ITEM] == "Paste" + assert labels[DELETE_ITEM] == "Delete" diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index 3ea7fb3d..fdf7f284 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -1,3 +1,5 @@ +from typing import Optional + import pytest from pydantic import ValidationError @@ -6,7 +8,11 @@ OrderRegion, TrackerRegion, ) -from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -90,3 +96,83 @@ def test_a_row_off_the_channel_axis_is_rejected(self) -> None: first_position=0, last_position=0, ) + + +class TestTrackerRegionMembership: + """Which cells a rectangle holds, which is what a gesture raised on one asks.""" + + @pytest.fixture + def region(self) -> TrackerRegion: + return TrackerRegion(first_row=2, last_row=5, first_slot=3, last_slot=7) + + @pytest.mark.parametrize( + ("row", "slot_index"), + [ + (2, 3), + (5, 7), + (3, 5), + ], + ) + def test_a_cell_inside_the_rectangle_belongs_to_it( + self, + region: TrackerRegion, + row: int, + slot_index: int, + ) -> None: + assert region.covers(row, slot_from_flat(slot_index)) is True + + @pytest.mark.parametrize( + ("row", "slot_index"), + [ + (1, 5), + (6, 5), + (3, 2), + (3, 8), + ], + ) + def test_a_cell_outside_the_rectangle_stands_on_its_own( + self, + region: TrackerRegion, + row: int, + slot_index: int, + ) -> None: + assert region.covers(row, slot_from_flat(slot_index)) is False + + +class TestOrderRegionMembership: + @pytest.fixture + def region(self) -> OrderRegion: + return OrderRegion(first_row=1, last_row=2, first_position=3, last_position=6) + + @pytest.mark.parametrize( + ("generator", "position"), + [ + (GeneratorName.PULSE1, 3), + (GeneratorName.PULSE2, 6), + (GeneratorName.PULSE1, 5), + ], + ) + def test_a_cell_inside_the_rectangle_belongs_to_it( + self, + region: OrderRegion, + generator: GeneratorName, + position: int, + ) -> None: + assert region.covers(generator, position) is True + + @pytest.mark.parametrize( + ("generator", "position"), + [ + (None, 5), + (GeneratorName.TRIANGLE, 5), + (GeneratorName.PULSE1, 2), + (GeneratorName.PULSE1, 7), + ], + ) + def test_a_cell_outside_the_rectangle_stands_on_its_own( + self, + region: OrderRegion, + generator: Optional[GeneratorName], + position: int, + ) -> None: + assert region.covers(generator, position) is False From 873ddb21a73964df30d95eff59bddc94a1265f7f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 23:39:01 +0200 Subject: [PATCH 11/28] Extracted: shared clipboard action labels --- .../categories/context.py | 21 +++++++++++++++++++ .../categories/elements/global_.py | 4 ++++ .../categories/elements/sequencer.py | 8 ------- src/sampletones_application/ui/menu.py | 9 ++------ .../ui/panels/sequencer/order.py | 10 +++++---- .../ui/panels/sequencer/tracker.py | 10 +++++---- src/sampletones_config/lang/en.yaml | 12 ++++------- 7 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 src/sampletones_application/categories/context.py diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py new file mode 100644 index 00000000..69d18a9b --- /dev/null +++ b/src/sampletones_application/categories/context.py @@ -0,0 +1,21 @@ +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager + + +def context_label( + language_manager: LanguageManager, + element: ContextElements, +) -> str: + """Resolves a context-action label, the words every menu offering that action prints. + + Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the + sequencer grids, the file trees and the menu bar read them from one entry. A reader then + meets the same word for the same action, and a translation reaches all of them at once. + """ + return language_manager[ + Page.GLOBAL, + Panel.CONTEXT, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 5dfca380..f41b3494 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -33,6 +33,10 @@ class TreeElements(AbstractElement): class ContextElements(AbstractElement): PLAY = "play" + CUT = "cut" + COPY = "copy" + PASTE = "paste" + DELETE = "delete" MARK_AS_FAVORITE = "mark_as_favorite" UNMARK_AS_FAVORITE = "unmark_as_favorite" COPY_FILENAME = "copy_filename" diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index c5df06bf..51db2e5d 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,10 +30,6 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" - CONTEXT_COPY = "context_copy" - CONTEXT_CUT = "context_cut" - CONTEXT_PASTE = "context_paste" - CONTEXT_DELETE = "context_delete" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -66,10 +62,6 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" - CONTEXT_COPY = "context_copy" - CONTEXT_CUT = "context_cut" - CONTEXT_PASTE = "context_paste" - CONTEXT_DELETE = "context_delete" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 6b63ed91..056e7a52 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label from sampletones_application.categories.elements.global_ import ( ContextElements, MenuElements, @@ -152,13 +153,7 @@ def _label(self, element: MenuElements) -> str: ] def _context_label(self, element: ContextElements) -> str: - """Resolves a shared context-action label reused between the tree menus and this bar.""" - return self._language_manager[ - Page.GLOBAL, - Panel.CONTEXT, - TextType.LABEL, - element, - ] + return context_label(self._language_manager, element) def create(self, state: MenuBarViewModel) -> None: with dpg.menu_bar(): diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 69dd1ddb..b58c53f4 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -2,6 +2,8 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerOrderElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager @@ -211,10 +213,10 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) - self._lbl_context_copy = label(SequencerOrderElements.CONTEXT_COPY) - self._lbl_context_cut = label(SequencerOrderElements.CONTEXT_CUT) - self._lbl_context_paste = label(SequencerOrderElements.CONTEXT_PASTE) - self._lbl_context_delete = label(SequencerOrderElements.CONTEXT_DELETE) + self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) + self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) + self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) + self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 126e4dac..8918e1dd 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,6 +2,8 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerTrackerElements, ) @@ -244,10 +246,10 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) - self._lbl_context_copy = label(SequencerTrackerElements.CONTEXT_COPY) - self._lbl_context_cut = label(SequencerTrackerElements.CONTEXT_CUT) - self._lbl_context_paste = label(SequencerTrackerElements.CONTEXT_PASTE) - self._lbl_context_delete = label(SequencerTrackerElements.CONTEXT_DELETE) + self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) + self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) + self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) + self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6f29a114..fdffff2e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -134,6 +134,10 @@ global.browser.label.clear_search: "Clear" # Global — Context menu # ============================================================================= global.context.label.play: "Play" +global.context.label.cut: "Cut" +global.context.label.copy: "Copy" +global.context.label.paste: "Paste" +global.context.label.delete: "Delete" global.context.label.mark_as_favorite: "Mark as favorite" global.context.label.unmark_as_favorite: "Unmark as favorite" global.context.label.copy_filename: "Copy filename to clipboard" @@ -450,10 +454,6 @@ sequencer.tracker.label.column_triangle: "Triangle" sequencer.tracker.label.column_noise: "Noise" sequencer.tracker.label.context_play: "Play from here" sequencer.tracker.label.context_play_from_frame: "Play from this frame" -sequencer.tracker.label.context_copy: "Copy" -sequencer.tracker.label.context_cut: "Cut" -sequencer.tracker.label.context_paste: "Paste" -sequencer.tracker.label.context_delete: "Delete" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -487,10 +487,6 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" -sequencer.order.label.context_copy: "Copy" -sequencer.order.label.context_cut: "Cut" -sequencer.order.label.context_paste: "Paste" -sequencer.order.label.context_delete: "Delete" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" From 849ccbaf040534c56989685f0c09e4d004dfdef3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 00:07:12 +0200 Subject: [PATCH 12/28] Extracted: one action builder --- .../ui/panels/sequencer/input/order.py | 31 ++- .../ui/panels/sequencer/input/state.py | 31 ++- .../ui/panels/sequencer/input/target.py | 56 ++++ .../ui/panels/sequencer/order.py | 170 ++++++------ .../ui/panels/sequencer/tracker.py | 139 ++++------ .../ui/panels/sequencer/test_block_menu.py | 250 ++++++++++++++---- .../sequencer/test_tracker_context_menu.py | 15 +- 7 files changed, 447 insertions(+), 245 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/input/target.py diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index f62bbb40..67b58a25 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -66,6 +66,26 @@ def region(self) -> Optional[OrderRegion]: last_position=max(self.anchor.position, self.cursor.position), ) + def region_at(self, cell: OrderCursor) -> OrderRegion: + """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell + alone. + + A gesture raised inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects it to reach; one raised anywhere else acts on the cell it + names, which is a block of exactly that cell. + """ + region = self.region + if region is not None and region.covers(cell.generator, cell.position): + return region + + row = CHANNEL_AXIS.index(cell.generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=cell.position, + last_position=cell.position, + ) + @property def target_region(self) -> Optional[OrderRegion]: """The region a block gesture acts on: the selection, or the cursor's own cell. @@ -73,19 +93,10 @@ def target_region(self) -> Optional[OrderRegion]: A cursor with nothing selected stands on a block of one cell, so copying reaches the cell the reader is working in and needs no selection made first. """ - if self.region is not None: - return self.region - if self.cursor is None: return None - row = CHANNEL_AXIS.index(self.cursor.generator) - return OrderRegion( - first_row=row, - last_row=row, - first_position=self.cursor.position, - last_position=self.cursor.position, - ) + return self.region_at(self.cursor) def extend_to(self, cursor: OrderCursor) -> OrderInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index b89e11a0..d06d7716 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -104,6 +104,26 @@ def region(self) -> Optional[TrackerRegion]: last_slot=max(anchor_slot, cursor_slot), ) + def region_at(self, cell: TrackerCursor) -> TrackerRegion: + """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell + alone. + + A gesture raised inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects it to reach; one raised anywhere else acts on the cell it + names, which is a block of exactly that cell. + """ + slot = TrackerSlot(cell.generator, cell.subcolumn) + region = self.region + if region is not None and region.covers(cell.row, slot): + return region + + return TrackerRegion( + first_row=cell.row, + last_row=cell.row, + first_slot=slot.flat_index, + last_slot=slot.flat_index, + ) + @property def target_region(self) -> Optional[TrackerRegion]: """The region a block gesture acts on: the selection, or the cursor's own cell. @@ -111,19 +131,10 @@ def target_region(self) -> Optional[TrackerRegion]: A cursor with nothing selected stands on a block of one cell, so copying reaches the cell the reader is working in and needs no selection made first. """ - if self.region is not None: - return self.region - if self.cursor is None: return None - slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - return TrackerRegion( - first_row=self.cursor.row, - last_row=self.cursor.row, - first_slot=slot, - last_slot=slot, - ) + return self.region_at(self.cursor) def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py new file mode 100644 index 00000000..089a812c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass + +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) + + +@dataclass(frozen=True) +class TrackerMenuTarget: + """The tracker cell a set of actions was raised on, and the block those actions act on. + + Both are needed at once: the block decides what the clipboard actions cover, while the cell + decides where a pasted block lands and which row and channel the cell-level actions reach. + A target keeps the pair together, so a builder handed one prints a whole action set. + """ + + cell: TrackerCursor + region: TrackerRegion + + @property + def anchor(self) -> TrackerCell: + """The cell a pasted block is written from, which is the target's own row and column. + + A block carries the subcolumn offsets it was read at, so the anchor names a row and a + column and leaves the rest to the block. + """ + return TrackerCell( + row=self.cell.row, + generator=self.cell.generator, + ) + + +@dataclass(frozen=True) +class OrderMenuTarget: + """The order cell a set of actions was raised on, and the block those actions act on. + + Both are needed at once: the block decides what the clipboard actions cover, while the cell + decides where a pasted block lands and which frame the frame actions reach. + """ + + cell: OrderCursor + region: OrderRegion + + @property + def anchor(self) -> OrderCell: + """The cell a pasted block is written from, which is the target's own channel row and + position.""" + return OrderCell( + generator=self.cell.generator, + position=self.cell.position, + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index b58c53f4..9d16ef0e 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -51,6 +51,7 @@ OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderMenuTarget from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, create_selectable_text_theme, @@ -1043,6 +1044,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: + target = self._menu_target(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1053,114 +1055,106 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_block_items(generator, position) - dpg.add_separator() - dpg.add_menu_item( - label=self._lbl_context_duplicate, - shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), - callback=lambda: self.call(self.on_duplicate_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clone, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), - callback=lambda: self.call(self.on_clone_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_insert, - shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), - callback=lambda: self.call(self.on_insert_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clear, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), - callback=lambda: self.call(self.on_clear_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_remove, - shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), - callback=lambda: self.call(self.on_remove_requested, position), - ) - dpg.add_separator() - self._add_move_item( - self._lbl_context_move_left, - ShortcutId.ORDER_MOVE_FRAME_LEFT, - position, - ) - self._add_move_item( - self._lbl_context_move_right, - ShortcutId.ORDER_MOVE_FRAME_RIGHT, - position, - ) - self._add_move_item( - self._lbl_context_move_start, - ShortcutId.ORDER_MOVE_FRAME_TO_START, - position, - ) - self._add_move_item( - self._lbl_context_move_end, - ShortcutId.ORDER_MOVE_FRAME_TO_END, - position, - ) - - def _menu_region( - self, - generator: Optional[GeneratorName], - position: int, - ) -> OrderRegion: - """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + self._add_action_items(target) - A menu opened inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects the actions to reach; one opened anywhere else acts on the - cell it was raised on, the same block the cursor alone stands for. - """ - region = self._input_state.region - if region is not None and region.covers(generator, position): - return region - - row = CHANNEL_AXIS.index(generator) - return OrderRegion( - first_row=row, - last_row=row, - first_position=position, - last_position=position, + def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: + """The cell a set of actions is built for, paired with the block those actions act on.""" + return OrderMenuTarget( + cell=cell, + region=self._input_state.region_at(cell), ) - def _add_block_items( - self, - generator: Optional[GeneratorName], - position: int, - ) -> None: - """Builds the clipboard items, acting on the block the menu was raised on. + def _add_action_items(self, target: OrderMenuTarget) -> None: + """Builds every action an order cell offers, in the order each menu prints them. - Paste is offered once a block has been copied, and it anchors at the clicked cell, so the - menu lands a block where the pointer is while the keys land it under the cursor. Delete - prints no key of its own, because ``Del`` empties a selection while one stands and clears - the cell under the cursor otherwise. + The table states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. + """ + self._add_block_items(target) + dpg.add_separator() + self._add_frame_items(target.cell.position) + dpg.add_separator() + self._add_move_items(target.cell.position) + + def _add_block_items(self, target: OrderMenuTarget) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + Delete prints no key of its own, because ``Del`` empties a selection while one stands and + clears the cell under the cursor otherwise. """ - region = self._menu_region(generator, position) - cell = OrderCell( - generator=generator, - position=position, - ) dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, region), + callback=lambda: self.call(self.on_copy_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, region), + callback=lambda: self.call(self.on_cut_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, cell), + callback=lambda: self.call(self.on_paste_block, target.anchor), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, region), + callback=lambda: self.call(self.on_delete_block, target.region), + ) + + def _add_frame_items(self, position: int) -> None: + """Builds the frame operations, each acting on the whole frame the target cell sits in.""" + dpg.add_menu_item( + label=self._lbl_context_duplicate, + shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), + callback=lambda: self.call(self.on_duplicate_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_clone, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), + callback=lambda: self.call(self.on_clone_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_insert, + shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), + callback=lambda: self.call(self.on_insert_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_clear, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), + callback=lambda: self.call(self.on_clear_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_remove, + shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), + callback=lambda: self.call(self.on_remove_requested, position), + ) + + def _add_move_items(self, position: int) -> None: + """Builds the four moves a frame can make, in the order they walk the song.""" + self._add_move_item( + self._lbl_context_move_left, + ShortcutId.ORDER_MOVE_FRAME_LEFT, + position, + ) + self._add_move_item( + self._lbl_context_move_right, + ShortcutId.ORDER_MOVE_FRAME_RIGHT, + position, + ) + self._add_move_item( + self._lbl_context_move_start, + ShortcutId.ORDER_MOVE_FRAME_TO_START, + position, + ) + self._add_move_item( + self._lbl_context_move_end, + ShortcutId.ORDER_MOVE_FRAME_TO_END, + position, ) def _add_move_item( diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 8918e1dd..ab7ce277 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -56,6 +56,7 @@ EditAction, ) from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, @@ -1259,6 +1260,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: + target = self._menu_target(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1276,88 +1278,66 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_block_items(row_index, generator, subcolumn) - dpg.add_separator() - self._add_instrument_submenu(row_index, generator) - dpg.add_menu_item( - label=self._lbl_context_note_off, - callback=lambda: self.call(self.on_set_note_off, row_index, generator), - ) - dpg.add_separator() - self._add_transpose_items(row_index, generator) - dpg.add_separator() - self._add_volume_items(row_index, generator) - dpg.add_separator() - self._add_clear_items(row_index, generator, subcolumn) - - def _menu_region( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> TrackerRegion: - """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + self._add_action_items(target) - A menu opened inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects the actions to reach; one opened anywhere else acts on the - cell it was raised on, the same block the cursor alone stands for. - """ - slot = TrackerSlot(generator, subcolumn) - region = self._input_state.region - if region is not None and region.covers(row_index, slot): - return region - - return TrackerRegion( - first_row=row_index, - last_row=row_index, - first_slot=slot.flat_index, - last_slot=slot.flat_index, + def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: + """The cell a set of actions is built for, paired with the block those actions act on.""" + return TrackerMenuTarget( + cell=cell, + region=self._input_state.region_at(cell), ) - def _add_block_items( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - """Builds the clipboard items, acting on the block the menu was raised on. + def _add_action_items(self, target: TrackerMenuTarget) -> None: + """Builds every action a tracker cell offers, in the order each menu prints them. - Paste is offered once a block has been copied, and it anchors at the clicked cell, so the - menu lands a block where the pointer is while the keys land it under the cursor. Delete - prints no key of its own, because ``Del`` empties a selection while one stands and clears - the cell under the cursor otherwise. + The grid states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. """ - region = self._menu_region(row_index, generator, subcolumn) - cell = TrackerCell( - row=row_index, - generator=generator, + self._add_block_items(target) + dpg.add_separator() + self._add_instrument_submenu(target.cell) + dpg.add_menu_item( + label=self._lbl_context_note_off, + callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator), ) + dpg.add_separator() + self._add_transpose_items(target.cell) + dpg.add_separator() + self._add_volume_items(target.cell) + dpg.add_separator() + self._add_clear_items(target.cell) + + def _add_block_items(self, target: TrackerMenuTarget) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + Delete prints no key of its own, because ``Del`` empties a selection while one stands and + clears the cell under the cursor otherwise. + """ dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, region), + callback=lambda: self.call(self.on_copy_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, region), + callback=lambda: self.call(self.on_cut_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, cell), + callback=lambda: self.call(self.on_paste_block, target.anchor), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, region), + callback=lambda: self.call(self.on_delete_block, target.region), ) - def _add_instrument_submenu( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () if not samples: @@ -1370,15 +1350,11 @@ def _add_instrument_submenu( for index, sample in enumerate(samples): dpg.add_menu_item( label=tracker_display.indexed_label(index, sample.name), - user_data=(row_index, generator, sample.sample_id), + user_data=(cell.row, cell.generator, sample.sample_id), callback=self._on_set_instrument_menu, ) - def _add_transpose_items( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_transpose_items(self, cell: TrackerCursor) -> None: for label, delta in ( (self._lbl_context_transpose_up, SEMITONE_STEP), (self._lbl_context_transpose_down, -SEMITONE_STEP), @@ -1387,15 +1363,11 @@ def _add_transpose_items( ): dpg.add_menu_item( label=label, - user_data=(row_index, generator, delta), + user_data=(cell.row, cell.generator, delta), callback=self._on_transpose_menu, ) - def _add_volume_items( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_volume_items(self, cell: TrackerCursor) -> None: for label, delta in ( (self._lbl_context_volume_up, VOLUME_FINE_STEP), (self._lbl_context_volume_down, -VOLUME_FINE_STEP), @@ -1404,7 +1376,7 @@ def _add_volume_items( ): dpg.add_menu_item( label=label, - user_data=(row_index, generator, delta), + user_data=(cell.row, cell.generator, delta), callback=self._on_volume_menu, ) @@ -1435,13 +1407,8 @@ def _on_volume_menu( row_index, generator, delta = user_data self.call(self.on_adjust_volume, row_index, generator, delta) - def _add_clear_items( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - """Builds the three clear levels: the clicked subcolumn, the whole channel cell, the whole row. + def _add_clear_items(self, cell: TrackerCursor) -> None: + """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. The cell and row levels coincide on the sample column, which already clears every channel, so the per-channel ``Clear cell`` item is offered only for an actual channel. @@ -1450,23 +1417,23 @@ def _add_clear_items( label=self._lbl_context_clear_subcolumn, callback=lambda: self.call( self.on_clear_subcolumn, - row_index, - generator, - subcolumn, + cell.row, + cell.generator, + cell.subcolumn, ), ) - if generator is not None: + if cell.generator is not None: dpg.add_menu_item( label=self._lbl_context_clear_cell, callback=lambda: self.call( self.on_clear_row, - row_index, - generator, + cell.row, + cell.generator, ), ) dpg.add_menu_item( label=self._lbl_context_clear_row, - callback=lambda: self.call(self.on_clear_row, row_index, None), + callback=lambda: self.call(self.on_clear_row, cell.row, None), ) def _keys_active(self) -> bool: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 130d1f06..7776dc1d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -1,5 +1,7 @@ +import contextlib from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional +from types import ModuleType +from typing import Any, Callable, Iterator, List, Optional, Tuple import pytest @@ -55,6 +57,10 @@ class Gestures: pasted: List[Any] = field(default_factory=list) +def _prints_only() -> None: + """Stands in for the callback of an item that only states something, such as an empty list.""" + + class _MenuRecorder: """Captures the items a builder registers, in the order it registers them.""" @@ -66,17 +72,56 @@ def add_menu_item(self, **kwargs: Any) -> int: MenuItem( label=kwargs["label"], enabled=kwargs.get("enabled", True), - callback=kwargs["callback"], + callback=kwargs.get("callback", _prints_only), ) ) return 0 -def _labels(panel: Any) -> None: - panel._lbl_context_copy = "Copy" - panel._lbl_context_cut = "Cut" - panel._lbl_context_paste = "Paste" - panel._lbl_context_delete = "Delete" +CLIPBOARD_LABELS = { + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "delete": "Delete", +} + +TRACKER_LABELS = ( + "note_off", + "set_instrument", + "no_samples", + "clear_subcolumn", + "clear_cell", + "clear_row", + "transpose_up", + "transpose_down", + "transpose_octave_up", + "transpose_octave_down", + "volume_up", + "volume_down", + "volume_up_coarse", + "volume_down_coarse", +) + +ORDER_LABELS = ( + "duplicate", + "clone", + "insert", + "clear", + "remove", + "move_left", + "move_right", + "move_start", + "move_end", +) + + +def _labels(panel: Any, names: Tuple[str, ...]) -> None: + """Gives the panel the words its builders print, the clipboard four reading as they ship.""" + for name, text in CLIPBOARD_LABELS.items(): + setattr(panel, f"_lbl_context_{name}", text) + + for name in names: + setattr(panel, f"_lbl_context_{name}", name) def _tracker_panel( @@ -86,9 +131,10 @@ def _tracker_panel( ) -> tracker_module.GUISequencerTrackerPanel: """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) - _labels(panel) + _labels(panel, TRACKER_LABELS) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState() + panel._current_samples = None panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append panel.on_delete_block = gestures.deleted.append @@ -104,9 +150,10 @@ def _order_panel( ) -> order_module.GUISequencerOrderPanel: """An order panel whose menu builder can run with no DearPyGui context behind it.""" panel = order_module.GUISequencerOrderPanel.__new__(order_module.GUISequencerOrderPanel) - _labels(panel) + _labels(panel, ORDER_LABELS) panel._shortcuts = shipped_source() panel._input_state = OrderInputState() + panel._position_count = POSITION_COUNT panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append panel.on_delete_block = gestures.deleted.append @@ -115,18 +162,41 @@ def _order_panel( return panel -@pytest.fixture -def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: +@contextlib.contextmanager +def _submenu(**_kwargs: Any) -> Iterator[None]: + """Stands in for a submenu, whose items land in the same recording as the rest.""" + yield + + +def _record_into( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, +) -> _MenuRecorder: recorder = _MenuRecorder() - monkeypatch.setattr(tracker_module.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(module.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(module.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(module.dpg, "menu", _submenu) return recorder +@pytest.fixture +def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + return _record_into(monkeypatch, tracker_module) + + @pytest.fixture def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: - recorder = _MenuRecorder() - monkeypatch.setattr(order_module.dpg, "add_menu_item", recorder.add_menu_item) - return recorder + return _record_into(monkeypatch, order_module) + + +def _tracker_cell(generator: Optional[GeneratorName]) -> TrackerCursor: + """The clicked cell the tracker item tests raise their menu on.""" + return TrackerCursor(CLICKED_ROW, generator, SubColumn.INSTRUMENT) + + +def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor: + """The clicked cell the order item tests raise their menu on.""" + return OrderCursor(generator, CLICKED_POSITION) def _selected_tracker_state() -> TrackerInputState: @@ -146,25 +216,29 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - region = panel._menu_region( - CLICKED_ROW + 1, - GeneratorName.PULSE1, - SubColumn.TRANSPOSE, + target = panel._menu_target( + TrackerCursor( + CLICKED_ROW + 1, + GeneratorName.PULSE1, + SubColumn.TRANSPOSE, + ) ) - assert region == panel._input_state.region + assert target.region == panel._input_state.region def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - region = panel._menu_region( - CLICKED_ROW, - GeneratorName.TRIANGLE, - SubColumn.VOLUME, + target = panel._menu_target( + TrackerCursor( + CLICKED_ROW, + GeneratorName.TRIANGLE, + SubColumn.VOLUME, + ) ) - assert region == TrackerRegion( + assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, @@ -174,14 +248,35 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - region = panel._menu_region( - CLICKED_ROW, - None, - SubColumn.INSTRUMENT, - ) + target = panel._menu_target(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) - assert region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) - assert region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) + assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + + def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: + """The menu bar asks for the cursor's own target, which is the standing selection.""" + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + cursor = TrackerCursor(CLICKED_ROW + 2, GeneratorName.PULSE1, SubColumn.VOLUME) + + target = panel._menu_target(cursor) + + assert target.region == panel._input_state.region + + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: + panel = _tracker_panel(Gestures()) + cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + panel._input_state = TrackerInputState(cursor=cursor) + + target = panel._menu_target(cursor) + + assert target.region == TrackerRegion( + first_row=CLICKED_ROW, + last_row=CLICKED_ROW, + first_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + ) + assert target.anchor == TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE) class TestTrackerMenuItems: @@ -194,7 +289,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -207,7 +302,15 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord gestures = Gestures() panel = _tracker_panel(gestures) - panel._add_block_items(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + panel._add_block_items( + panel._menu_target( + TrackerCursor( + CLICKED_ROW, + GeneratorName.NOISE, + SubColumn.VOLUME, + ) + ) + ) tracker_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)] @@ -215,7 +318,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -226,7 +329,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -236,17 +339,17 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION + 1) + target = panel._menu_target(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) - assert region == panel._input_state.region + assert target.region == panel._input_state.region def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - region = panel._menu_region(None, CLICKED_POSITION) + target = panel._menu_target(_order_cell(None)) - assert region == OrderRegion( + assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), last_row=CHANNEL_AXIS.index(None), first_position=CLICKED_POSITION, @@ -256,14 +359,39 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION) + target = panel._menu_target(_order_cell(GeneratorName.PULSE1)) + + assert target.region == OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: + """The menu bar asks for the cursor's own target, which is the standing selection.""" + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 2) + + target = panel._menu_target(cursor) + + assert target.region == panel._input_state.region + + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: + panel = _order_panel(Gestures()) + cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) + panel._input_state = OrderInputState(cursor=cursor) + + target = panel._menu_target(cursor) - assert region == OrderRegion( + assert target.region == OrderRegion( first_row=PULSE1_ROW, last_row=PULSE1_ROW, first_position=CLICKED_POSITION, last_position=CLICKED_POSITION, ) + assert target.anchor == OrderCell(generator=GeneratorName.PULSE1, position=CLICKED_POSITION) class TestOrderMenuItems: @@ -276,7 +404,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -288,7 +416,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(None, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -296,7 +424,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -307,18 +435,46 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] +class TestActionSet: + """One builder states each grid's actions, so every menu offering them prints the same set.""" + + def test_the_tracker_action_set_opens_with_the_clipboard_items( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_action_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + + labels = [item.label for item in tracker_recorder.items] + assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert panel._lbl_context_clear_row in labels + + def test_the_order_action_set_opens_with_the_clipboard_items( + self, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + + panel._add_action_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + + labels = [item.label for item in order_recorder.items] + assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert panel._lbl_context_move_end in labels + + class TestMenuItemOrder: """The four items keep the order the indices name, which is what the item tests read them by.""" def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 0eb8a719..3e413f27 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,10 +4,12 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, ) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP @@ -74,13 +76,18 @@ def _menu(**kwargs: Any) -> Iterator[None]: return instance +def _cell(row: int, generator: GeneratorName) -> TrackerCursor: + """The cell a menu was raised on, which the items carry as their payload.""" + return TrackerCursor(row, generator, SubColumn.INSTRUMENT) + + class TestMenuDispatchPreservesPayload: def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] panel.on_adjust_transpose = lambda row, generator, delta: deltas.append(delta) - panel._add_transpose_items(2, GeneratorName.PULSE1) + panel._add_transpose_items(_cell(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -95,7 +102,7 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder deltas: List[int] = [] panel.on_adjust_volume = lambda row, generator, delta: deltas.append(delta) - panel._add_volume_items(2, GeneratorName.PULSE1) + panel._add_volume_items(_cell(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -110,7 +117,7 @@ def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRec calls: List[Tuple[int, GeneratorName, int]] = [] panel.on_adjust_transpose = lambda row, generator, delta: calls.append((row, generator, delta)) - panel._add_transpose_items(7, GeneratorName.TRIANGLE) + panel._add_transpose_items(_cell(7, GeneratorName.TRIANGLE)) recorder.dispatch_as_dpg() assert calls[0] == (7, GeneratorName.TRIANGLE, SEMITONE_STEP) @@ -129,7 +136,7 @@ def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) chosen: List[str] = [] panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id) - panel._add_instrument_submenu(0, GeneratorName.PULSE2) + panel._add_instrument_submenu(_cell(0, GeneratorName.PULSE2)) recorder.dispatch_as_dpg() assert chosen == ["lead-id"] From 4fc2f427f4643b3f21c66bed3d0a307cf58682e9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 00:53:55 +0200 Subject: [PATCH 13/28] Added: focus-aware actions in the Edit menu --- src/sampletones_application/application.py | 8 ++ .../coordinators/edit/__init__.py | 0 .../coordinators/edit/protocol.py | 7 ++ .../coordinators/edit/router.py | 43 ++++++++ .../coordinators/tabs/sequencer.py | 21 +++- src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 75 ++++++++++++- .../ui/panels/sequencer/order.py | 19 ++++ .../ui/panels/sequencer/tracker.py | 19 ++++ src/sampletones_application/utils/gui/dpg.py | 16 +++ .../utils/gui/staging.py | 6 +- .../coordinators/edit/__init__.py | 0 .../coordinators/edit/test_router.py | 66 +++++++++++ .../sampletones_application/ui/test_menu.py | 103 +++++++++++++++++- 14 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 src/sampletones_application/coordinators/edit/__init__.py create mode 100644 src/sampletones_application/coordinators/edit/protocol.py create mode 100644 src/sampletones_application/coordinators/edit/router.py create mode 100644 tests/unit/sampletones_application/coordinators/edit/__init__.py create mode 100644 tests/unit/sampletones_application/coordinators/edit/test_router.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index f1b0653d..9f7ce0ac 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -15,6 +15,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator +from sampletones_application.coordinators.edit.router import EditRouter from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -312,6 +313,7 @@ def __init__( player_glyphs=self.layout.glyphs.player, player_layout=self.layout.player, language_manager=self.language_manager, + build_edit_actions=self._build_edit_actions, on_play_from_start=self._play_from_start, on_pause_or_resume=self._play, on_stop=self._stop, @@ -461,6 +463,8 @@ def __init__( on_channels_changed=self._update_menu, ) + self._edit_router = EditRouter(surfaces=self._sequencer_tab.edit_surfaces) + self._playback_router = PlaybackRouter( sources=( self._reconstructions_tab.player, @@ -1325,6 +1329,10 @@ def _persist_application_state(self) -> None: self.session_manager.set_current_tab(current_tab) self.session_manager.save_config() + def _build_edit_actions(self) -> bool: + """States the actions of the grid holding the cursor into the Edit menu being built.""" + return self._edit_router.build_menu_actions() + def _play_from_start(self) -> None: self._playback_router.play_from_start() self._update_menu() diff --git a/src/sampletones_application/coordinators/edit/__init__.py b/src/sampletones_application/coordinators/edit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/coordinators/edit/protocol.py b/src/sampletones_application/coordinators/edit/protocol.py new file mode 100644 index 00000000..3c5c57fe --- /dev/null +++ b/src/sampletones_application/coordinators/edit/protocol.py @@ -0,0 +1,7 @@ +from typing import Protocol + + +class EditSurfaceProtocol(Protocol): + def owns_edit_actions(self) -> bool: ... + + def build_edit_actions(self) -> None: ... diff --git a/src/sampletones_application/coordinators/edit/router.py b/src/sampletones_application/coordinators/edit/router.py new file mode 100644 index 00000000..edd6c3c2 --- /dev/null +++ b/src/sampletones_application/coordinators/edit/router.py @@ -0,0 +1,43 @@ +from typing import Optional, Sequence + +from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol + + +class EditRouter: + """The single editing surface behind the menu bar's Edit menu. + + A surface is a grid that offers editing gestures on the cell it holds a cursor in. Each one + states whether it owns those gestures at this moment, and the router asks the one that does to + build its actions into the menu being built. Surfaces are mutually exclusive, since taking a + cursor in one drops the cursor of the others, so at most one answers. + + It is stateless: the surface is resolved on each call, so the menu states the actions of + whoever holds the cursor when it is opened, and needs no notice of cursors moving. + + The router itself draws nothing. It calls the surface, which builds its items into the + container the menu bar has opened, the way :class:`PlaybackRouter` calls a source to play. + """ + + def __init__(self, *, surfaces: Sequence[EditSurfaceProtocol]) -> None: + self._surfaces = tuple(surfaces) + + def build_menu_actions(self) -> bool: + """Builds the focused surface's actions, reporting whether a surface stated any. + + Returns: + bool: Whether a surface owned the editing gestures and built its actions. + """ + surface = self._focused_surface() + if surface is None: + return False + + surface.build_edit_actions() + return True + + def _focused_surface(self) -> Optional[EditSurfaceProtocol]: + """The surface owning the editing gestures, or ``None`` while a reader edits elsewhere.""" + for surface in self._surfaces: + if surface.owns_edit_actions(): + return surface + + return None diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 20486780..2ed05068 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional, ParamSpec, Union +from typing import Callable, Optional, ParamSpec, Tuple, Union import dearpygui.dearpygui as dpg @@ -11,6 +11,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.playback import FollowMode +from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -1277,7 +1278,12 @@ def _settle_inserted_frame(self, position: int) -> None: standing on one of them follows it, and the grid moves to the new frame for the reader to work on. """ - self._relocate_playhead(lambda playhead: remap_after_insert(playhead, position)) + self._relocate_playhead( + lambda playhead: remap_after_insert( + playhead, + position, + ) + ) self._select_frame_when_idle(position) def _on_order_insert(self, position: int) -> None: @@ -1411,3 +1417,14 @@ def _build_right_column(self, parent: str) -> None: @property def player(self) -> AudioPlayerProtocol: return self._guarded_player + + @property + def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: + """The grids offering editing gestures on the cell they hold a cursor in. + + The two hold one cursor between them, so the menu bar reaches whichever one has it. + """ + return ( + self._sequencer_tracker_panel, + self._sequencer_order_panel, + ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index d1814485..ecdfc8f7 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -440,6 +440,12 @@ Widget.MENU, "item_edit_redo", ) +TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_edit_actions", +) TAG_GLOBAL_DIALOG_PROJECT_SAVED = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 056e7a52..b9b21d34 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Dict, Final, Tuple +from typing import Callable, Dict, Final, Optional, Tuple import dearpygui.dearpygui as dpg @@ -19,6 +19,8 @@ from sampletones_application.layout.player import PlayerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_REGISTRY, + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, @@ -68,6 +70,8 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( dpg_configure_item, + dpg_container, + dpg_delete_children, dpg_set_item_label, dpg_set_value, ) @@ -81,6 +85,7 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback PROJECT_ITEM_TAGS: Final[Tuple[str, ...]] = ( @@ -101,6 +106,12 @@ FollowMode.PATTERNS: MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS, FollowMode.OFF: MenuElements.ITEM_PLAYBACK_FOLLOW_OFF, } +UNFOCUSED_CLIPBOARD_ELEMENTS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = { GeneratorName.PULSE1: ContextElements.PULSE_1, GeneratorName.PULSE2: ContextElements.PULSE_2, @@ -120,6 +131,7 @@ def __init__( player_glyphs: PlayerGlyphs, player_layout: PlayerLayout, language_manager: LanguageManager, + build_edit_actions: Callable[[], bool], on_play_from_start: VoidCallback, on_pause_or_resume: VoidCallback, on_stop: VoidCallback, @@ -132,6 +144,7 @@ def __init__( self._player_glyphs = player_glyphs self._player_layout = player_layout self._language_manager = language_manager + self._build_edit_actions = build_edit_actions self._on_play_from_start = on_play_from_start self._on_pause_or_resume = on_pause_or_resume self._on_stop = on_stop @@ -144,6 +157,12 @@ def __init__( self._pause_tooltip_tag = compose_tag(self._pause_button_tag, SUF_PLAYER_TOOLTIP) self._lbl_pause = language_manager["global.player.label.pause"] + self._edit_actions_handler_tag = compose_tag( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + SUF_HANDLER_REGISTRY, + ) + self._edit_actions_frame: Optional[int] = None + def _label(self, element: MenuElements) -> str: return self._language_manager[ Page.GLOBAL, @@ -237,6 +256,11 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: ) def _create_edit_menu(self, state: MenuBarViewModel) -> None: + """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor. + + The trailing section is a container of its own, so the actions it holds are stated afresh + each time the menu is opened while Undo and Redo stand where they are. + """ with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): self._shortcut_manager.add_menu_item( ShortcutId.UNDO, @@ -250,6 +274,55 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_EDIT_REDO), enabled=state.redo_enabled, ) + dpg.add_separator() + dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) + + with dpg.item_handler_registry(tag=self._edit_actions_handler_tag): + dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn) + + dpg.bind_item_handler_registry( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + self._edit_actions_handler_tag, + ) + self._refresh_edit_actions() + + def _on_edit_actions_drawn( + self, + _sender: Sender, + _app_data: Sender, + ) -> None: + """States the actions afresh each time the Edit menu is opened. + + DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in + those reports marks a fresh opening. The section stays built between openings, which gives + the popup its full height on the frame it appears, and the rebuilt one takes over a frame + later — long before an item can be reached and chosen. + """ + frame = dpg.get_frame_count() + reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1 + self._edit_actions_frame = frame + if reopened: + self._refresh_edit_actions() + + def _refresh_edit_actions(self) -> None: + """Empties the Edit menu's trailing section and asks the focused surface to state it. + + A surface builds the same actions its own cell menu offers, so the two doors print one set + with the keys and the enablement each action carries. With no surface holding a cursor, the + clipboard actions are named greyed out, so a reader still meets the commands. + """ + dpg_delete_children(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) + with dpg_container(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS): + if not self._build_edit_actions(): + self._add_unfocused_clipboard_items() + + def _add_unfocused_clipboard_items(self) -> None: + """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor.""" + for element in UNFOCUSED_CLIPBOARD_ELEMENTS: + dpg.add_menu_item( + label=self._context_label(element), + enabled=False, + ) def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_RECONSTRUCTION)): diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 9d16ef0e..080fa18b 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -1064,6 +1064,25 @@ def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: region=self._input_state.region_at(cell), ) + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this table's actions, which it does while it owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._keys_active() + + def build_edit_actions(self) -> None: + """Builds this table's whole action set for the cell the cursor stands on. + + The menu bar asks while the table owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + cursor = self._input_state.cursor + if cursor is None: + return + + self._add_action_items(self._menu_target(cursor)) + def _add_action_items(self, target: OrderMenuTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index ab7ce277..249f0e21 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1287,6 +1287,25 @@ def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: region=self._input_state.region_at(cell), ) + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._keys_active() + + def build_edit_actions(self) -> None: + """Builds this grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + cursor = self._input_state.cursor + if cursor is None: + return + + self._add_action_items(self._menu_target(cursor)) + def _add_action_items(self, target: TrackerMenuTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 8e093677..42bd49c7 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -1,8 +1,10 @@ import functools +from contextlib import contextmanager from typing import ( Any, Callable, Concatenate, + Iterator, Optional, ParamSpec, TypeVar, @@ -55,6 +57,20 @@ def dpg_delete_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None: dpg.delete_item(tag, *args, **kwargs) +@contextmanager +def dpg_container(tag: Sender) -> Iterator[None]: + """Makes ``tag`` the container parentless items land in for the length of the block. + + A builder that states its items without naming a parent can then be pointed at any container, + which is how one set of items is built into the menu, popup or panel that asked for it. + """ + dpg.push_container_stack(tag) + try: + yield + finally: + dpg.pop_container_stack() + + def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) diff --git a/src/sampletones_application/utils/gui/staging.py b/src/sampletones_application/utils/gui/staging.py index e3b872f7..f4f31af1 100644 --- a/src/sampletones_application/utils/gui/staging.py +++ b/src/sampletones_application/utils/gui/staging.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg +from sampletones_application.utils.gui.dpg import dpg_container from sampletones_shared.types.application import Sender @@ -25,11 +26,8 @@ def staged_container(stage: Sender) -> Iterator[None]: Items created with an explicit parent still honour that parent; the stage captures the parentless ones. """ - dpg.push_container_stack(stage) - try: + with dpg_container(stage): yield - finally: - dpg.pop_container_stack() def attach_staged_item(item: Sender, parent: Sender) -> None: diff --git a/tests/unit/sampletones_application/coordinators/edit/__init__.py b/tests/unit/sampletones_application/coordinators/edit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/coordinators/edit/test_router.py b/tests/unit/sampletones_application/coordinators/edit/test_router.py new file mode 100644 index 00000000..61d52fc8 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/edit/test_router.py @@ -0,0 +1,66 @@ +from typing import List, Sequence + +from sampletones_application.coordinators.edit.router import EditRouter + + +class FakeSurface: + """A test double for a grid offering editing gestures on the cell it holds a cursor in.""" + + def __init__(self, name: str, *, focused: bool) -> None: + self.name = name + self.focused = focused + self.builds = 0 + + def owns_edit_actions(self) -> bool: + return self.focused + + def build_edit_actions(self) -> None: + self.builds += 1 + + +def _router(surfaces: Sequence[FakeSurface]) -> EditRouter: + return EditRouter(surfaces=surfaces) + + +class TestFocusedSurface: + """The menu states the actions of whoever holds the cursor when it is opened.""" + + def test_the_focused_surface_states_its_actions(self) -> None: + tracker = FakeSurface("tracker", focused=True) + order = FakeSurface("order", focused=False) + router = _router([tracker, order]) + + assert router.build_menu_actions() is True + assert (tracker.builds, order.builds) == (1, 0) + + def test_a_surface_left_behind_states_nothing(self) -> None: + tracker = FakeSurface("tracker", focused=False) + order = FakeSurface("order", focused=True) + router = _router([tracker, order]) + + router.build_menu_actions() + + assert (tracker.builds, order.builds) == (0, 1) + + def test_nothing_is_built_with_no_surface_focused(self) -> None: + """A tab switch leaves both grids holding their cursors while neither owns the keys.""" + surfaces = [FakeSurface("tracker", focused=False), FakeSurface("order", focused=False)] + router = _router(surfaces) + + assert router.build_menu_actions() is False + assert [surface.builds for surface in surfaces] == [0, 0] + + def test_a_router_with_no_surface_reports_nothing_built(self) -> None: + assert _router([]).build_menu_actions() is False + + def test_the_surface_is_resolved_on_each_call(self) -> None: + """The router holds no target, so a cursor taken after it was built reaches the menu.""" + tracker = FakeSurface("tracker", focused=False) + router = _router([tracker]) + + first: List[bool] = [router.build_menu_actions()] + tracker.focused = True + first.append(router.build_menu_actions()) + + assert first == [False, True] + assert tracker.builds == 1 diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 1f13cf65..d4474195 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from typing import Any, Dict, FrozenSet, Iterator, List +from typing import Any, Callable, Dict, FrozenSet, Iterator, List import pytest @@ -7,6 +7,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) @@ -57,6 +58,9 @@ def __init__(self) -> None: self.values: Dict[str, bool] = {} self.enabled: Dict[str, bool] = {} self.menus: List[Dict[str, Any]] = [] + self.items: List[Dict[str, Any]] = [] + self.containers: List[str] = [] + self.emptied: List[str] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: @@ -69,6 +73,18 @@ def submenu(self, tag: str) -> Dict[str, Any]: def add_separator(self, **kwargs: Any) -> int: return 0 + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append(kwargs) + return 0 + + @contextmanager + def container(self, tag: str) -> Iterator[None]: + self.containers.append(tag) + yield + + def delete_children(self, tag: str) -> None: + self.emptied.append(tag) + def set_value(self, item: str, value: bool) -> None: self.values[item] = value @@ -113,8 +129,11 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: instance = _DearPyGuiRecorder() monkeypatch.setattr(menu_module.dpg, "menu", instance.menu) monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value) monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item) + monkeypatch.setattr(menu_module, "dpg_container", instance.container) + monkeypatch.setattr(menu_module, "dpg_delete_children", instance.delete_children) return instance @@ -359,3 +378,85 @@ def test_a_full_mix_withholds_the_restore( menu_bar._update_channels(_state(frozenset())) assert framework.enabled == {TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS: False} + + +def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: + """A bar holding what the Edit menu's trailing section reads, and nothing else.""" + instance = MenuBar.__new__(MenuBar) + instance._language_manager = LanguageManager(LANG_EN) + instance._build_edit_actions = build_edit_actions + instance._edit_actions_frame = None + return instance + + +class TestEditActionsSection: + """The Edit menu carries the actions of the grid holding the cursor, and names them itself + while no grid holds one.""" + + def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused( + self, + framework: _DearPyGuiRecorder, + ) -> None: + _edit_bar(lambda: False)._refresh_edit_actions() + + assert [item["label"] for item in framework.items] == ["Copy", "Cut", "Paste", "Delete"] + assert [item["enabled"] for item in framework.items] == [False] * 4 + + def test_a_focused_grid_states_its_own_actions( + self, + framework: _DearPyGuiRecorder, + ) -> None: + requests: List[bool] = [] + + def build() -> bool: + requests.append(True) + return True + + _edit_bar(build)._refresh_edit_actions() + + assert requests == [True] + assert framework.items == [] + + def test_the_section_is_emptied_before_the_actions_are_stated( + self, + framework: _DearPyGuiRecorder, + ) -> None: + _edit_bar(lambda: False)._refresh_edit_actions() + + assert framework.emptied == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + + +class TestEditActionsRefresh: + """DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in + those reports is what marks a fresh opening.""" + + def test_the_actions_are_stated_once_while_the_menu_stays_open( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 12, 13]) + monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + menu_bar = _edit_bar(lambda: bool(requests.append(1))) + + for _ in range(4): + menu_bar._on_edit_actions_drawn(0, 0) + + assert len(requests) == 1 + + def test_the_actions_are_stated_afresh_each_time_the_menu_is_opened( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 40, 41]) + monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + menu_bar = _edit_bar(lambda: bool(requests.append(1))) + + for _ in range(4): + menu_bar._on_edit_actions_drawn(0, 0) + + assert len(requests) == 2 From 78f38cf48c9f969935ebd088c4ffd10539f29ea1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 01:21:15 +0200 Subject: [PATCH 14/28] Fixed: the Edit menu growing --- src/sampletones_application/tags/general.py | 10 +- src/sampletones_application/ui/menu.py | 55 +++++++---- src/sampletones_application/utils/gui/dpg.py | 26 ++++- .../sampletones_application/ui/test_menu.py | 99 +++++++++++++++---- 4 files changed, 150 insertions(+), 40 deletions(-) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index ecdfc8f7..34749614 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -440,11 +440,17 @@ Widget.MENU, "item_edit_redo", ) -TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS = TagName( +TAG_GLOBAL_MENU_GROUP_EDIT = TagName( Page.GLOBAL, Panel.IMPLICIT, Widget.MENU, - "group_edit_actions", + "group_edit", +) +TAG_GLOBAL_MENU_GROUP_EDIT_MARKER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_edit_marker", ) TAG_GLOBAL_DIALOG_PROJECT_SAVED = TagName( Page.GLOBAL, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index b9b21d34..377429b8 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -20,7 +20,8 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_HANDLER_REGISTRY, - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, @@ -69,9 +70,9 @@ ) from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( + dpg_append_items, dpg_configure_item, - dpg_container, - dpg_delete_children, + dpg_delete_item, dpg_set_item_label, dpg_set_value, ) @@ -158,10 +159,11 @@ def __init__( self._lbl_pause = language_manager["global.player.label.pause"] self._edit_actions_handler_tag = compose_tag( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, SUF_HANDLER_REGISTRY, ) self._edit_actions_frame: Optional[int] = None + self._edit_action_items: Tuple[Sender, ...] = () def _label(self, element: MenuElements) -> str: return self._language_manager[ @@ -258,10 +260,16 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: def _create_edit_menu(self, state: MenuBarViewModel) -> None: """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor. - The trailing section is a container of its own, so the actions it holds are stated afresh - each time the menu is opened while Undo and Redo stand where they are. + The actions are stated into the menu itself and taken away again on each opening, so they + follow the cursor. A marker leads the menu, holding nothing and reporting the popup drawn: + a container standing below a menu item takes the width those items span as its own, which + the popup then grows to fit on every frame it stays open. """ - with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): + with dpg.menu( + label=self._label(MenuElements.GROUP_EDIT), + tag=TAG_GLOBAL_MENU_GROUP_EDIT, + ): + dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER) self._shortcut_manager.add_menu_item( ShortcutId.UNDO, tag=TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, @@ -275,13 +283,12 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: enabled=state.redo_enabled, ) dpg.add_separator() - dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) with dpg.item_handler_registry(tag=self._edit_actions_handler_tag): dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn) dpg.bind_item_handler_registry( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, self._edit_actions_handler_tag, ) self._refresh_edit_actions() @@ -293,10 +300,10 @@ def _on_edit_actions_drawn( ) -> None: """States the actions afresh each time the Edit menu is opened. - DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in - those reports marks a fresh opening. The section stays built between openings, which gives - the popup its full height on the frame it appears, and the rebuilt one takes over a frame - later — long before an item can be reached and chosen. + DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in + those reports marks a fresh opening. The actions stay standing between openings, which + gives the popup its full height on the frame it appears, and the rebuilt ones take over a + frame later — long before an item can be reached and chosen. """ frame = dpg.get_frame_count() reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1 @@ -305,16 +312,24 @@ def _on_edit_actions_drawn( self._refresh_edit_actions() def _refresh_edit_actions(self) -> None: - """Empties the Edit menu's trailing section and asks the focused surface to state it. + """Takes the standing actions out of the Edit menu and asks the focused surface for its own. A surface builds the same actions its own cell menu offers, so the two doors print one set - with the keys and the enablement each action carries. With no surface holding a cursor, the - clipboard actions are named greyed out, so a reader still meets the commands. + with the keys and the enablement each action carries. The history steps above them stand + where they are, since only what the last build stated is taken away. """ - dpg_delete_children(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) - with dpg_container(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS): - if not self._build_edit_actions(): - self._add_unfocused_clipboard_items() + for item in self._edit_action_items: + dpg_delete_item(item) + + self._edit_action_items = dpg_append_items( + TAG_GLOBAL_MENU_GROUP_EDIT, + self._add_edit_action_items, + ) + + def _add_edit_action_items(self) -> None: + """States the focused surface's actions, or the clipboard four greyed out while none is.""" + if not self._build_edit_actions(): + self._add_unfocused_clipboard_items() def _add_unfocused_clipboard_items(self) -> None: """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor.""" diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 42bd49c7..4f1c9a31 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -4,9 +4,12 @@ Any, Callable, Concatenate, + Final, Iterator, + List, Optional, ParamSpec, + Tuple, TypeVar, cast, ) @@ -15,11 +18,13 @@ from sampletones_application.ui.elements.button import GUIButton from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import Callback +from sampletones_shared.types.callback import Callback, VoidCallback P = ParamSpec("P") R = TypeVar("R") +SLOT_ITEMS: Final[int] = 1 + def dpg_wrapper( button_function: Optional[Callback] = None, @@ -75,6 +80,25 @@ def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) +def dpg_item_children(tag: Sender) -> Tuple[Sender, ...]: + """The items the container holds, in the order they are drawn.""" + children = cast(List[Sender], dpg.get_item_children(tag, SLOT_ITEMS)) + return tuple(children) + + +def dpg_append_items(tag: Sender, build: VoidCallback) -> Tuple[Sender, ...]: + """Runs ``build`` with ``tag`` open as the container, reporting the items it left there. + + What one build stated is known by what the container gained, so a caller that rebuilds a + section takes exactly those items away again and leaves the rest of the container standing. + """ + standing = len(dpg_item_children(tag)) + with dpg_container(tag): + build() + + return dpg_item_children(tag)[standing:] + + def dpg_bind_item_theme( tag: Sender, theme_tag: Sender, diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d4474195..e63f59d3 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from typing import Any, Callable, Dict, FrozenSet, Iterator, List +from typing import Any, Callable, Dict, FrozenSet, Iterator, List, Tuple import pytest @@ -7,7 +7,8 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) @@ -37,11 +38,13 @@ class _ShortcutManagerRecorder: """Records the items a menu asks for, in place of the manager that would create them.""" - def __init__(self) -> None: + def __init__(self, built: List[str]) -> None: self.items: List[Dict[str, Any]] = [] + self._built = built def add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None: self.items.append({"shortcut_id": shortcut_id, **kwargs}) + self._built.append(f"item:{kwargs['label']}") @property def labels(self) -> List[str]: @@ -59,8 +62,9 @@ def __init__(self) -> None: self.enabled: Dict[str, bool] = {} self.menus: List[Dict[str, Any]] = [] self.items: List[Dict[str, Any]] = [] + self.built: List[str] = [] self.containers: List[str] = [] - self.emptied: List[str] = [] + self.deleted: List[int] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: @@ -71,19 +75,37 @@ def submenu(self, tag: str) -> Dict[str, Any]: return next(entry for entry in self.menus if entry.get("tag") == tag) def add_separator(self, **kwargs: Any) -> int: + self.built.append("separator") + return 0 + + def add_group(self, *, tag: str) -> int: + self.built.append(f"group:{tag}") return 0 def add_menu_item(self, **kwargs: Any) -> int: self.items.append(kwargs) + self.built.append(f"item:{kwargs['label']}") return 0 @contextmanager - def container(self, tag: str) -> Iterator[None]: + def item_handler_registry(self, **kwargs: Any) -> Iterator[int]: + yield 0 + + def add_item_visible_handler(self, **kwargs: Any) -> int: + return 0 + + def bind_item_handler_registry(self, item: str, registry: str) -> None: + return None + + def append_items(self, tag: str, build: Callable[[], None]) -> Tuple[int, ...]: + """Stands in for the helper that reports what one build left in the container.""" self.containers.append(tag) - yield + standing = len(self.items) + build() + return tuple(range(standing, len(self.items))) - def delete_children(self, tag: str) -> None: - self.emptied.append(tag) + def delete_item(self, item: int) -> None: + self.deleted.append(item) def set_value(self, item: str, value: bool) -> None: self.values[item] = value @@ -130,16 +152,20 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: monkeypatch.setattr(menu_module.dpg, "menu", instance.menu) monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_group", instance.add_group) + monkeypatch.setattr(menu_module.dpg, "item_handler_registry", instance.item_handler_registry) + monkeypatch.setattr(menu_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler) + monkeypatch.setattr(menu_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry) monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value) monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item) - monkeypatch.setattr(menu_module, "dpg_container", instance.container) - monkeypatch.setattr(menu_module, "dpg_delete_children", instance.delete_children) + monkeypatch.setattr(menu_module, "dpg_append_items", instance.append_items) + monkeypatch.setattr(menu_module, "dpg_delete_item", instance.delete_item) return instance @pytest.fixture -def shortcuts() -> _ShortcutManagerRecorder: - return _ShortcutManagerRecorder() +def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder: + return _ShortcutManagerRecorder(framework.built) @pytest.fixture @@ -153,11 +179,15 @@ def menu_bar( shortcuts: _ShortcutManagerRecorder, switched: List[GeneratorName], ) -> MenuBar: - """A bar with the collaborators its Channels submenu reads, from the real language file.""" + """A bar with the collaborators its submenus read, from the real language file.""" instance = MenuBar.__new__(MenuBar) instance._shortcut_manager = shortcuts instance._language_manager = LanguageManager(LANG_EN) instance._on_channel_muted = switched.append + instance._build_edit_actions = lambda: False + instance._edit_actions_handler_tag = "handlers" + instance._edit_actions_frame = None + instance._edit_action_items = () return instance @@ -381,11 +411,12 @@ def test_a_full_mix_withholds_the_restore( def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: - """A bar holding what the Edit menu's trailing section reads, and nothing else.""" + """A bar holding what the Edit menu's action section reads, and nothing else.""" instance = MenuBar.__new__(MenuBar) instance._language_manager = LanguageManager(LANG_EN) instance._build_edit_actions = build_edit_actions instance._edit_actions_frame = None + instance._edit_action_items = () return instance @@ -417,14 +448,48 @@ def build() -> bool: assert requests == [True] assert framework.items == [] - def test_the_section_is_emptied_before_the_actions_are_stated( + def test_the_actions_are_stated_into_the_menu_itself( self, framework: _DearPyGuiRecorder, ) -> None: _edit_bar(lambda: False)._refresh_edit_actions() - assert framework.emptied == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] - assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT] + + def test_a_build_takes_away_only_what_the_one_before_it_stated( + self, + framework: _DearPyGuiRecorder, + ) -> None: + menu_bar = _edit_bar(lambda: False) + + menu_bar._refresh_edit_actions() + menu_bar._refresh_edit_actions() + + assert framework.deleted == [0, 1, 2, 3] + + +class TestEditMenuOrder: + """The marker leads the Edit menu. A container standing below a menu item takes the width the + items span as its own, and the popup grows to fit it on every frame it stays open.""" + + def test_the_marker_stands_before_every_item( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_edit_menu(_state(frozenset())) + + assert framework.built == [ + f"group:{TAG_GLOBAL_MENU_GROUP_EDIT_MARKER}", + "item:Undo", + "item:Redo", + "separator", + "item:Copy", + "item:Cut", + "item:Paste", + "item:Delete", + ] class TestEditActionsRefresh: From 8391b4a345e65590686cf2133222358de72f771c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 01:43:52 +0200 Subject: [PATCH 15/28] Extracted: the grid drag-selection gesture --- .../ui/elements/table/drag.py | 86 +++++++++- .../ui/panels/sequencer/order.py | 42 ++--- .../ui/panels/sequencer/tracker.py | 42 ++--- .../ui/elements/table/test_cells.py | 21 +++ .../ui/elements/table/test_drag.py | 147 ++++++++++++++++++ .../panels/sequencer/test_selection_drag.py | 102 ++++++------ 6 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/table/test_drag.py diff --git a/src/sampletones_application/ui/elements/table/drag.py b/src/sampletones_application/ui/elements/table/drag.py index bde55c5e..41f12172 100644 --- a/src/sampletones_application/ui/elements/table/drag.py +++ b/src/sampletones_application/ui/elements/table/drag.py @@ -1,6 +1,10 @@ from collections.abc import Hashable from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Callable, Generic, Optional, TypeVar + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_shared.types.application import Sender KeyT = TypeVar("KeyT", bound=Hashable) @@ -19,3 +23,83 @@ class DragGesture(Generic[KeyT]): origin: KeyT extends: bool moved: bool = False + + +@dataclass(frozen=True) +class DragReach(Generic[KeyT]): + """How far a drag has carried the pointer, and which end it grew from. + + A plain drag anchors a fresh selection at ``origin`` and runs it out to ``reached``; a drag + whose press held Shift reports ``extends``, and carries the selection already on the grid + out to ``reached`` instead. + """ + + origin: KeyT + reached: KeyT + extends: bool + + +class DragSelection(Generic[KeyT]): + """The gesture a grid selection is dragged out with. + + A grid hands its pointer reports here — the cell a press holds, the click that follows, the + press that starts the next gesture — and states the reach that comes back as a selection in + its own coordinates. The cell cache names the widget a press landed on, and ``cell_at`` reads + the cell the pointer stands on now off the grid's geometry. + """ + + def __init__( + self, + *, + cells: EditableCells[KeyT], + cell_at: Callable[[], Optional[KeyT]], + ) -> None: + self._cells = cells + self._cell_at = cell_at + self._gesture: Optional[DragGesture[KeyT]] = None + + def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]: + """How far a held pointer has carried, once it has left the cell the press landed on. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the grid's own geometry while the held widget names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._gesture is None: + origin = self._cells.key(widget) + if origin is not None: + self._gesture = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), + ) + + return None + + reached = self._cell_at() + if reached is None or (reached == self._gesture.origin and not self._gesture.moved): + return None + + self._gesture.moved = True + return DragReach( + origin=self._gesture.origin, + reached=reached, + extends=self._gesture.extends, + ) + + def claims_click(self) -> bool: + """Whether the click reaching the grid ends a drag, which the drag then takes as its own. + + A drag that comes back to the cell it started from releases there, and the release reports + a click; that click belongs to the drag, so the range dragged out stands and the gesture + ends here. + """ + claimed = self._gesture is not None and self._gesture.moved + if claimed: + self._gesture = None + + return claimed + + def clear(self) -> None: + """Drops the gesture in hand, so the next press drags a selection out on its own.""" + self._gesture = None diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 080fa18b..1c2da3e8 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -39,7 +39,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label -from sampletones_application.ui.elements.table.drag import DragGesture +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -142,7 +142,10 @@ def __init__( self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() self._selection: FrozenSet[OrderKey] = frozenset() - self._drag: Optional[DragGesture[OrderKey]] = None + self._drag: DragSelection[OrderKey] = DragSelection( + cells=self._order, + cell_at=self._cell_at, + ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None @@ -483,7 +486,7 @@ def _rebuild_table( self._highlighted = None self._highlighted_column = None self._selection = frozenset() - self._drag = None + self._drag.clear() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -873,8 +876,7 @@ def _on_cell_clicked( """ dpg.set_value(sender, False) self._selection -= {user_data} - if self._drag is not None and self._drag.moved: - self._drag = None + if self._drag.claims_click(): self._repaint_selection() return @@ -890,32 +892,18 @@ def _on_cell_clicked( def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. - DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag - has reached is read off the table's own geometry while the held cell names where the press - landed. A press that stays on its own cell is still a click, and the click itself is what - places the cursor there. + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. """ - if self._drag is None: - origin = self._order.key(app_data) - if origin is None: - return - - self._drag = DragGesture( - origin=origin, - extends=Modifier.SHIFT in capture_modifiers(), - ) - return - - reached = self._cell_at() - if reached is None or (reached == self._drag.origin and not self._drag.moved): + reach = self._drag.hold(app_data) + if reach is None: return - self._drag.moved = True state = self._committed_state() - if not self._drag.extends: - state = OrderInputState(cursor=OrderCursor(*self._drag.origin)) + if not reach.extends: + state = OrderInputState(cursor=OrderCursor(*reach.origin)) - self._apply_state(state.extend_to(OrderCursor(*reached))) + self._apply_state(state.extend_to(OrderCursor(*reach.reached))) def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: """Drops the gesture a finished drag left behind, so this press selects on its own. @@ -924,7 +912,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag = None + self._drag.clear() def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 249f0e21..bfa47dde 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -32,7 +32,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragGesture +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -171,7 +171,10 @@ def __init__( self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() self._selection: FrozenSet[CellKey] = frozenset() - self._drag: Optional[DragGesture[CellKey]] = None + self._drag: DragSelection[CellKey] = DragSelection( + cells=self._editable_cells, + cell_at=self._cell_at, + ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 @@ -488,7 +491,7 @@ def _rebuild_table( dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() self._selection = frozenset() - self._drag = None + self._drag.clear() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -1093,8 +1096,7 @@ def _on_cell_clicked( """ dpg.set_value(sender, False) self._selection -= {user_data} - if self._drag is not None and self._drag.moved: - self._drag = None + if self._drag.claims_click(): self._repaint_selection() return @@ -1110,32 +1112,18 @@ def _on_cell_clicked( def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. - DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag - has reached is read off the grid's own geometry while the held cell names where the press - landed. A press that stays on its own cell is still a click, and the click itself is what - places the cursor there. + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. """ - if self._drag is None: - origin = self._editable_cells.key(app_data) - if origin is None: - return - - self._drag = DragGesture( - origin=origin, - extends=Modifier.SHIFT in capture_modifiers(), - ) - return - - reached = self._cell_at() - if reached is None or (reached == self._drag.origin and not self._drag.moved): + reach = self._drag.hold(app_data) + if reach is None: return - self._drag.moved = True state = self._committed_state() - if not self._drag.extends: - state = TrackerInputState(cursor=TrackerCursor(*self._drag.origin)) + if not reach.extends: + state = TrackerInputState(cursor=TrackerCursor(*reach.origin)) - self._apply_state(state.extend_to(TrackerCursor(*reached))) + self._apply_state(state.extend_to(TrackerCursor(*reach.reached))) def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: """Drops the gesture a finished drag left behind, so this press selects on its own. @@ -1144,7 +1132,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag = None + self._drag.clear() def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/tests/unit/sampletones_application/ui/elements/table/test_cells.py b/tests/unit/sampletones_application/ui/elements/table/test_cells.py index 2a61713c..3553b52d 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_cells.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_cells.py @@ -47,6 +47,27 @@ def test_reconcile_updates_only_changed_registered_cells(self) -> None: configure.assert_called_once_with(20, label="label-b") assert cells.values["b"] == "z" + def test_a_registered_widget_reads_back_as_its_key(self) -> None: + """A cell cache answers from both sides, because a handler reports the widget it fired for.""" + cells: EditableCells[str] = EditableCells() + cells.register("a", 1) + + assert cells.key(1) == "a" + assert cells.widget("a") == 1 + + def test_a_rebuild_drops_both_directions(self) -> None: + cells: EditableCells[str] = EditableCells() + cells.register("a", 1) + cells.reset({}) + + assert cells.key(1) is None + assert cells.widget("a") is None + + def test_an_unknown_widget_names_no_cell(self) -> None: + cells: EditableCells[str] = EditableCells() + + assert cells.key(1) is None + def test_reconcile_caches_value_even_without_a_widget(self) -> None: cells: EditableCells[str] = EditableCells() cells.reset({"a": "x"}) diff --git a/tests/unit/sampletones_application/ui/elements/table/test_drag.py b/tests/unit/sampletones_application/ui/elements/table/test_drag.py new file mode 100644 index 00000000..220075c6 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/table/test_drag.py @@ -0,0 +1,147 @@ +from typing import Optional, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.utils.gui.keyboard.modifiers import Modifier + +Key = Tuple[int, int] + +ORIGIN_WIDGET = 101 +OTHER_WIDGET = 202 +ORIGIN: Key = (2, 1) +REACHED: Key = (5, 3) + + +class _Pointer: + """Where the pointer stands, which a drag reads off the grid between holds.""" + + def __init__(self, cell: Optional[Key]) -> None: + self.cell = cell + + +def _hold_modifiers(monkeypatch: pytest.MonkeyPatch, shift: bool) -> None: + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: {Modifier.SHIFT} if shift else set(), + ) + + +def _drag( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[Key], + shift: bool = False, +) -> Tuple[DragSelection[Key], _Pointer]: + cells: EditableCells[Key] = EditableCells() + cells.register(ORIGIN, ORIGIN_WIDGET) + pointer = _Pointer(reached) + _hold_modifiers(monkeypatch, shift) + return ( + DragSelection(cells=cells, cell_at=lambda: pointer.cell), + pointer, + ) + + +class TestDragReach: + """A press grows into a drag only once the pointer has left the cell it landed on.""" + + def test_a_press_alone_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=ORIGIN) + + drag.hold(ORIGIN_WIDGET) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_drag_reports_the_cell_it_grew_from(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.origin == ORIGIN + assert reach.reached == REACHED + assert reach.extends is False + + def test_a_shift_press_reports_a_carried_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED, shift=True) + + drag.hold(ORIGIN_WIDGET) + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.extends is True + + def test_a_drag_returning_to_its_origin_reaches_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, pointer = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + pointer.cell = ORIGIN + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.reached == ORIGIN + + def test_a_pointer_off_the_grid_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=None) + + drag.hold(ORIGIN_WIDGET) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(OTHER_WIDGET) + + assert drag.hold(OTHER_WIDGET) is None + + +class TestDragClick: + """The click a drag ends on belongs to the drag; every other click is a gesture of its own.""" + + def test_a_click_without_a_press_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + assert drag.claims_click() is False + + def test_a_press_that_never_moved_leaves_its_click_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=ORIGIN) + + drag.hold(ORIGIN_WIDGET) + + assert drag.claims_click() is False + + def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + + assert drag.claims_click() is True + + def test_the_claimed_click_ends_the_gesture(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + drag.claims_click() + + assert drag.claims_click() is False + + def test_a_cleared_gesture_starts_afresh(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + drag.clear() + + assert drag.claims_click() is False + assert drag.hold(ORIGIN_WIDGET) is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index b776b479..d133e465 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union import pytest @@ -11,6 +11,7 @@ PALETTES_DIRECTORY, ) from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, @@ -49,9 +50,15 @@ def _hold_modifiers( module: str, shift: bool, ) -> None: + """Holds Shift down for both readers of it: the drag reads the press, the panel the click.""" + modifiers = {Modifier.SHIFT} if shift else set() monkeypatch.setattr( f"sampletones_application.ui.panels.sequencer.{module}.capture_modifiers", - lambda: {Modifier.SHIFT} if shift else set(), + lambda: modifiers, + ) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: modifiers, ) @@ -63,9 +70,12 @@ def _tracker( panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._input_state = TrackerInputState() panel._current_row_count = ROW_COUNT - panel._drag = None panel._editable_cells = EditableCells() panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + panel._drag = DragSelection( + cells=panel._editable_cells, + cell_at=lambda: panel._cell_at(), + ) states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -82,9 +92,12 @@ def _order( panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._input_state = OrderInputState() panel._position_count = POSITION_COUNT - panel._drag = None panel._order = EditableCells() panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) + panel._drag = DragSelection( + cells=panel._order, + cell_at=lambda: panel._cell_at(), + ) states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -93,28 +106,18 @@ def _order( return panel, states -class TestEditableCellKeys: - """A cell cache answers from both sides, because a handler reports the widget it fired for.""" - - def test_a_registered_widget_reads_back_as_its_key(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - - assert cells.key(ORIGIN_WIDGET) == ORIGIN_CELL - assert cells.widget(ORIGIN_CELL) == ORIGIN_WIDGET - - def test_a_rebuild_drops_both_directions(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - cells.reset({}) - - assert cells.key(ORIGIN_WIDGET) is None - assert cells.widget(ORIGIN_CELL) is None - - def test_an_unknown_widget_names_no_cell(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - - assert cells.key(ORIGIN_WIDGET) is None +def _silence_click( + monkeypatch: pytest.MonkeyPatch, + panel: Union[GUISequencerTrackerPanel, GUISequencerOrderPanel], + module: str, +) -> None: + """Lets a click run over a grid that was never drawn: the cell releases, the repaint stands in.""" + panel._selection = frozenset() + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + f"sampletones_application.ui.panels.sequencer.{module}.dpg.set_value", + lambda widget, value: None, + ) class TestTrackerDrag: @@ -125,9 +128,6 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.origin == ORIGIN_CELL - assert panel._drag.moved is False assert states == [] def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -145,8 +145,6 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.moved is True assert states[-1].region == TrackerRegion( first_row=2, last_row=5, @@ -202,21 +200,27 @@ def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.M last_slot=4, ) - def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_a_press_on_a_cell_the_cache_forgot_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + panel._on_cell_held(0, ORIGIN_WIDGET + 1) panel._on_cell_held(0, ORIGIN_WIDGET + 1) - assert panel._drag is None assert states == [] def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = _tracker(monkeypatch, reached=ORIGIN_CELL) + """The press starting a gesture ends the one before it, so its click places the cursor.""" + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + _silence_click(monkeypatch, panel, "tracker") + panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_pointer_pressed(0, 0) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) - assert panel._drag is None + assert states[-1].cursor == TrackerCursor(*ORIGIN_CELL) + assert states[-1].region is None def test_the_click_ending_a_drag_leaves_the_selection_alone( self, @@ -225,12 +229,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( """A drag returning to its own cell releases there, and that release reports a click.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - panel._selection = frozenset({ORIGIN_CELL}) - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) - monkeypatch.setattr( - "sampletones_application.ui.panels.sequencer.tracker.dpg.set_value", - lambda widget, value: None, - ) + _silence_click(monkeypatch, panel, "tracker") panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -238,7 +237,6 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) assert len(states) == applied - assert panel._drag is None class TestTrackerDragHitTest: @@ -294,8 +292,6 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.origin == ORIGIN_ENTRY assert states == [] def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -326,12 +322,18 @@ def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pyt assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6) def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = _order(monkeypatch, reached=ORIGIN_ENTRY) + """The press starting a gesture ends the one before it, so its click places the cursor.""" + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + _silence_click(monkeypatch, panel, "order") + panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_pointer_pressed(0, 0) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) - assert panel._drag is None + assert states[-1].cursor == OrderCursor(*ORIGIN_ENTRY) + assert states[-1].region is None def test_the_click_ending_a_drag_leaves_the_selection_alone( self, @@ -339,12 +341,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( ) -> None: reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - panel._selection = frozenset({ORIGIN_ENTRY}) - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) - monkeypatch.setattr( - "sampletones_application.ui.panels.sequencer.order.dpg.set_value", - lambda widget, value: None, - ) + _silence_click(monkeypatch, panel, "order") panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -352,4 +349,3 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) assert len(states) == applied - assert panel._drag is None From 5dd82847058dd8e3ac36cf342c666a951358793e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 02:09:09 +0200 Subject: [PATCH 16/28] Extracted: shared grid selection state --- .../ui/panels/sequencer/display.py | 2 +- .../ui/panels/sequencer/input/cursor.py | 12 - .../ui/panels/sequencer/input/order.py | 109 +----- .../ui/panels/sequencer/input/state.py | 329 ++---------------- .../ui/panels/sequencer/input/target.py | 2 +- .../ui/panels/sequencer/input/tracker.py | 309 ++++++++++++++++ .../ui/panels/sequencer/tracker.py | 3 +- .../panels/sequencer/input/test_grid_input.py | 137 ++++++++ .../sequencer/input/test_tracker_input.py | 3 +- .../ui/panels/sequencer/test_block_keys.py | 3 +- .../ui/panels/sequencer/test_block_menu.py | 3 +- .../ui/panels/sequencer/test_panel_escape.py | 3 +- .../panels/sequencer/test_panel_tab_gate.py | 3 +- .../panels/sequencer/test_selection_drag.py | 3 +- .../panels/sequencer/test_selection_keys.py | 3 +- .../sequencer/test_tracker_context_menu.py | 2 +- .../sequencer/test_tracker_navigation.py | 3 +- .../sequencer/test_tracker_play_shortcut.py | 3 +- .../ui/panels/sequencer/test_tracker_rows.py | 3 +- 19 files changed, 515 insertions(+), 420 deletions(-) delete mode 100644 src/sampletones_application/ui/panels/sequencer/input/cursor.py create mode 100644 src/sampletones_application/ui/panels/sequencer/input/tracker.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index a35151ca..03d10e47 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -1,7 +1,7 @@ from typing import Dict, Final, Optional, Tuple from sampletones_application.ui.elements.table.cells import pending_label -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel from sampletones_core.constants.enums import GeneratorName diff --git a/src/sampletones_application/ui/panels/sequencer/input/cursor.py b/src/sampletones_application/ui/panels/sequencer/input/cursor.py deleted file mode 100644 index 926e7786..00000000 --- a/src/sampletones_application/ui/panels/sequencer/input/cursor.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName - - -@dataclass(frozen=True) -class TrackerCursor: - row: int - generator: Optional[GeneratorName] - subcolumn: SubColumn diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 67b58a25..8f702e60 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -1,10 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Final, Optional, Tuple -from pydantic.dataclasses import dataclass - from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer.input.state import GridInputState from sampletones_application.view_model.sequencer.region import OrderRegion from sampletones_core.constants.enums import GeneratorName @@ -24,92 +24,31 @@ def _parse(pending: str) -> Optional[int]: return None -@dataclass -class OrderInputState: +@dataclass(frozen=True) +class OrderInputState(GridInputState[OrderCursor, OrderRegion]): """Edit cursor, pending hex entry and selection anchor for the order table. The order has no subcolumns, so a cell holds a single pattern index; typing accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index. - Navigation moves along positions (columns) or channels/master (rows). The anchor is where a - range selection was started and the cursor is its other end, so the two together are the - block a copy or a paste acts on. + Navigation moves along positions (columns) or channels/master (rows). """ - cursor: Optional[OrderCursor] = None - pending: str = "" - anchor: Optional[OrderCursor] = None - - def reset_pending(self) -> OrderInputState: - """Drops a partial entry, leaving the cursor and any selection where they stand. - - The anchor survives because this runs before every move, the extending ones included: - each gesture then decides whether to hold the selection or collapse it. - """ - return OrderInputState(cursor=self.cursor, pending="", anchor=self.anchor) - - def collapse(self) -> OrderInputState: - """Drops the selection, leaving the cursor's own cell as the whole target.""" - return OrderInputState(cursor=self.cursor, pending=self.pending) - - @property - def region(self) -> Optional[OrderRegion]: - """The block a selection covers, once one has been started.""" - if self.cursor is None or self.anchor is None: - return None - - anchor_row = CHANNEL_AXIS.index(self.anchor.generator) - cursor_row = CHANNEL_AXIS.index(self.cursor.generator) - return OrderRegion( - first_row=min(anchor_row, cursor_row), - last_row=max(anchor_row, cursor_row), - first_position=min(self.anchor.position, self.cursor.position), - last_position=max(self.anchor.position, self.cursor.position), - ) - - def region_at(self, cell: OrderCursor) -> OrderRegion: - """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell - alone. - - A gesture raised inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects it to reach; one raised anywhere else acts on the cell it - names, which is a block of exactly that cell. - """ - region = self.region - if region is not None and region.covers(cell.generator, cell.position): - return region - - row = CHANNEL_AXIS.index(cell.generator) + def _region_between( + self, + first: OrderCursor, + second: OrderCursor, + ) -> OrderRegion: + first_row = CHANNEL_AXIS.index(first.generator) + second_row = CHANNEL_AXIS.index(second.generator) return OrderRegion( - first_row=row, - last_row=row, - first_position=cell.position, - last_position=cell.position, + first_row=min(first_row, second_row), + last_row=max(first_row, second_row), + first_position=min(first.position, second.position), + last_position=max(first.position, second.position), ) - @property - def target_region(self) -> Optional[OrderRegion]: - """The region a block gesture acts on: the selection, or the cursor's own cell. - - A cursor with nothing selected stands on a block of one cell, so copying reaches the cell - the reader is working in and needs no selection made first. - """ - if self.cursor is None: - return None - - return self.region_at(self.cursor) - - def extend_to(self, cursor: OrderCursor) -> OrderInputState: - """Carries the moving end of the selection to ``cursor``, anchoring it where it began. - - A selection that has not been started yet takes the cell the cursor stands on as its - anchor, so the first extending gesture selects the cell it came from as well as the one - it reaches. - """ - return OrderInputState( - cursor=cursor, - pending="", - anchor=self.anchor if self.anchor is not None else self.cursor, - ) + def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool: + return region.covers(cell.generator, cell.position) def extend_position( self, @@ -175,20 +114,8 @@ def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]: return self._after_entry(), _parse(pending) - def _after_entry(self) -> OrderInputState: - """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. - - Typing writes the one cell the cursor stands on, so it takes the selection down to that - cell instead of leaving a range for the next gesture to act on. - """ - return self.collapse().reset_pending() - def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: if not self.pending or self.cursor is None: return self, None return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS)) - - def cancel(self) -> OrderInputState: - """Drops a partial entry and any selection, which is what Escape asks of the table.""" - return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index d06d7716..864edb0c 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -1,110 +1,56 @@ -from __future__ import annotations +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Generic, Optional, Self, TypeVar -from typing import Dict, Final, Optional, Tuple +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") -from pydantic.dataclasses import dataclass -from sampletones_application.constants.sequencer import CHANNEL_AXIS -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.edit import ( - ClearAction, - EditAction, -) -from sampletones_application.view_model.sequencer.region import TrackerRegion -from sampletones_application.view_model.sequencer.slot import ( - SLOT_COUNT, - SUBCOLUMNS, - TrackerSlot, - slot_from_flat, -) -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.general import MAX_VOLUME -from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS - -DIGIT_COUNT: Final[Dict[SubColumn, int]] = { - SubColumn.INSTRUMENT: 2, - SubColumn.TRANSPOSE: 2, - SubColumn.VOLUME: 1, -} - - -def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: - try: - match cursor.subcolumn: - case SubColumn.INSTRUMENT: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=int(pending, 16), - transpose=None, - volume=None, - ) - case SubColumn.VOLUME: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=None, - volume=min(int(pending, 16), MAX_VOLUME), - ) - case SubColumn.TRANSPOSE: - sign = -1 if pending.startswith(MINUS) else 1 - magnitude = pending.lstrip(PLUS_MINUS) - if not magnitude: - return None - - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=sign * int(magnitude, 16), - volume=None, - ) - except ValueError: - return None - - -@dataclass -class TrackerInputState: - """Edit cursor, pending entry and selection anchor for the tracker grid. +@dataclass(frozen=True) +class GridInputState(ABC, Generic[CursorT, RegionT]): + """Edit cursor, pending entry and selection anchor of a sequencer grid. The anchor is where a range selection was started; the cursor is its other end, so the two together are the region a block operation acts on. Every plain move builds a state without one, which is what makes a move collapse a selection to the cell it lands in. + + A grid states how a pair of its own cells bounds a block and how a block reaches a cell; the + selection rules that follow from those two are stated here and serve every grid. """ - cursor: Optional[TrackerCursor] = None + cursor: Optional[CursorT] = None pending: str = "" - anchor: Optional[TrackerCursor] = None + anchor: Optional[CursorT] = None + + @abstractmethod + def _region_between(self, first: CursorT, second: CursorT) -> RegionT: + """The block a pair of cells bounds, whichever way round the pair stands.""" - def reset_pending(self) -> TrackerInputState: + @abstractmethod + def _covers(self, region: RegionT, cell: CursorT) -> bool: + """Whether ``region`` reaches ``cell``.""" + + def reset_pending(self) -> Self: """Drops a partial entry, leaving the cursor and any selection where they stand. The anchor survives because this runs before every move, the extending ones included: each gesture then decides whether to hold the selection or collapse it. """ - return TrackerInputState(cursor=self.cursor, pending="", anchor=self.anchor) + return type(self)(cursor=self.cursor, pending="", anchor=self.anchor) - def collapse(self) -> TrackerInputState: + def collapse(self) -> Self: """Drops the selection, leaving the cursor's own cell as the whole target.""" - return TrackerInputState(cursor=self.cursor, pending=self.pending) + return type(self)(cursor=self.cursor, pending=self.pending) @property - def region(self) -> Optional[TrackerRegion]: + def region(self) -> Optional[RegionT]: """The block a selection covers, once one has been started.""" if self.cursor is None or self.anchor is None: return None - anchor_slot = TrackerSlot(self.anchor.generator, self.anchor.subcolumn).flat_index - cursor_slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - return TrackerRegion( - first_row=min(self.anchor.row, self.cursor.row), - last_row=max(self.anchor.row, self.cursor.row), - first_slot=min(anchor_slot, cursor_slot), - last_slot=max(anchor_slot, cursor_slot), - ) + return self._region_between(self.anchor, self.cursor) - def region_at(self, cell: TrackerCursor) -> TrackerRegion: + def region_at(self, cell: CursorT) -> RegionT: """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell alone. @@ -112,20 +58,14 @@ def region_at(self, cell: TrackerCursor) -> TrackerRegion: just dragged a range out expects it to reach; one raised anywhere else acts on the cell it names, which is a block of exactly that cell. """ - slot = TrackerSlot(cell.generator, cell.subcolumn) region = self.region - if region is not None and region.covers(cell.row, slot): + if region is not None and self._covers(region, cell): return region - return TrackerRegion( - first_row=cell.row, - last_row=cell.row, - first_slot=slot.flat_index, - last_slot=slot.flat_index, - ) + return self._region_between(cell, cell) @property - def target_region(self) -> Optional[TrackerRegion]: + def target_region(self) -> Optional[RegionT]: """The region a block gesture acts on: the selection, or the cursor's own cell. A cursor with nothing selected stands on a block of one cell, so copying reaches the cell @@ -136,222 +76,27 @@ def target_region(self) -> Optional[TrackerRegion]: return self.region_at(self.cursor) - def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: + def extend_to(self, cursor: CursorT) -> Self: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. A selection that has not been started yet takes the cell the cursor stands on as its anchor, so the first extending gesture selects the cell it came from as well as the one it reaches. """ - return TrackerInputState( + return type(self)( cursor=cursor, pending="", anchor=self.anchor if self.anchor is not None else self.cursor, ) - def extend_row( - self, - value: int, - row_count: int, - absolute: bool = False, - ) -> TrackerInputState: - """Carries the selection's moving end to another row of the same slot.""" - if self.cursor is None or row_count == 0: - return self - - new_row = value if absolute else self.cursor.row + value - new_row = max(0, min(new_row, row_count - 1)) - return self.extend_to( - TrackerCursor( - new_row, - self.cursor.generator, - self.cursor.subcolumn, - ) - ) - - def extend_slot(self, value: int) -> TrackerInputState: - """Carries the selection's moving end along the flat slot axis, stopping at either end. - - A selection covers a run of the grid, so the walk stops at the first and the last slot - rather than wrapping around the way plain navigation does. - """ - if self.cursor is None: - return self - - current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) - return self.extend_to(TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn)) - - def navigate_row( - self, - value: int, - row_count: int, - absolute: bool = False, - ) -> TrackerInputState: - if self.cursor is None or row_count == 0: - return self - - new_row = value if absolute else self.cursor.row + value - new_row = max(0, min(new_row, row_count - 1)) - return TrackerInputState( - cursor=TrackerCursor( - new_row, - self.cursor.generator, - self.cursor.subcolumn, - ), - pending="", - ) - - def navigate_subcolumn( - self, - value: int, - absolute: bool = False, - ) -> TrackerInputState: - """Steps the cursor along the flattened slot axis, wrapping at either end. - - Wrapping is a navigation policy the cursor owns: walking right off the last - volume slot lands on the sample column's instrument, so a held arrow key - tours the whole row. - """ - if self.cursor is None: - return self - - if absolute: - new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)] - return TrackerInputState( - cursor=TrackerCursor( - self.cursor.row, - self.cursor.generator, - new_sub, - ), - pending="", - ) - - current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - slot = slot_from_flat((current + value) % SLOT_COUNT) - return TrackerInputState( - cursor=TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn), - pending="", - ) - - def navigate_column_by(self, delta: int) -> TrackerInputState: - if self.cursor is None: - return self - - current_idx = CHANNEL_AXIS.index(self.cursor.generator) - next_idx = (current_idx + delta) % len(CHANNEL_AXIS) - return TrackerInputState( - cursor=TrackerCursor( - self.cursor.row, - CHANNEL_AXIS[next_idx], - self.cursor.subcolumn, - ), - pending="", - ) - - def type_char( - self, - char: str, - ) -> Tuple[TrackerInputState, Optional[EditAction]]: - if self.cursor is None: - return self, None - - if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: - return self._after_entry(), self._note_off_action(self.cursor) - - if self.cursor.subcolumn is SubColumn.TRANSPOSE: - return self._type_transpose_char(char) - - if char in SIGNS: - return self, None - - pending = self.pending + char - expected = DIGIT_COUNT[self.cursor.subcolumn] - if len(pending) < expected: - return TrackerInputState(cursor=self.cursor, pending=pending), None - - action = _parse(self.cursor, pending) - return self._after_entry(), action + def cancel(self) -> Self: + """Drops a partial entry and any selection, which is what Escape asks of a grid.""" + return self.collapse().reset_pending() - def _after_entry(self) -> TrackerInputState: + def _after_entry(self) -> Self: """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. Typing writes the one cell the cursor stands on, so it takes the selection down to that cell instead of leaving a range for the next gesture to act on. """ return self.collapse().reset_pending() - - def _note_off_action(self, cursor: TrackerCursor) -> EditAction: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=None, - volume=None, - note_off=True, - ) - - def _type_transpose_char( - self, - char: str, - ) -> Tuple[TrackerInputState, Optional[EditAction]]: - """Drives the signed transpose field: ``[±][H][H]``. - - The first slot is reserved for the sign. A leading sign sets it; a leading - digit implies ``+``. A sign key pressed later flips the sign in place, - keeping any digits already entered. The field commits once both magnitude - digits are in. - """ - if self.cursor is None: - return self, None - - is_sign = char in SIGNS - if not self.pending: - pending = char if is_sign else f"{PLUS}{char}" - elif is_sign: - pending = char + self.pending[1:] - return ( - TrackerInputState(cursor=self.cursor, pending=pending), - None, - ) - else: - pending = self.pending + char - - digits = len(pending) - 1 - if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]: - return TrackerInputState(cursor=self.cursor, pending=pending), None - - action = _parse(self.cursor, pending) - return self._after_entry(), action - - def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: - if not self.pending or self.cursor is None: - return self, None - - if self.cursor.subcolumn is SubColumn.TRANSPOSE: - action = _parse(self.cursor, self.pending) - return self.reset_pending(), action - - expected = DIGIT_COUNT[self.cursor.subcolumn] - padded = self.pending.zfill(expected) - action = _parse(self.cursor, padded) - return self.reset_pending(), action - - def clear(self) -> Tuple[TrackerInputState, ClearAction]: - action = ClearAction( - row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, - ) - return self.reset_pending(), action - - def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: - action = ClearAction( - row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, - subcolumn=self.cursor.subcolumn if self.cursor else None, - ) - return self.reset_pending(), action - - def cancel(self) -> TrackerInputState: - """Drops a partial entry and any selection, which is what Escape asks of the grid.""" - return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py index 089a812c..664c7c0d 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/target.py +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py new file mode 100644 index 00000000..08f5a528 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Optional, Tuple + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer.input.edit import ( + ClearAction, + EditAction, +) +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS + +DIGIT_COUNT: Final[Dict[SubColumn, int]] = { + SubColumn.INSTRUMENT: 2, + SubColumn.TRANSPOSE: 2, + SubColumn.VOLUME: 1, +} + + +@dataclass(frozen=True) +class TrackerCursor: + row: int + generator: Optional[GeneratorName] + subcolumn: SubColumn + + +def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: + try: + match cursor.subcolumn: + case SubColumn.INSTRUMENT: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=int(pending, 16), + transpose=None, + volume=None, + ) + case SubColumn.VOLUME: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=None, + volume=min(int(pending, 16), MAX_VOLUME), + ) + case SubColumn.TRANSPOSE: + sign = -1 if pending.startswith(MINUS) else 1 + magnitude = pending.lstrip(PLUS_MINUS) + if not magnitude: + return None + + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=sign * int(magnitude, 16), + volume=None, + ) + except ValueError: + return None + + +@dataclass(frozen=True) +class TrackerInputState(GridInputState[TrackerCursor, TrackerRegion]): + """Edit cursor, pending entry and selection anchor for the tracker grid. + + A cell of the grid is a row crossed with a slot — a channel and one of its subcolumns — + so a selection reaches across the sample column and the channels alike, and typing drives + the subcolumn the cursor stands on. + """ + + def _region_between( + self, + first: TrackerCursor, + second: TrackerCursor, + ) -> TrackerRegion: + first_slot = TrackerSlot(first.generator, first.subcolumn).flat_index + second_slot = TrackerSlot(second.generator, second.subcolumn).flat_index + return TrackerRegion( + first_row=min(first.row, second.row), + last_row=max(first.row, second.row), + first_slot=min(first_slot, second_slot), + last_slot=max(first_slot, second_slot), + ) + + def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool: + return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn)) + + def extend_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + """Carries the selection's moving end to another row of the same slot.""" + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return self.extend_to( + TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ) + ) + + def extend_slot(self, value: int) -> TrackerInputState: + """Carries the selection's moving end along the flat slot axis, stopping at either end. + + A selection covers a run of the grid, so the walk stops at the first and the last slot + rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = TrackerSlot( + self.cursor.generator, + self.cursor.subcolumn, + ).flat_index + slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) + return self.extend_to( + TrackerCursor( + self.cursor.row, + slot.generator, + slot.subcolumn, + ) + ) + + def navigate_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return TrackerInputState( + cursor=TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ), + pending="", + ) + + def navigate_subcolumn( + self, + value: int, + absolute: bool = False, + ) -> TrackerInputState: + """Steps the cursor along the flattened slot axis, wrapping at either end. + + Wrapping is a navigation policy the cursor owns: walking right off the last + volume slot lands on the sample column's instrument, so a held arrow key + tours the whole row. + """ + if self.cursor is None: + return self + + if absolute: + new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)] + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + self.cursor.generator, + new_sub, + ), + pending="", + ) + + current = TrackerSlot( + self.cursor.generator, + self.cursor.subcolumn, + ).flat_index + slot = slot_from_flat((current + value) % SLOT_COUNT) + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + slot.generator, + slot.subcolumn, + ), + pending="", + ) + + def navigate_column_by(self, delta: int) -> TrackerInputState: + if self.cursor is None: + return self + + current_idx = CHANNEL_AXIS.index(self.cursor.generator) + next_idx = (current_idx + delta) % len(CHANNEL_AXIS) + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + CHANNEL_AXIS[next_idx], + self.cursor.subcolumn, + ), + pending="", + ) + + def type_char( + self, + char: str, + ) -> Tuple[TrackerInputState, Optional[EditAction]]: + if self.cursor is None: + return self, None + + if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: + return self._after_entry(), self._note_off_action(self.cursor) + + if self.cursor.subcolumn is SubColumn.TRANSPOSE: + return self._type_transpose_char(char) + + if char in SIGNS: + return self, None + + pending = self.pending + char + expected = DIGIT_COUNT[self.cursor.subcolumn] + if len(pending) < expected: + return TrackerInputState(cursor=self.cursor, pending=pending), None + + action = _parse(self.cursor, pending) + return self._after_entry(), action + + def _note_off_action(self, cursor: TrackerCursor) -> EditAction: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=None, + volume=None, + note_off=True, + ) + + def _type_transpose_char( + self, + char: str, + ) -> Tuple[TrackerInputState, Optional[EditAction]]: + """Drives the signed transpose field: ``[±][H][H]``. + + The first slot is reserved for the sign. A leading sign sets it; a leading + digit implies ``+``. A sign key pressed later flips the sign in place, + keeping any digits already entered. The field commits once both magnitude + digits are in. + """ + if self.cursor is None: + return self, None + + is_sign = char in SIGNS + if not self.pending: + pending = char if is_sign else f"{PLUS}{char}" + elif is_sign: + pending = char + self.pending[1:] + return ( + TrackerInputState(cursor=self.cursor, pending=pending), + None, + ) + else: + pending = self.pending + char + + digits = len(pending) - 1 + if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]: + return TrackerInputState(cursor=self.cursor, pending=pending), None + + action = _parse(self.cursor, pending) + return self._after_entry(), action + + def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: + if not self.pending or self.cursor is None: + return self, None + + if self.cursor.subcolumn is SubColumn.TRANSPOSE: + action = _parse(self.cursor, self.pending) + return self.reset_pending(), action + + expected = DIGIT_COUNT[self.cursor.subcolumn] + padded = self.pending.zfill(expected) + action = _parse(self.cursor, padded) + return self.reset_pending(), action + + def clear(self) -> Tuple[TrackerInputState, ClearAction]: + action = ClearAction( + row=self.cursor.row if self.cursor else 0, + generator=self.cursor.generator if self.cursor else None, + ) + return self.reset_pending(), action + + def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: + action = ClearAction( + row=self.cursor.row if self.cursor else 0, + generator=self.cursor.generator if self.cursor else None, + subcolumn=self.cursor.subcolumn if self.cursor else None, + ) + return self.reset_pending(), action diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index bfa47dde..6538b578 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -50,13 +50,12 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py new file mode 100644 index 00000000..be408fee --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass + +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + + +@dataclass(frozen=True) +class _Cell: + row: int + column: int + + +@dataclass(frozen=True) +class _Block: + first_row: int + last_row: int + first_column: int + last_column: int + + +@dataclass(frozen=True) +class _GridState(GridInputState[_Cell, _Block]): + """A grid of plain rows and columns, which is the coordinate space the shared rules are read in.""" + + def _region_between(self, first: _Cell, second: _Cell) -> _Block: + return _Block( + first_row=min(first.row, second.row), + last_row=max(first.row, second.row), + first_column=min(first.column, second.column), + last_column=max(first.column, second.column), + ) + + def _covers(self, region: _Block, cell: _Cell) -> bool: + return ( + region.first_row <= cell.row <= region.last_row and region.first_column <= cell.column <= region.last_column + ) + + +def _state( + row: int = 2, + column: int = 1, + pending: str = "", +) -> _GridState: + return _GridState(cursor=_Cell(row, column), pending=pending) + + +class TestSelection: + """A selection stands between the anchor a gesture started on and the cursor it carried to.""" + + def test_a_cursor_alone_covers_no_region(self) -> None: + assert _state().region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(row=2, column=1).extend_to(_Cell(4, 3)) + + assert extended.anchor == _Cell(2, 1) + assert extended.region == _Block(first_row=2, last_row=4, first_column=1, last_column=3) + + def test_a_later_extend_keeps_the_anchor_it_began_on(self) -> None: + extended = _state(row=2, column=1).extend_to(_Cell(4, 3)).extend_to(_Cell(6, 5)) + + assert extended.anchor == _Cell(2, 1) + assert extended.region == _Block(first_row=2, last_row=6, first_column=1, last_column=5) + + def test_extending_backwards_names_the_same_region_as_forwards(self) -> None: + backwards = _state(row=4, column=3).extend_to(_Cell(2, 1)).region + forwards = _state(row=2, column=1).extend_to(_Cell(4, 3)).region + + assert backwards == forwards + + def test_extending_leaves_nothing_pending(self) -> None: + assert _state(pending="5").extend_to(_Cell(4, 3)).pending == "" + + def test_collapsing_drops_the_selection_and_holds_the_entry(self) -> None: + collapsed = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).collapse() + + assert collapsed.region is None + assert collapsed.pending == "5" + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + held = _state(pending="5").extend_to(_Cell(4, 3)).reset_pending() + + assert held.region is not None + assert held.pending == "" + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + assert cancelled.cursor == _Cell(2, 1) + + def test_a_committed_entry_leaves_the_cursor_alone(self) -> None: + settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry() + + assert settled.region is None + assert settled.pending == "" + + def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: + """A grid state states its own rules, so what a shared rule builds is the grid's own state.""" + assert isinstance(_state().extend_to(_Cell(4, 3)), _GridState) + assert isinstance(_state().reset_pending(), _GridState) + assert isinstance(_state().collapse(), _GridState) + + +class TestTarget: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + assert _state(row=2, column=1).target_region == _Block( + first_row=2, + last_row=2, + first_column=1, + last_column=1, + ) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state().extend_to(_Cell(4, 3)) + + assert selected.target_region == selected.region + + def test_a_grid_with_no_cursor_targets_nothing(self) -> None: + assert _GridState().target_region is None + + def test_a_cell_inside_the_selection_is_raised_on_the_whole_of_it(self) -> None: + selected = _state(row=2, column=1).extend_to(_Cell(6, 5)) + + assert selected.region_at(_Cell(4, 3)) == selected.region + + def test_a_cell_outside_the_selection_is_raised_on_itself(self) -> None: + selected = _state(row=2, column=1).extend_to(_Cell(4, 3)) + + assert selected.region_at(_Cell(8, 7)) == _Block( + first_row=8, + last_row=8, + first_column=7, + last_column=7, + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 48289f08..dd365c74 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -1,7 +1,6 @@ from typing import Optional -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 3229aae6..6e21cb2d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,12 +5,11 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 7776dc1d..522f6ab6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,12 +8,11 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index 20e499de..0557c7b7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py @@ -3,12 +3,11 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py index 1dcd8ac5..08b1610a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -3,12 +3,11 @@ import pytest -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index d133e465..700e17a4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -12,12 +12,11 @@ ) from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.drag import DragSelection -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import Modifier diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index 22a7af00..a5308d61 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -2,12 +2,11 @@ import pytest -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 3e413f27..e08c2c58 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index 31af3d6f..2bfec650 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -5,8 +5,7 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py index e5d43ea8..9a6c38c9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py @@ -2,8 +2,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index fa7f4517..6ae66a05 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -9,8 +9,7 @@ tracker_table_column, tracker_table_row, ) -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.sequencer.settings import ( From 3f1bfcff299794a12a790cc9ea5d861d083796e2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 10:11:00 +0200 Subject: [PATCH 17/28] Extracted: shared grid block gestures --- docs/development/sequencer-blocks.md | 6 +- .../logic/sequencer/order/block.py | 2 - .../logic/sequencer/order/reader.py | 6 +- .../logic/sequencer/tracker/block.py | 15 -- .../logic/sequencer/tracker/reader.py | 3 - .../logic/sequencer/tracker/writer.py | 14 +- .../ui/panels/sequencer/grid/__init__.py | 0 .../ui/panels/sequencer/grid/gestures.py | 103 +++++++++++++ .../ui/panels/sequencer/input/state.py | 16 +- .../ui/panels/sequencer/input/target.py | 7 +- .../ui/panels/sequencer/order.py | 84 +++++------ .../ui/panels/sequencer/tracker.py | 84 +++++------ tests/suite/sequencer.py | 9 +- .../logic/sequencer/order/test_reader.py | 30 +--- .../logic/sequencer/tracker/test_reader.py | 14 +- .../ui/panels/sequencer/grid/__init__.py | 0 .../ui/panels/sequencer/grid/test_gestures.py | 138 ++++++++++++++++++ .../panels/sequencer/input/test_grid_input.py | 12 +- .../sequencer/input/test_order_input.py | 15 +- .../sequencer/input/test_tracker_input.py | 15 +- .../ui/panels/sequencer/test_block_keys.py | 5 + .../ui/panels/sequencer/test_block_menu.py | 61 ++++---- 22 files changed, 397 insertions(+), 242 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/gestures.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index d88b3529..b3279431 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -90,11 +90,11 @@ emptied trailing frames stand as silent ones. ## What a gesture acts on - **From the keyboard**: the selection, or — with none up — the cursor's own cell. - `target_region` on each input state is where that fallback lives, so copying one cell + `region_at` on the shared input state is where that fallback lives, so copying one cell needs no selection made first. - **From a context menu**: the selection when the menu was raised inside it, and the - clicked cell otherwise (`_menu_region` on each panel, over `Region.covers`). A paste - from a menu anchors at the clicked cell; a paste from the keyboard anchors at the cursor. + clicked cell otherwise (the same `region_at`, over `Region.covers`). A paste from a menu + anchors at the clicked cell; a paste from the keyboard anchors at the cursor. - **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share a combination inside a shortcut category, so this branch is the route; it also matches tracker convention. diff --git a/src/sampletones_application/logic/sequencer/order/block.py b/src/sampletones_application/logic/sequencer/order/block.py index f217848f..3ba479ea 100644 --- a/src/sampletones_application/logic/sequencer/order/block.py +++ b/src/sampletones_application/logic/sequencer/order/block.py @@ -20,6 +20,4 @@ class OrderBlock: reaches nothing, so the order ends where the last written column does. """ - row_count: int - position_count: int entries: Dict[BlockKey, Optional[int]] diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py index a81f5d84..c1481970 100644 --- a/src/sampletones_application/logic/sequencer/order/reader.py +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -32,11 +32,7 @@ def read(self, region: OrderRegion) -> OrderBlock: if agreement.is_unanimous: entries[(row_offset, position_offset)] = agreement.value - return OrderBlock( - row_count=region.row_count, - position_count=region.position_count, - entries=entries, - ) + return OrderBlock(entries=entries) def _agree( self, diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py index 899b2837..a5501932 100644 --- a/src/sampletones_application/logic/sequencer/tracker/block.py +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -30,21 +30,6 @@ class TrackerBlock: channel to whichever column it is written into. """ - row_count: int - first_slot: int - last_slot: int notes: Dict[BlockKey, Optional[BlockNote]] transposes: Dict[BlockKey, Optional[int]] volumes: Dict[BlockKey, Optional[int]] - - @property - def slot_count(self) -> int: - return self.last_slot - self.first_slot + 1 - - @property - def slots(self) -> range: - return range(self.first_slot, self.last_slot + 1) - - @property - def rows(self) -> range: - return range(self.row_count) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index 8bac8910..c0ef980d 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -33,9 +33,6 @@ def read(self, region: TrackerRegion) -> TrackerBlock: """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" base = column_slot_base(slot_from_flat(region.first_slot).generator) return TrackerBlock( - row_count=region.row_count, - first_slot=region.first_slot - base, - last_slot=region.last_slot - base, notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index 5849cc7a..22a98438 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -1,13 +1,6 @@ +from collections.abc import Hashable from typing import Callable, Dict, Optional, TypeVar -from sampletones_application.logic.sequencer.tracker.block import ( - BlockKey, - BlockNote, - TrackerBlock, -) -from sampletones_application.logic.sequencer.tracker.tracker import ( - SequencerTrackerLogic, -) from sampletones_application.view_model.sequencer.region import ( TrackerCell, TrackerRegion, @@ -21,7 +14,10 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.project.instruments.note_off import NoteOff -ValueT = TypeVar("ValueT") +from .block import BlockKey, BlockNote, TrackerBlock +from .tracker import SequencerTrackerLogic + +ValueT = TypeVar("ValueT", bound=Hashable) class TrackerBlockWriter: diff --git a/src/sampletones_application/ui/panels/sequencer/grid/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py new file mode 100644 index 00000000..3b1edf44 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -0,0 +1,103 @@ +from typing import Callable, Generic, Optional, Protocol, TypeVar + +from sampletones_shared.utils.callbacks import CallbackMixin + +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +RegionT_co = TypeVar("RegionT_co", covariant=True) +CellT_co = TypeVar("CellT_co", covariant=True) + + +class BlockTarget(Protocol[RegionT_co, CellT_co]): + """What a block gesture acts on: the block it covers, and the cell a pasted block lands at. + + A grid resolves one from whichever cell raised the gesture, so the pair travels together and + each gesture reads the half it acts on. + """ + + @property + def region(self) -> RegionT_co: ... + + @property + def anchor(self) -> CellT_co: ... + + +class BlockGrid(Protocol[RegionT, CellT]): + """What a grid states to the block gestures raised over it. + + The hooks are the grid's own, so the coordinator keeps wiring them where it already does; the + two methods are what a key press needs, since it names its target through the cursor. + """ + + on_copy_block: Optional[Callable[[RegionT], None]] + on_cut_block: Optional[Callable[[RegionT], None]] + on_delete_block: Optional[Callable[[RegionT], None]] + on_paste_block: Optional[Callable[[CellT], None]] + can_paste_block: Optional[Callable[[], bool]] + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" + + def cursor_target(self) -> Optional[BlockTarget[RegionT, CellT]]: + """The target the cursor names, once the grid holds a cursor.""" + + +class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): + """The four gestures a grid's blocks answer to: copy, cut, paste and delete. + + Three doors raise the same four. A key press acts at the cursor, and takes its target once the + entry being typed has landed, so a gesture carries the value the reader has just finished. A + cell menu and the menu bar's Edit menu each name the target they were built for and act on it + where it stands. Holding the four here is what has every door fire one implementation. + + The plain gestures act at the cursor; the ``_at`` gestures act on a target already named. + """ + + def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: + self._grid = grid + + def can_paste(self) -> bool: + """Whether a block stands ready for a paste to write.""" + return self.query(self._grid.can_paste_block, default=False) + + def copy(self) -> None: + self._at_cursor(self.copy_at) + + def cut(self) -> None: + self._at_cursor(self.cut_at) + + def delete(self) -> None: + self._at_cursor(self.delete_at) + + def paste(self) -> None: + self._at_cursor(self.paste_at) + + def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Takes what a target covers, leaving the grid as it stands.""" + self.call(self._grid.on_copy_block, target.region) + + def cut_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Takes what a target covers, and empties it.""" + self.call(self._grid.on_cut_block, target.region) + + def delete_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Empties what a target covers, the block in hand standing as it is.""" + self.call(self._grid.on_delete_block, target.region) + + def paste_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Writes the block in hand from a target's own cell, which is where it lands.""" + self.call(self._grid.on_paste_block, target.anchor) + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self._grid.cursor_target() + if target is not None: + gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 864edb0c..cb5f3373 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -56,7 +56,9 @@ def region_at(self, cell: CursorT) -> RegionT: A gesture raised inside a selection acts on the whole of it, which is what a reader who has just dragged a range out expects it to reach; one raised anywhere else acts on the cell it - names, which is a block of exactly that cell. + names, which is a block of exactly that cell. A cursor with nothing selected therefore + stands on a block of one cell, so copying reaches the cell the reader is working in and + needs no selection made first. """ region = self.region if region is not None and self._covers(region, cell): @@ -64,18 +66,6 @@ def region_at(self, cell: CursorT) -> RegionT: return self._region_between(cell, cell) - @property - def target_region(self) -> Optional[RegionT]: - """The region a block gesture acts on: the selection, or the cursor's own cell. - - A cursor with nothing selected stands on a block of one cell, so copying reaches the cell - the reader is working in and needs no selection made first. - """ - if self.cursor is None: - return None - - return self.region_at(self.cursor) - def extend_to(self, cursor: CursorT) -> Self: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py index 664c7c0d..20570b4c 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/target.py +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -11,12 +11,13 @@ @dataclass(frozen=True) -class TrackerMenuTarget: +class TrackerTarget: """The tracker cell a set of actions was raised on, and the block those actions act on. Both are needed at once: the block decides what the clipboard actions cover, while the cell decides where a pasted block lands and which row and channel the cell-level actions reach. - A target keeps the pair together, so a builder handed one prints a whole action set. + A target keeps the pair together, so a builder handed one prints a whole action set and a key + press handed one reaches the same block the menus would. """ cell: TrackerCursor @@ -36,7 +37,7 @@ def anchor(self) -> TrackerCell: @dataclass(frozen=True) -class OrderMenuTarget: +class OrderTarget: """The order cell a set of actions was raised on, and the block those actions act on. Both are needed at once: the block decides what the clipboard actions cover, while the cell diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 1c2da3e8..23d54995 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -46,12 +46,13 @@ channel_tooltip, ) from sampletones_application.ui.panels.sequencer.columns import channel_color +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.target import OrderMenuTarget +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, create_selectable_text_theme, @@ -183,6 +184,7 @@ def __init__( self.on_channels_muted: Optional[VoidCallback] = None self.on_channels_unmuted: Optional[VoidCallback] = None + self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) self._load_context_labels(language_manager) @@ -1032,7 +1034,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: - target = self._menu_target(OrderCursor(generator, position)) + target = self._target_at(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1045,13 +1047,21 @@ def _show_context_menu( dpg.add_separator() self._add_action_items(target) - def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: - """The cell a set of actions is built for, paired with the block those actions act on.""" - return OrderMenuTarget( + def _target_at(self, cell: OrderCursor) -> OrderTarget: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return OrderTarget( cell=cell, region=self._input_state.region_at(cell), ) + def cursor_target(self) -> Optional[OrderTarget]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._input_state.cursor + if cursor is None: + return None + + return self._target_at(cursor) + def owns_edit_actions(self) -> bool: """Whether the Edit menu states this table's actions, which it does while it owns keys. @@ -1065,13 +1075,11 @@ def build_edit_actions(self) -> None: The menu bar asks while the table owns the editing gestures, so the cursor names the target the same way a pointer names it on the cell menu. """ - cursor = self._input_state.cursor - if cursor is None: - return - - self._add_action_items(self._menu_target(cursor)) + target = self.cursor_target() + if target is not None: + self._add_action_items(target) - def _add_action_items(self, target: OrderMenuTarget) -> None: + def _add_action_items(self, target: OrderTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. The table states its actions once, and whoever asks for them decides where they are shown: @@ -1084,7 +1092,7 @@ def _add_action_items(self, target: OrderMenuTarget) -> None: dpg.add_separator() self._add_move_items(target.cell.position) - def _add_block_items(self, target: OrderMenuTarget) -> None: + def _add_block_items(self, target: OrderTarget) -> None: """Builds the clipboard items, acting on the block the actions were raised on. Paste is offered once a block has been copied, and it anchors at the target's own cell, so @@ -1095,22 +1103,22 @@ def _add_block_items(self, target: OrderMenuTarget) -> None: dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, target.region), + callback=lambda: self._blocks.copy_at(target), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, target.region), + callback=lambda: self._blocks.cut_at(target), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), - enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, target.anchor), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, target.region), + callback=lambda: self._blocks.delete_at(target), ) def _add_frame_items(self, position: int) -> None: @@ -1276,44 +1284,18 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.ORDER_COPY_BLOCK: - self._region_gesture(self.on_copy_block) + self._blocks.copy() case ShortcutId.ORDER_CUT_BLOCK: - self._region_gesture(self.on_cut_block) + self._blocks.cut() case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: - self._region_gesture(self.on_delete_block) + self._blocks.delete() case ShortcutId.ORDER_PASTE_BLOCK: - self._paste_block() + self._blocks.paste() case _: return False return True - def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: - """Hands the selected block out to a gesture, the cell under the cursor standing for itself. - - A partial entry is committed first, so the block carries the index the reader has just - finished typing. - """ - state = self._committed_state() - self._apply_state(state) - region = state.target_region - if region is not None: - self.call(callback, region) - - def _paste_block(self) -> None: - """Names the cell a block is written from, which is wherever the cursor stands.""" - state = self._committed_state() - self._apply_state(state) - cursor = state.cursor - if cursor is not None: - self.call( - self.on_paste_block, - OrderCell( - generator=cursor.generator, - position=cursor.position, - ), - ) - def _edit_cell(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. @@ -1417,6 +1399,14 @@ def _committed_state(self) -> OrderInputState: return state + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the index the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + def _type_character(self, event: KeyEvent) -> bool: """Types a hex digit into the cell under the cursor, reporting whether the press was one. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 6538b578..9ab41c57 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -50,11 +50,12 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) -from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( @@ -203,6 +204,7 @@ def __init__( self.on_channels_muted: Optional[VoidCallback] = None self.on_channels_unmuted: Optional[VoidCallback] = None + self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) self._lbl_tracker = self._label( @@ -1247,7 +1249,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: - target = self._menu_target(TrackerCursor(row_index, generator, subcolumn)) + target = self._target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1267,13 +1269,21 @@ def _show_context_menu( dpg.add_separator() self._add_action_items(target) - def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: - """The cell a set of actions is built for, paired with the block those actions act on.""" - return TrackerMenuTarget( + def _target_at(self, cell: TrackerCursor) -> TrackerTarget: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return TrackerTarget( cell=cell, region=self._input_state.region_at(cell), ) + def cursor_target(self) -> Optional[TrackerTarget]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._input_state.cursor + if cursor is None: + return None + + return self._target_at(cursor) + def owns_edit_actions(self) -> bool: """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. @@ -1287,13 +1297,11 @@ def build_edit_actions(self) -> None: The menu bar asks while the grid owns the editing gestures, so the cursor names the target the same way a pointer names it on the cell menu. """ - cursor = self._input_state.cursor - if cursor is None: - return - - self._add_action_items(self._menu_target(cursor)) + target = self.cursor_target() + if target is not None: + self._add_action_items(target) - def _add_action_items(self, target: TrackerMenuTarget) -> None: + def _add_action_items(self, target: TrackerTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. The grid states its actions once, and whoever asks for them decides where they are shown: @@ -1314,7 +1322,7 @@ def _add_action_items(self, target: TrackerMenuTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) - def _add_block_items(self, target: TrackerMenuTarget) -> None: + def _add_block_items(self, target: TrackerTarget) -> None: """Builds the clipboard items, acting on the block the actions were raised on. Paste is offered once a block has been copied, and it anchors at the target's own cell, so @@ -1325,22 +1333,22 @@ def _add_block_items(self, target: TrackerMenuTarget) -> None: dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, target.region), + callback=lambda: self._blocks.copy_at(target), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, target.region), + callback=lambda: self._blocks.cut_at(target), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), - enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, target.anchor), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, target.region), + callback=lambda: self._blocks.delete_at(target), ) def _add_instrument_submenu(self, cell: TrackerCursor) -> None: @@ -1544,44 +1552,18 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._region_gesture(self.on_copy_block) + self._blocks.copy() case ShortcutId.TRACKER_CUT_BLOCK: - self._region_gesture(self.on_cut_block) + self._blocks.cut() case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: - self._region_gesture(self.on_delete_block) + self._blocks.delete() case ShortcutId.TRACKER_PASTE_BLOCK: - self._paste_block() + self._blocks.paste() case _: return False return True - def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: - """Hands the selected block out to a gesture, the cell under the cursor standing for itself. - - A partial entry is committed first, so the block carries the value the reader has just - finished typing. - """ - state = self._committed_state() - self._apply_state(state) - region = state.target_region - if region is not None: - self.call(callback, region) - - def _paste_block(self) -> None: - """Names the cell a block is written from, which is wherever the cursor stands.""" - state = self._committed_state() - self._apply_state(state) - cursor = state.cursor - if cursor is not None: - self.call( - self.on_paste_block, - TrackerCell( - row=cursor.row, - generator=cursor.generator, - ), - ) - def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. @@ -1742,6 +1724,14 @@ def _committed_state(self) -> TrackerInputState: return state + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the value the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + def _type_character(self, event: KeyEvent) -> bool: """Types a note, digit or sign into the cell under the cursor, reporting whether the press was one. diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 5ed85b86..36551400 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -121,11 +121,7 @@ def parse_order_block(rows: Sequence[str]) -> OrderBlock: if token != MIXED: entries[(row_offset, position_offset)] = parse_index(token) - return OrderBlock( - row_count=len(rows), - position_count=widths.pop(), - entries=entries, - ) + return OrderBlock(entries=entries) def fill_order( @@ -200,9 +196,6 @@ def parse_block( volumes[key] = parse_volume(token) return TrackerBlock( - row_count=len(rows), - first_slot=first_slot, - last_slot=first_slot + widths.pop() - 1, notes=notes, transposes=transposes, volumes=volumes, diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py index bc4fde3c..2c49e3cc 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py @@ -149,34 +149,8 @@ def test_a_position_its_channels_disagree_over_is_left_out( assert block.entries == {(0, 0): 0} -class TestExtent: - """A block states the rectangle it was read at, which a mixed edge column cannot take away.""" - - def test_a_region_carries_the_shape_it_covers( - self, - logic: SequencerOrderLogic, - reader: OrderBlockReader, - ) -> None: - fill_order( - logic, - ( - "00 01 02", - "00 01 02", - "00 01 02", - "00 01 02", - ), - ) - - block = reader.read( - OrderRegion( - first_row=MASTER_ROW, - last_row=NOISE_ROW, - first_position=1, - last_position=2, - ) - ) - - assert (block.row_count, block.position_count) == (5, 2) +class TestOffsets: + """A block addresses its entries by the offsets they stand at, counted from where it begins.""" def test_offsets_run_from_the_cell_the_region_begins_at( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py index bcf080ba..f1518560 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -142,7 +142,6 @@ def test_rows_past_the_pattern_read_empty( block = reader.read(_column(GeneratorName.PULSE1, last_row=3)) - assert block.row_count == 4 assert block.volumes[_key(SubColumn.VOLUME)] == 4 assert block.volumes[_key(SubColumn.VOLUME, 2)] is None assert block.volumes[_key(SubColumn.VOLUME, 3)] is None @@ -232,15 +231,15 @@ def test_an_untouched_row_carries_its_emptiness( assert block.volumes[_key(SubColumn.VOLUME)] is None -class TestExtent: - """A block states the rectangle it was read from, whatever the cells in it turned out to hold.""" +class TestOffsets: + """A block addresses its values by the offsets it was read at, whatever the cells hold.""" - def test_a_mixed_edge_column_keeps_its_place_in_the_block( + def test_a_mixed_edge_column_leaves_only_itself_out( self, logic: SequencerTrackerLogic, reader: TrackerBlockReader, ) -> None: - """The last slot reads as nothing, and the extent is what still states the block reaches it.""" + """The last slot reads as nothing, and the cells beside it keep the offsets they stand at.""" logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2) block = reader.read( @@ -252,8 +251,8 @@ def test_a_mixed_edge_column_keeps_its_place_in_the_block( ) ) - assert (block.first_slot, block.last_slot) == (0, 2) - assert block.slot_count == 3 + assert set(block.notes) == {_key(SubColumn.INSTRUMENT)} + assert set(block.transposes) == {_key(SubColumn.TRANSPOSE)} assert _key(SubColumn.VOLUME) not in block.volumes def test_the_offsets_are_measured_from_the_column_the_block_begins_in( @@ -274,7 +273,6 @@ def test_the_offsets_are_measured_from_the_column_the_block_begins_in( ) ) - assert (block.first_slot, block.last_slot) == (1, 3) assert set(block.transposes) == {(0, 1)} assert set(block.volumes) == {(0, 2)} assert set(block.notes) == {(0, 3)} diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py new file mode 100644 index 00000000..e41bad8f --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass +from typing import Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures + +Gestures = BlockGestures[str, str] + + +@dataclass(frozen=True) +class _Target: + """A block and the cell a paste lands at, written as the words each hook reports.""" + + region: str + anchor: str + + +CURSOR_TARGET: Final[_Target] = _Target(region="cursor block", anchor="cursor cell") +NAMED_TARGET: Final[_Target] = _Target(region="named block", anchor="named cell") + + +class _Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles and the hooks it announces through land in one list, so a test reads + both what a gesture reached and when the grid committed what was being typed. + """ + + def __init__( + self, + *, + target: Optional[_Target] = None, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self._target = target + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def commit_entry(self) -> None: + self.events.append("commit") + + def cursor_target(self) -> Optional[_Target]: + return self._target + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures, raised at the cursor and on a target a menu named.""" + + name: str + at_cursor: Callable[[Gestures], None] + at_target: Callable[[Gestures, _Target], None] + from_cursor: str + from_target: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda gestures: gestures.copy(), + at_target=lambda gestures, target: gestures.copy_at(target), + from_cursor="copy cursor block", + from_target="copy named block", + ), + GestureCase( + name="cut", + at_cursor=lambda gestures: gestures.cut(), + at_target=lambda gestures, target: gestures.cut_at(target), + from_cursor="cut cursor block", + from_target="cut named block", + ), + GestureCase( + name="delete", + at_cursor=lambda gestures: gestures.delete(), + at_target=lambda gestures, target: gestures.delete_at(target), + from_cursor="delete cursor block", + from_target="delete named block", + ), + GestureCase( + name="paste", + at_cursor=lambda gestures: gestures.paste(), + at_target=lambda gestures, target: gestures.paste_at(target), + from_cursor="paste cursor cell", + from_target="paste named cell", + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = _Grid(target=CURSOR_TARGET) + + case.at_cursor(BlockGestures(grid=grid)) + + assert grid.events == ["commit", case.from_cursor] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = _Grid(target=None) + + case.at_cursor(BlockGestures(grid=grid)) + + assert grid.events == ["commit"] + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestOnANamedTarget: + """A menu item acts on the target it was built for, wherever the cursor happens to stand.""" + + def test_a_gesture_reaches_the_target_it_was_handed(self, case: GestureCase) -> None: + grid = _Grid(target=CURSOR_TARGET) + + case.at_target(BlockGestures(grid=grid), NAMED_TARGET) + + assert grid.events == [case.from_target] + + +class TestPasteEnablement: + """Paste is offered while a block stands ready for it to write.""" + + def test_a_grid_holding_a_block_offers_the_paste(self) -> None: + assert BlockGestures(grid=_Grid(can_paste=True)).can_paste() is True + + def test_a_grid_holding_none_offers_no_paste(self) -> None: + assert BlockGestures(grid=_Grid(can_paste=False)).can_paste() is False + + def test_a_grid_awaiting_its_wiring_offers_no_paste(self) -> None: + grid = _Grid() + grid.can_paste_block = None + + assert BlockGestures(grid=grid).can_paste() is False diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index be408fee..e508fc39 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -105,22 +105,14 @@ def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: class TestTarget: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - assert _state(row=2, column=1).target_region == _Block( + def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None: + assert _state(row=2, column=1).region_at(_Cell(2, 1)) == _Block( first_row=2, last_row=2, first_column=1, last_column=1, ) - def test_a_selection_is_targeted_whole(self) -> None: - selected = _state().extend_to(_Cell(4, 3)) - - assert selected.target_region == selected.region - - def test_a_grid_with_no_cursor_targets_nothing(self) -> None: - assert _GridState().target_region is None - def test_a_cell_inside_the_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(row=2, column=1).extend_to(_Cell(6, 5)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 1f36dc47..93a43a4b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -113,20 +113,19 @@ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: class TestTarget: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - region = _state(GeneratorName.PULSE2, position=4).target_region + def test_a_cell_of_a_table_with_nothing_selected_is_raised_on_itself(self) -> None: + cell = OrderCursor(GeneratorName.PULSE2, 4) + + region = _state(GeneratorName.PULSE2, position=4).region_at(cell) - assert region is not None assert (region.first_position, region.last_position) == (4, 4) assert region.generators == (GeneratorName.PULSE2,) - def test_a_selection_is_targeted_whole(self) -> None: + def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(position=4).extend_position(2, POSITION_COUNT) + cell = OrderCursor(GeneratorName.PULSE1, 5) - assert selected.target_region == selected.region - - def test_a_table_with_no_cursor_targets_nothing(self) -> None: - assert OrderInputState().target_region is None + assert selected.region_at(cell) == selected.region class TestEntry: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index dd365c74..e2c0dbb4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -137,20 +137,19 @@ def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: class TestTargetRegion: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - region = _state(SubColumn.TRANSPOSE, row=4).target_region + def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None: + cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + + region = _state(SubColumn.TRANSPOSE, row=4).region_at(cell) - assert region is not None assert (region.first_row, region.last_row) == (4, 4) assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) - def test_a_selection_is_targeted_whole(self) -> None: + def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + cell = TrackerCursor(5, GeneratorName.PULSE1, SubColumn.INSTRUMENT) - assert selected.target_region == selected.region - - def test_a_grid_with_no_cursor_targets_nothing(self) -> None: - assert TrackerInputState().target_region is None + assert selected.region_at(cell) == selected.region class TestColumnNavigation: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 6e21cb2d..2186b19a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,6 +5,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -83,6 +84,8 @@ def _panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) + panel.can_paste_block = lambda: True + panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel @@ -103,6 +106,8 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) + panel.can_paste_block = lambda: True + panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 522f6ab6..ceabdd53 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,6 +8,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -139,6 +140,7 @@ def _tracker_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel._blocks = BlockGestures(grid=panel) return panel @@ -158,6 +160,7 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel._blocks = BlockGestures(grid=panel) return panel @@ -210,12 +213,12 @@ def _selected_order_state() -> OrderInputState: return state.extend_position(2, POSITION_COUNT) -class TestTrackerMenuTarget: +class TestTrackerTarget: def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._menu_target( + target = panel._target_at( TrackerCursor( CLICKED_ROW + 1, GeneratorName.PULSE1, @@ -229,7 +232,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._menu_target( + target = panel._target_at( TrackerCursor( CLICKED_ROW, GeneratorName.TRIANGLE, @@ -247,7 +250,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - target = panel._menu_target(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) + target = panel._target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) @@ -256,19 +259,23 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: """The menu bar asks for the cursor's own target, which is the standing selection.""" panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - cursor = TrackerCursor(CLICKED_ROW + 2, GeneratorName.PULSE1, SubColumn.VOLUME) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == panel._input_state.region + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert _tracker_panel(Gestures()).cursor_target() is None + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _tracker_panel(Gestures()) cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) panel._input_state = TrackerInputState(cursor=cursor) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, @@ -288,7 +295,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -302,7 +309,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord panel = _tracker_panel(gestures) panel._add_block_items( - panel._menu_target( + panel._target_at( TrackerCursor( CLICKED_ROW, GeneratorName.NOISE, @@ -317,7 +324,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -328,17 +335,17 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] -class TestOrderMenuTarget: +class TestOrderTarget: def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._menu_target(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) + target = panel._target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) assert target.region == panel._input_state.region @@ -346,7 +353,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._menu_target(_order_cell(None)) + target = panel._target_at(_order_cell(None)) assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), @@ -358,7 +365,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - target = panel._menu_target(_order_cell(GeneratorName.PULSE1)) + target = panel._target_at(_order_cell(GeneratorName.PULSE1)) assert target.region == OrderRegion( first_row=PULSE1_ROW, @@ -371,19 +378,23 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: """The menu bar asks for the cursor's own target, which is the standing selection.""" panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 2) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == panel._input_state.region + def test_a_table_holding_no_cursor_names_no_target(self) -> None: + assert _order_panel(Gestures()).cursor_target() is None + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _order_panel(Gestures()) cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) panel._input_state = OrderInputState(cursor=cursor) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == OrderRegion( first_row=PULSE1_ROW, last_row=PULSE1_ROW, @@ -403,7 +414,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -415,7 +426,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(panel._menu_target(_order_cell(None))) + panel._add_block_items(panel._target_at(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -423,7 +434,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -434,7 +445,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -448,7 +459,7 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_action_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_action_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -460,7 +471,7 @@ def test_the_order_action_set_opens_with_the_clipboard_items( ) -> None: panel = _order_panel(Gestures()) - panel._add_action_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_action_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -473,7 +484,7 @@ class TestMenuItemOrder: def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" From 3fe19bfe03577036177a80d9c419429b6564ed06 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 10:39:39 +0200 Subject: [PATCH 18/28] Extracted: shared grid selection painting --- .../ui/elements/table/selection.py | 74 +++++++ .../ui/panels/sequencer/order.py | 44 ++--- .../ui/panels/sequencer/tracker.py | 46 ++--- .../view_model/sequencer/region.py | 12 -- .../ui/elements/table/test_selection.py | 184 ++++++++++++++++++ .../sequencer/input/test_order_input.py | 1 - .../sequencer/input/test_tracker_input.py | 1 - .../panels/sequencer/test_selection_drag.py | 30 ++- .../view_model/sequencer/test_region.py | 10 +- 9 files changed, 297 insertions(+), 105 deletions(-) create mode 100644 src/sampletones_application/ui/elements/table/selection.py create mode 100644 tests/unit/sampletones_application/ui/elements/table/test_selection.py diff --git a/src/sampletones_application/ui/elements/table/selection.py b/src/sampletones_application/ui/elements/table/selection.py new file mode 100644 index 00000000..14729cab --- /dev/null +++ b/src/sampletones_application/ui/elements/table/selection.py @@ -0,0 +1,74 @@ +from collections.abc import Hashable +from typing import Callable, FrozenSet, Generic, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragReach, DragSelection +from sampletones_shared.types.application import Sender + +KeyT = TypeVar("KeyT", bound=Hashable) + + +class TableSelection(Generic[KeyT]): + """The selection a table shows, and the pointer gesture that draws it. + + A grid states which of its cells the selection covers, in whatever coordinates it selects in; + which of them stand painted, and how far a held pointer has carried, are held here. A selected + cell is drawn by the selectable's own selected state, which the table's theme colours, so a + repaint reaches only the cells whose membership changed. + """ + + def __init__( + self, + *, + cells: EditableCells[KeyT], + cell_at: Callable[[], Optional[KeyT]], + covered: Callable[[], FrozenSet[KeyT]], + ) -> None: + self._cells = cells + self._covered = covered + self._drag: DragSelection[KeyT] = DragSelection(cells=cells, cell_at=cell_at) + self._painted: FrozenSet[KeyT] = frozenset() + + def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]: + """How far a held pointer has carried, which is what a drag grows the selection out to.""" + return self._drag.hold(widget) + + def claims_click(self, sender: Sender, key: KeyT) -> bool: + """Whether the click on a cell ends a drag, which the drag then takes as its own. + + DearPyGui toggles a selectable as it reports the click, so the cell is released here and + dropped from what stands painted: the repaint that follows is what states whether the cell + belongs to the selection. + """ + dpg.set_value(sender, False) + self._painted -= {key} + if not self._drag.claims_click(): + return False + + self.repaint() + return True + + def drop_gesture(self) -> None: + """Drops the gesture in hand, the selection standing as it is.""" + self._drag.clear() + + def repaint(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left.""" + covered = self._covered() + for key in self._painted ^ covered: + widget = self._cells.widget(key) + if widget is not None: + dpg.set_value(widget, key in covered) + + self._painted = covered + + def reset(self) -> None: + """Forgets the selection and the gesture, which is what a rebuilt table asks for. + + The cells a selection stood on belong to the body being replaced, so the paint is forgotten + with them and the grid states its selection onto the new cells afresh. + """ + self._drag.clear() + self._painted = frozenset() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 23d54995..f651783f 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -39,7 +39,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -142,10 +142,10 @@ def __init__( self._position_count: int = 0 self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() - self._selection: FrozenSet[OrderKey] = frozenset() - self._drag: DragSelection[OrderKey] = DragSelection( + self._selection: TableSelection[OrderKey] = TableSelection( cells=self._order, cell_at=self._cell_at, + covered=self._selected_cells, ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None @@ -398,7 +398,7 @@ def deselect_cell(self) -> None: self._clear_cursor_highlight() self._clear_column_highlight() self._input_state = OrderInputState() - self._repaint_selection() + self._selection.repaint() self._update_caret() if cursor is not None: @@ -487,8 +487,7 @@ def _rebuild_table( dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) self._highlighted = None self._highlighted_column = None - self._selection = frozenset() - self._drag.clear() + self._selection.reset() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -754,20 +753,6 @@ def _selected_cells(self) -> FrozenSet[OrderKey]: return frozenset(keys) - def _repaint_selection(self) -> None: - """Marks the cells the selection now covers and releases the ones it has left. - - A selected cell is drawn by the selectable's own selected state, which the order table's - theme colours, so a repaint reaches only the cells whose membership actually changed. - """ - selected = self._selected_cells() - for key in self._selection ^ selected: - widget = self._order.widget(key) - if widget is not None: - dpg.set_value(widget, key in selected) - - self._selection = selected - def _clear_cursor_highlight(self) -> None: if self._highlighted is None: return @@ -834,7 +819,7 @@ def _apply_state( if old is None or old.position != new.position: self.call(self.on_frame_selected, new.position) - self._repaint_selection() + self._selection.repaint() self._update_caret() self._refresh_remove_enabled() @@ -869,17 +854,10 @@ def _on_cell_clicked( ) -> None: """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is - released here and its membership dropped: the repaint that follows is what states whether - the cell the user clicked belongs to the selection. - - A drag that comes back to the cell it started from ends on a click, and that click is the - end of the drag rather than a gesture of its own, so it leaves the selection standing. + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. """ - dpg.set_value(sender, False) - self._selection -= {user_data} - if self._drag.claims_click(): - self._repaint_selection() + if self._selection.claims_click(sender, user_data): return state = self._committed_state() @@ -897,7 +875,7 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. """ - reach = self._drag.hold(app_data) + reach = self._selection.hold(app_data) if reach is None: return @@ -914,7 +892,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag.clear() + self._selection.drop_gesture() def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 9ab41c57..b9f3319f 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -32,7 +32,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -170,10 +170,10 @@ def __init__( self._painted_row: Optional[int] = None self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() - self._selection: FrozenSet[CellKey] = frozenset() - self._drag: DragSelection[CellKey] = DragSelection( + self._selection: TableSelection[CellKey] = TableSelection( cells=self._editable_cells, cell_at=self._cell_at, + covered=self._selected_cells, ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} @@ -491,8 +491,7 @@ def _rebuild_table( """ dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() - self._selection = frozenset() - self._drag.clear() + self._selection.reset() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -823,7 +822,7 @@ def _update_cursor(self) -> None: else: self._input_state = TrackerInputState() - self._repaint_selection() + self._selection.repaint() self._update_caret() def deselect_cell(self) -> None: @@ -831,7 +830,7 @@ def deselect_cell(self) -> None: if cursor is not None: self._input_state = TrackerInputState() self._remove_cell_highlight(cursor.row, cursor.generator) - self._repaint_selection() + self._selection.repaint() self._update_caret() @@ -858,7 +857,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: if new_pos != old_pos and new_cursor is not None: self.call(self.on_cell_selected) - self._repaint_selection() + self._selection.repaint() self._update_caret() def update_samples(self, view_model: SequencerSamplesViewModel) -> None: @@ -1049,20 +1048,6 @@ def _selected_cells(self) -> FrozenSet[CellKey]: return frozenset(keys) - def _repaint_selection(self) -> None: - """Marks the cells the selection now covers and releases the ones it has left. - - A selected cell is drawn by the selectable's own selected state, which the pattern table's - theme colours, so a repaint reaches only the cells whose membership actually changed. - """ - selected = self._selected_cells() - for key in self._selection ^ selected: - widget = self._editable_cells.widget(key) - if widget is not None: - dpg.set_value(widget, key in selected) - - self._selection = selected - def _remove_cell_highlight( self, row_index: int, @@ -1088,17 +1073,10 @@ def _on_cell_clicked( ) -> None: """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is - released here and its membership dropped: the repaint that follows is what states whether - the cell the user clicked belongs to the selection. - - A drag that comes back to the cell it started from ends on a click, and that click is the - end of the drag rather than a gesture of its own, so it leaves the selection standing. + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. """ - dpg.set_value(sender, False) - self._selection -= {user_data} - if self._drag.claims_click(): - self._repaint_selection() + if self._selection.claims_click(sender, user_data): return state = self._committed_state() @@ -1116,7 +1094,7 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. """ - reach = self._drag.hold(app_data) + reach = self._selection.hold(app_data) if reach is None: return @@ -1133,7 +1111,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag.clear() + self._selection.drop_gesture() def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 25dbccd4..c4f8de5b 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -49,10 +49,6 @@ def _validate_rows(self) -> Self: return self - @property - def row_count(self) -> int: - return self.last_row - self.first_row + 1 - @property def rows(self) -> range: return range(self.first_row, self.last_row + 1) @@ -79,10 +75,6 @@ def _validate_slots(self) -> Self: return self - @property - def slot_count(self) -> int: - return self.last_slot - self.first_slot + 1 - @property def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" @@ -118,10 +110,6 @@ def _validate_positions(self) -> Self: return self - @property - def position_count(self) -> int: - return self.last_position - self.first_position + 1 - @property def positions(self) -> range: return range(self.first_position, self.last_position + 1) diff --git a/tests/unit/sampletones_application/ui/elements/table/test_selection.py b/tests/unit/sampletones_application/ui/elements/table/test_selection.py new file mode 100644 index 00000000..6a19fb8a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/table/test_selection.py @@ -0,0 +1,184 @@ +from typing import Dict, FrozenSet, List, Optional, Set, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.utils.gui.keyboard.modifiers import Modifier + +Key = Tuple[int, int] + +ORIGIN: Key = (2, 1) +REACHED: Key = (5, 3) +FORGOTTEN: Key = (9, 9) +WIDGETS: Dict[Key, int] = {ORIGIN: 101, REACHED: 202} + + +class _Grid: + """A grid stating what its selection covers, and recording what was painted on it.""" + + def __init__(self, covered: Set[Key]) -> None: + self.covered = covered + self.painted: List[Tuple[int, bool]] = [] + self.cell: Optional[Key] = REACHED + + def covers(self) -> FrozenSet[Key]: + return frozenset(self.covered) + + +def _selection( + monkeypatch: pytest.MonkeyPatch, + covered: Set[Key], +) -> Tuple[TableSelection[Key], _Grid]: + cells: EditableCells[Key] = EditableCells() + for key, widget in WIDGETS.items(): + cells.register(key, widget) + + grid = _Grid(covered) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.selection.dpg.set_value", + lambda widget, value: grid.painted.append((widget, value)), + ) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: set(), + ) + return ( + TableSelection(cells=cells, cell_at=lambda: grid.cell, covered=grid.covers), + grid, + ) + + +def _drag_out(selection: TableSelection[Key], widget: int) -> None: + """Carries a press out to another cell, which is what turns it into a drag.""" + selection.hold(widget) + selection.hold(widget) + + +class TestRepaint: + """A repaint reaches the cells whose membership changed, and leaves the rest standing.""" + + def test_the_cells_now_covered_are_marked(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], True)] + + def test_a_cell_the_selection_has_left_is_released(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + grid.covered = {REACHED} + selection.repaint() + + assert sorted(grid.painted) == [(WIDGETS[ORIGIN], False), (WIDGETS[REACHED], True)] + + def test_a_cell_standing_as_it_was_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.repaint() + + assert grid.painted == [] + + def test_a_cell_the_cache_forgot_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A region names cells of the grid, so a repaint reaches those the cache holds a widget for.""" + selection, grid = _selection(monkeypatch, covered={FORGOTTEN}) + + selection.repaint() + + assert grid.painted == [] + + +class TestClick: + """A click releases the cell DearPyGui toggled, and a drag takes the click that ends it.""" + + def test_a_click_releases_the_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered=set()) + + claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert claimed is False + assert grid.painted == [(WIDGETS[ORIGIN], False)] + + def test_a_clicked_cell_the_selection_covers_is_marked_again(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The click leaves the cell released, so the repaint after it states the membership again.""" + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)] + + def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + grid.painted.clear() + + claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert claimed is True + assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)] + + def test_a_second_click_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The drag ends with the click it was claimed by, so the click after it places a cursor.""" + selection, _ = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + + +class TestGestureAndReset: + """The gesture in hand and the selection painted are dropped by different callers.""" + + def test_dropping_the_gesture_leaves_the_selection_painted(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + selection.repaint() + grid.painted.clear() + + selection.drop_gesture() + selection.repaint() + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + assert grid.painted == [(WIDGETS[ORIGIN], False)] + + def test_a_reset_forgets_what_stood_painted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A rebuilt table holds cells of its own, so the selection is marked onto them afresh.""" + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.reset() + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], True)] + + def test_a_reset_drops_the_gesture_in_hand(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, _ = _selection(monkeypatch, covered=set()) + _drag_out(selection, WIDGETS[ORIGIN]) + + selection.reset() + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + + +def test_a_shift_press_carries_the_selection_out(monkeypatch: pytest.MonkeyPatch) -> None: + """The reach a hold reports is the drag's own, which the grid turns into its selection.""" + selection, grid = _selection(monkeypatch, covered=set()) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: {Modifier.SHIFT}, + ) + + selection.hold(WIDGETS[ORIGIN]) + reach = selection.hold(WIDGETS[ORIGIN]) + + assert reach is not None + assert (reach.origin, reach.reached, reach.extends) == (ORIGIN, grid.cell, True) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 93a43a4b..f4140920 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -54,7 +54,6 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: region = extended.region assert region is not None assert (region.first_position, region.last_position) == (2, 3) - assert region.position_count == 2 def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None: leftwards = _state(position=3).extend_position(-1, POSITION_COUNT).region diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index e2c0dbb4..27bec8f2 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -51,7 +51,6 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: region = extended.region assert region is not None assert (region.first_row, region.last_row) == (4, 5) - assert region.row_count == 2 def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index 700e17a4..ca00eb80 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple import pytest @@ -11,7 +11,7 @@ PALETTES_DIRECTORY, ) from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -71,9 +71,10 @@ def _tracker( panel._current_row_count = ROW_COUNT panel._editable_cells = EditableCells() panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - panel._drag = DragSelection( + panel._selection = TableSelection( cells=panel._editable_cells, cell_at=lambda: panel._cell_at(), + covered=panel._selected_cells, ) states: List[TrackerInputState] = [] @@ -93,9 +94,10 @@ def _order( panel._position_count = POSITION_COUNT panel._order = EditableCells() panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) - panel._drag = DragSelection( + panel._selection = TableSelection( cells=panel._order, cell_at=lambda: panel._cell_at(), + covered=panel._selected_cells, ) states: List[OrderInputState] = [] @@ -105,16 +107,10 @@ def _order( return panel, states -def _silence_click( - monkeypatch: pytest.MonkeyPatch, - panel: Union[GUISequencerTrackerPanel, GUISequencerOrderPanel], - module: str, -) -> None: - """Lets a click run over a grid that was never drawn: the cell releases, the repaint stands in.""" - panel._selection = frozenset() - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) +def _silence_click(monkeypatch: pytest.MonkeyPatch) -> None: + """Lets a click run over a grid that was never drawn: the cells it releases hold no widget.""" monkeypatch.setattr( - f"sampletones_application.ui.panels.sequencer.{module}.dpg.set_value", + "sampletones_application.ui.elements.table.selection.dpg.set_value", lambda widget, value: None, ) @@ -211,7 +207,7 @@ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.Monkey """The press starting a gesture ends the one before it, so its click places the cursor.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "tracker") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -228,7 +224,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( """A drag returning to its own cell releases there, and that release reports a click.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "tracker") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -324,7 +320,7 @@ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.Monkey """The press starting a gesture ends the one before it, so its click places the cursor.""" reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "order") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -340,7 +336,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( ) -> None: reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "order") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index fdf7f284..ebd29eda 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -21,8 +21,6 @@ class TestTrackerRegion: def test_a_single_cell_region_covers_that_cell(self) -> None: region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4) - assert region.row_count == 1 - assert region.slot_count == 1 assert tuple(region.rows) == (3,) assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) @@ -38,8 +36,8 @@ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None def test_a_region_spans_the_whole_axis(self) -> None: region = TrackerRegion(first_row=0, last_row=63, first_slot=0, last_slot=SLOT_COUNT - 1) - assert region.row_count == 64 - assert region.slot_count == SLOT_COUNT + assert tuple(region.rows) == tuple(range(64)) + assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT)) def test_inverted_rows_are_rejected(self) -> None: with pytest.raises(ValidationError): @@ -63,8 +61,6 @@ class TestOrderRegion: def test_a_single_cell_region_covers_that_cell(self) -> None: region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2) - assert region.row_count == 1 - assert region.position_count == 1 assert region.generators == (None,) assert tuple(region.positions) == (2,) @@ -82,7 +78,7 @@ def test_a_region_spans_the_whole_channel_axis(self) -> None: ) assert region.generators == CHANNEL_AXIS - assert region.position_count == 8 + assert tuple(region.positions) == tuple(range(8)) def test_inverted_positions_are_rejected(self) -> None: with pytest.raises(ValidationError): From 47b798a0b4fd0e592cbda533c31cf5f572e0aa64 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 11:02:29 +0200 Subject: [PATCH 19/28] Documented: the focus-aware Edit menu --- docs/development/architecture.md | 2 +- docs/development/sequencer-blocks.md | 62 +++++++++++++++++++++------- docs/guide/interface.md | 9 ++-- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 8aa730c5..68ecab45 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -292,7 +292,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m There are two coordinator kinds: -*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations) or `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`). +*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations), `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`), or `EditRouter` (the single edit surface behind the menu bar's Edit menu, which shows the actions of the grid holding the cursor — see `docs/development/sequencer-blocks.md`). *Tab coordinators* own everything for one tab: they instantiate its panels, logic objects, and tab-scoped services, wire their callbacks together, and provide `create_tab()` — the single method that builds the DPG widget tree for that tab. Tab coordinators present a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, …) and keep their panels and logic objects private. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index b3279431..2f053e55 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -4,7 +4,8 @@ A **block** is a rectangle of one sequencer grid, lifted out of the song so it c written back somewhere else. Copy, cut, paste and delete are the four gestures over it, and both grids — the tracker's pattern rows and the order's frames — carry the same set. -This document states the rules those gestures follow. The layering they sit in is +This document states the rules those gestures follow, and how a grid's actions reach the +menus and the keyboard that fire them. The layering they sit in is [Architecture](architecture.md); the conventions the code is held to are the [coding guidelines](guidelines.md). @@ -87,26 +88,55 @@ Growth runs before the first write, so one history entry covers the appended fra the values in them, and a single undo takes both back. Delete keeps the order's length: emptied trailing frames stand as silent ones. -## What a gesture acts on +## A grid declares its actions once -- **From the keyboard**: the selection, or — with none up — the cursor's own cell. - `region_at` on the shared input state is where that fallback lives, so copying one cell - needs no selection made first. -- **From a context menu**: the selection when the menu was raised inside it, and the - clicked cell otherwise (the same `region_at`, over `Region.covers`). A paste from a menu - anchors at the clicked cell; a paste from the keyboard anchors at the cursor. -- **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot - share a combination inside a shortcut category, so this branch is the route; it also - matches tracker convention. +Where they are shown is decided by whoever asks for them. Each grid builds its whole +action set from one **target** — the cell a gesture is aimed at, paired with the region +that gesture acts on — and three doors resolve that target their own way: -Copy is wired straight through rather than through `_undoable` — it mutates nothing, so a -transaction over it would record an entry with nothing to restore. Cut, delete and paste -each record exactly one entry, and none of them coalesces: a block gesture is already a -whole gesture, and folding two consecutive pastes would hide a repeat the reader performed -on purpose. +| Door | Aims at | Anchors a paste at | +|------|---------|--------------------| +| The keyboard | the cursor's cell | the cursor | +| A context menu | the cell it was raised on | the clicked cell | +| The menu bar's **Edit** menu | the cursor's cell | the cursor | + +The region behind a target is `region_at` on the shared input state: the selection when the +cell falls inside it (`Region.covers`), and the cell alone otherwise. So copying one cell +needs no selection made first, and a menu raised inside a selection acts on the whole of it. + +One builder means an action added to a grid appears at every door, and the accelerator +**Edit** prints is the one that grid answers to, since a binding is declared once and every +reader of it reads that entry ([Architecture](architecture.md), principle 12). + +`EditRouter` (`coordinators/edit/`) is the menu-side counterpart of the `KeyRouter` the +keyboard runs through. Each surface states whether it owns the editing gestures at this +moment — the same predicate its key scope answers with, so the menu offers what the next +press would reach — and the router asks the one that does to build its items into the menu +the bar has opened. It holds no state, resolving the surface on each call, so the menu +states the actions of whoever holds the cursor at the moment it is opened. The bar names the +clipboard four greyed out when no grid answers, which is how a reader working from the menus +learns the commands exist. + +**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share +a combination inside a shortcut category, so this branch is the route; it also matches +tracker convention. + +## One gesture, one history entry + +Cut, delete and paste each record exactly one entry, whichever door fired them, and none of +them coalesces: a block gesture is already a whole gesture, and folding two consecutive +pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, +since it mutates nothing. ## Dragging a range out +Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what +stands painted and the drag gesture that draws it. The grid states which of its cells the +selection covers, in its own coordinates; the repaint that follows reaches the cells whose +membership changed, marking each through the selectable's own selected state, which the +table's theme colours. A rebuilt table asks for a reset, since the cells a selection stood on +belong to the body that was replaced. + Both panels read the cell under a held pointer off their own geometry, because DearPyGui reports no hover for the cells a held pointer passes over. A drag carried past an edge reads as the edge, so it selects up to it. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 275784de..12bc7805 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -75,10 +75,11 @@ instructions data** to re-read the catalogue; selecting an entry in the The menu bar and status bar sit outside the tabs. -Each menu covers one kind of work: **File** for projects, **Edit** for undo and -redo, **Reconstruction** for the current reconstruction and its exports, -**Playback** for playing and for muting the sequencer's channels, **View** for -settings and the window, and **Help** for **About**. +Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo, +and what you can do where your cursor stands, **Reconstruction** for the current +reconstruction and its exports, **Playback** for playing and for muting the +sequencer's channels, **View** for settings and the window, and **Help** for +**About**. Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for From 1dd316741d0aabc1520387b6f33cc543ea168062 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 11:38:57 +0200 Subject: [PATCH 20/28] Added: transpose and volume over a selection --- docs/development/sequencer-blocks.md | 18 + docs/guide/sequencer.md | 16 + .../categories/elements/settings.py | 8 + .../coordinators/tabs/sequencer.py | 23 +- .../logic/sequencer/history_detail.py | 48 ++- .../logic/sequencer/tracker/__init__.py | 2 + .../logic/sequencer/tracker/adjuster.py | 57 ++++ .../logic/sequencer/tracker/tracker.py | 32 -- .../ui/panels/sequencer/tracker.py | 169 +++++++--- .../utils/gui/shortcuts/ids.py | 8 + .../view_model/sequencer/region.py | 10 + .../keybindings/default.yaml | 8 + src/sampletones_config/keybindings/macos.yaml | 8 + src/sampletones_config/lang/en.yaml | 8 + .../logic/sequencer/test_history_detail.py | 32 +- .../logic/sequencer/tracker/test_adjuster.py | 312 ++++++++++++++++++ .../logic/sequencer/tracker/test_tracker.py | 61 ---- .../ui/panels/sequencer/test_block_keys.py | 58 +++- .../ui/panels/sequencer/test_block_menu.py | 16 +- .../sequencer/test_tracker_context_menu.py | 51 +-- .../utils/gui/shortcuts/test_shipped.py | 117 +++++++ 21 files changed, 870 insertions(+), 192 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/tracker/adjuster.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 2f053e55..78479db7 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -88,6 +88,19 @@ Growth runs before the first write, so one history entry covers the appended fra the values in them, and a single undo takes both back. Delete keeps the order's length: emptied trailing frames stand as silent ones. +## A shift reads the columns behind a region + +Transpose and volume move whole cells, while a region names its edges as subcolumns. A shift +therefore reads the columns a region covers (`TrackerRegion.columns`) and reaches each of their +channels once, at every row the region spans. Two consequences follow: a nudge raised with the +cursor on a volume subcolumn still moves that cell's transpose, and a region covering the sample +column together with a channel beneath it moves that channel a single step, since the sample column +stands for the channels a value typed in it writes to. + +Each cell reaches the grid through the single-cell adjustment that already governs it, the way a +pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make — +the transpose and volume ranges included. + ## A grid declares its actions once Where they are shown is decided by whoever asks for them. Each grid builds its whole @@ -128,6 +141,11 @@ them coalesces: a block gesture is already a whole gesture, and folding two cons pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, since it mutates nothing. +A shift coalesces, because a nudge is a step of one gesture rather than a whole one. The block it +covers is its coalescing target, so a streak over one selection leaves a single step to undo and a +shift after the cursor moves or the selection is reached out starts the next entry. Transpose and +volume count separately, each carrying its own action. + ## Dragging a range out Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 28a5287d..de22cb8d 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -80,6 +80,22 @@ as it was. Emptying cells keeps the rows and frames they sit in, and every block action is one step in the history, so a single **Undo** takes it all back. +## Transposing and shading + +In the **Tracker**, transpose and volume move whatever the selection covers, so a +run of rows nudges together. + +| Key | Action | +|-----|--------| +| `Ctrl+Up` / `Ctrl+Down` | Transpose a semitone | +| `Ctrl+Shift+Up` / `Ctrl+Shift+Down` | Transpose an octave | +| `Alt+Up` / `Alt+Down` | Volume a step | +| `Alt+Shift+Up` / `Alt+Shift+Down` | Volume four steps | + +Control carries pitch, Alt carries volume, and Shift makes the step the bigger one. +With nothing selected they act on the cell the cursor stands on, and the same +commands sit on the right-click menu with these keys beside them. + ## Playing the song The transport below the grid plays the song, and the keyboard drives playback diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index b4a04dac..7b96b8e0 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -129,6 +129,14 @@ class KeybindingActionElements(AbstractElement): TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_CUT_BLOCK = "tracker_cut_block" TRACKER_PASTE_BLOCK = "tracker_paste_block" + TRACKER_TRANSPOSE_UP = "tracker_transpose_up" + TRACKER_TRANSPOSE_DOWN = "tracker_transpose_down" + TRACKER_TRANSPOSE_OCTAVE_UP = "tracker_transpose_octave_up" + TRACKER_TRANSPOSE_OCTAVE_DOWN = "tracker_transpose_octave_down" + TRACKER_VOLUME_UP = "tracker_volume_up" + TRACKER_VOLUME_DOWN = "tracker_volume_down" + TRACKER_VOLUME_UP_COARSE = "tracker_volume_up_coarse" + TRACKER_VOLUME_DOWN_COARSE = "tracker_volume_down_coarse" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 2ed05068..70df6d85 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -43,6 +43,7 @@ SequencerTrackerLogic, TrackerBlockReader, TrackerBlockWriter, + TrackerRegionAdjuster, ) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters @@ -197,6 +198,7 @@ def __init__( self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) + self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( @@ -348,13 +350,13 @@ def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame self._sequencer_tracker_panel.on_adjust_transpose = self._undoable( HistoryAction.ADJUST_TRANSPOSE, - self._sequencer_tracker_logic.adjust_cell_transpose, + self._tracker_region_adjuster.adjust_transpose, detail=self._history_detail.adjust_transpose, coalesce=self._adjustment_key, ) self._sequencer_tracker_panel.on_adjust_volume = self._undoable( HistoryAction.ADJUST_VOLUME, - self._sequencer_tracker_logic.adjust_cell_volume, + self._tracker_region_adjuster.adjust_volume, detail=self._history_detail.adjust_volume, coalesce=self._adjustment_key, ) @@ -711,11 +713,22 @@ def _cell_key( def _adjustment_key( self, - row_index: int, - generator: Optional[GeneratorName], + region: TrackerRegion, _delta: int, ) -> CoalesceKey: - return self._cell_key(row_index, generator) + """Identifies the cells an adjustment covers as one coalescing target. + + A streak of nudges over the same block reads as one entry, so holding a transpose key steps + the selection and leaves a single step to undo; moving the cursor or reaching the selection + out starts the next one. + """ + return ( + self._sequencer_tracker_logic.frame_index, + region.first_row, + region.last_row, + region.first_slot, + region.last_slot, + ) def _edit_row_key( self, diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 125cc181..32f1672b 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -144,37 +144,23 @@ def clear_subcolumn( segments.append(self._subcolumn(subcolumn)) return tuple(segments) - def adjust_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> Segments: - affected = self._tracker_logic.relevant_generators(row_index) - segments = list(self._location(row_index, generator, affected)) - segments.append( + def adjust_transpose(self, region: TrackerRegion, delta: int) -> Segments: + """Reads as the cells a shift covers, followed by the semitones it moves them.""" + return ( + *self._tracker_region(region), self._segment(display_transpose(delta), HistoryDetailRole.TRANSPOSE), ) - return tuple(segments) - def adjust_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> Segments: - affected = self._tracker_logic.relevant_generators(row_index) - segments = list(self._location(row_index, generator, affected)) - segments.append(self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME)) - return tuple(segments) + def adjust_volume(self, region: TrackerRegion, delta: int) -> Segments: + """Reads as the cells a shift covers, followed by the steps it moves them.""" + return ( + *self._tracker_region(region), + self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME), + ) def tracker_block(self, region: TrackerRegion) -> Segments: """Reads as the frame, the channels a block spans and the rows it covers.""" - return ( - self._frame(self._tracker_logic.frame_index), - self._channel(self._covered_channels({slot.generator for slot in region.slots})), - self._row_range(region.first_row, region.last_row), - ) + return self._tracker_region(region) def tracker_paste(self, cell: TrackerCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" @@ -319,6 +305,18 @@ def _edit_row_generators( return self._tracker_logic.relevant_generators(row_index) + def _tracker_region(self, region: TrackerRegion) -> Segments: + """Reads a rectangle of the tracker as its frame, the channels it spans and the rows it covers. + + Every gesture over a region reads the same way, so a block and a shift describe the cells + they reach in one form. + """ + return ( + self._frame(self._tracker_logic.frame_index), + self._channel(self._covered_channels(set(region.columns))), + self._row_range(region.first_row, region.last_row), + ) + def _location( self, row_index: int, diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py index 5e3c9ecc..1d2e89c8 100644 --- a/src/sampletones_application/logic/sequencer/tracker/__init__.py +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -1,3 +1,4 @@ +from .adjuster import TrackerRegionAdjuster from .block import BlockNote, TrackerBlock from .reader import TrackerBlockReader from .tracker import SequencerTrackerLogic @@ -9,4 +10,5 @@ "TrackerBlock", "TrackerBlockReader", "TrackerBlockWriter", + "TrackerRegionAdjuster", ] diff --git a/src/sampletones_application/logic/sequencer/tracker/adjuster.py b/src/sampletones_application/logic/sequencer/tracker/adjuster.py new file mode 100644 index 00000000..3e77e4b5 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/adjuster.py @@ -0,0 +1,57 @@ +from typing import Iterator, List, Optional, Tuple + +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_core.constants.enums import GeneratorName + +from .tracker import SequencerTrackerLogic + + +class TrackerRegionAdjuster: + """Shifts transpose and volume across the cells a region covers. + + A region names its edges as subcolumns while these two gestures act on whole cells, so an + adjustment reads the columns behind the region and reaches each of their channels once. The + sample column stands for the channels a value typed in it writes to, which is what keeps a + region covering it and a channel beneath it moving that channel a single step. + + Each cell reaches the grid through the single-cell adjustment that already governs it, so a + shift over a region lands exactly the writes the same nudge repeated by hand would make. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def adjust_transpose(self, region: TrackerRegion, delta: int) -> None: + """Shifts every covered cell's transpose by ``delta`` semitones.""" + for row_index, generator in self._cells(region): + self._tracker.adjust_transpose(generator, row_index, delta) + + def adjust_volume(self, region: TrackerRegion, delta: int) -> None: + """Shifts every covered cell's volume by ``delta``.""" + for row_index, generator in self._cells(region): + self._tracker.adjust_volume(generator, row_index, delta) + + def _cells( + self, + region: TrackerRegion, + ) -> Iterator[Tuple[int, GeneratorName]]: + """The channel cells a region reaches, row by row and each named once.""" + columns = region.columns + for row_index in region.rows: + for generator in self._channels(columns, row_index): + yield row_index, generator + + def _channels( + self, + columns: Tuple[Optional[GeneratorName], ...], + row_index: int, + ) -> List[GeneratorName]: + """The channels a row's columns reach, the sample column standing for the ones it governs.""" + channels: List[GeneratorName] = [] + for column in columns: + if column is None: + channels.extend(self._tracker.relevant_generators(row_index)) + else: + channels.append(column) + + return list(dict.fromkeys(channels)) diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 9cf7c00a..b9ad587f 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -256,28 +256,6 @@ def set_cell_subcolumn( volume=volume, ) - def adjust_cell_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - if generator is None: - self.adjust_sample_transpose(row_index, delta) - else: - self.adjust_transpose(generator, row_index, delta) - - def adjust_cell_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - if generator is None: - self.adjust_sample_volume(row_index, delta) - else: - self.adjust_volume(generator, row_index, delta) - def set_row( self, generator: GeneratorName, @@ -466,16 +444,6 @@ def adjust_volume( volume=self._current_volume(generator, row_index) + delta, ) - def adjust_sample_transpose(self, row_index: int, delta: int) -> None: - """Shifts transpose by ``delta`` across the sample column's channels.""" - for generator in self._subcolumn_generators(row_index): - self.adjust_transpose(generator, row_index, delta) - - def adjust_sample_volume(self, row_index: int, delta: int) -> None: - """Shifts volume by ``delta`` across the sample column's channels.""" - for generator in self._subcolumn_generators(row_index): - self.adjust_volume(generator, row_index, delta) - def row( self, generator: GeneratorName, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index b9f3319f..f960c4dd 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -115,7 +115,7 @@ OnCellSelectedCallback = VoidCallback OnPlayFromRowCallback = Callable[[int], None] OnPlayFromFrameCallback = VoidCallback -OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] +OnAdjustCallback = Callable[[TrackerRegion, int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] OnBlockRegionCallback = Callable[[TrackerRegion], None] @@ -127,6 +127,63 @@ VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 PLAYHEAD_PAINT_FRAMES: Final[int] = 1 +AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int] +AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None] + +TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_UP, + ShortcutId.TRACKER_TRANSPOSE_UP, + SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_DOWN, + -SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + OCTAVE_SEMITONES, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + -OCTAVE_SEMITONES, + ), +) + +VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP, + ShortcutId.TRACKER_VOLUME_UP, + VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN, + ShortcutId.TRACKER_VOLUME_DOWN, + -VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE, + ShortcutId.TRACKER_VOLUME_UP_COARSE, + VOLUME_COARSE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE, + ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + -VOLUME_COARSE_STEP, + ), +) + + +def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]: + return {shortcut_id: delta for _, shortcut_id, delta in actions} + + +TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS) +VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS) + class GUISequencerTrackerPanel(GUIPanel): def __init__( @@ -261,14 +318,9 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_clear_subcolumn = label(SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN) self._lbl_context_clear_cell = label(SequencerTrackerElements.CONTEXT_CLEAR_CELL) self._lbl_context_clear_row = label(SequencerTrackerElements.CONTEXT_CLEAR_ROW) - self._lbl_context_transpose_up = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_UP) - self._lbl_context_transpose_down = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN) - self._lbl_context_transpose_octave_up = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP) - self._lbl_context_transpose_octave_down = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN) - self._lbl_context_volume_up = label(SequencerTrackerElements.CONTEXT_VOLUME_UP) - self._lbl_context_volume_down = label(SequencerTrackerElements.CONTEXT_VOLUME_DOWN) - self._lbl_context_volume_up_coarse = label(SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE) - self._lbl_context_volume_down_coarse = label(SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE) + self._lbl_adjust: Dict[SequencerTrackerElements, str] = { + element: label(element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS) + } def _load_header_tooltips(self, language_manager: LanguageManager) -> None: """Reads the header tooltips, which name the click gestures the labels carry.""" @@ -1294,9 +1346,9 @@ def _add_action_items(self, target: TrackerTarget) -> None: callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator), ) dpg.add_separator() - self._add_transpose_items(target.cell) + self._add_transpose_items(target) dpg.add_separator() - self._add_volume_items(target.cell) + self._add_volume_items(target) dpg.add_separator() self._add_clear_items(target.cell) @@ -1346,30 +1398,31 @@ def _add_instrument_submenu(self, cell: TrackerCursor) -> None: callback=self._on_set_instrument_menu, ) - def _add_transpose_items(self, cell: TrackerCursor) -> None: - for label, delta in ( - (self._lbl_context_transpose_up, SEMITONE_STEP), - (self._lbl_context_transpose_down, -SEMITONE_STEP), - (self._lbl_context_transpose_octave_up, OCTAVE_SEMITONES), - (self._lbl_context_transpose_octave_down, -OCTAVE_SEMITONES), - ): - dpg.add_menu_item( - label=label, - user_data=(cell.row, cell.generator, delta), - callback=self._on_transpose_menu, - ) + def _add_transpose_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu) - def _add_volume_items(self, cell: TrackerCursor) -> None: - for label, delta in ( - (self._lbl_context_volume_up, VOLUME_FINE_STEP), - (self._lbl_context_volume_down, -VOLUME_FINE_STEP), - (self._lbl_context_volume_up_coarse, VOLUME_COARSE_STEP), - (self._lbl_context_volume_down_coarse, -VOLUME_COARSE_STEP), - ): + def _add_volume_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu) + + def _add_adjust_items( + self, + target: TrackerTarget, + actions: Tuple[AdjustAction, ...], + callback: AdjustMenuCallback, + ) -> None: + """Builds one axis of adjustment items, each shifting the cells its target covers. + + An adjustment acts on whole cells, so it reaches the columns the target's block covers and + the rows it spans: a nudge with a selection standing moves all of it, and one on a cell + alone moves that cell. Each item prints the key it answers to, since the action states its + label, its binding and its step in one entry. + """ + for element, shortcut_id, delta in actions: dpg.add_menu_item( - label=label, - user_data=(cell.row, cell.generator, delta), - callback=self._on_volume_menu, + label=self._lbl_adjust[element], + shortcut=self._shortcuts.display(shortcut_id), + user_data=(target.region, delta), + callback=callback, ) def _on_set_instrument_menu( @@ -1385,19 +1438,19 @@ def _on_transpose_menu( self, _sender: Sender, _app_data: None, - user_data: Tuple[int, Optional[GeneratorName], int], + user_data: Tuple[TrackerRegion, int], ) -> None: - row_index, generator, delta = user_data - self.call(self.on_adjust_transpose, row_index, generator, delta) + region, delta = user_data + self.call(self.on_adjust_transpose, region, delta) def _on_volume_menu( self, _sender: Sender, _app_data: None, - user_data: Tuple[int, Optional[GeneratorName], int], + user_data: Tuple[TrackerRegion, int], ) -> None: - row_index, generator, delta = user_data - self.call(self.on_adjust_volume, row_index, generator, delta) + region, delta = user_data + self.call(self.on_adjust_volume, region, delta) def _add_clear_items(self, cell: TrackerCursor) -> None: """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. @@ -1467,6 +1520,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._block_action(shortcut_id): return True + if self._adjust_action(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1542,6 +1598,41 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: return True + def _adjust_action(self, shortcut_id: ShortcutId) -> bool: + """Shifts the covered cells' transpose or volume, reporting whether the action was one of + the two axes. + + A press acts on the block the cursor stands in, which is the selection while one covers it + and the cursor's own cell otherwise — the target the menus resolve as well, so a key and a + menu item reach the same cells. + """ + transpose_step = TRANSPOSE_STEPS.get(shortcut_id) + if transpose_step is not None: + self._adjust_at_cursor(self.on_adjust_transpose, transpose_step) + return True + + volume_step = VOLUME_STEPS.get(shortcut_id) + if volume_step is not None: + self._adjust_at_cursor(self.on_adjust_volume, volume_step) + return True + + return False + + def _adjust_at_cursor( + self, + hook: Optional[OnAdjustCallback], + delta: int, + ) -> None: + """Raises an adjustment on the block the cursor stands in, the entry being typed landing first. + + Committing ahead of the shift is what lets a nudge carry the value the reader has just + finished typing, the rule the block gestures follow as well. + """ + self.commit_entry() + target = self.cursor_target() + if target is not None: + self.call(hook, target.region, delta) + def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index a14de3b2..bfd7b9ef 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -147,6 +147,14 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_UP = ("TrackerTransposeUp", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_DOWN = ("TrackerTransposeDown", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_OCTAVE_UP = ("TrackerTransposeOctaveUp", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_OCTAVE_DOWN = ("TrackerTransposeOctaveDown", ShortcutCategory.TRACKER) + TRACKER_VOLUME_UP = ("TrackerVolumeUp", ShortcutCategory.TRACKER) + TRACKER_VOLUME_DOWN = ("TrackerVolumeDown", ShortcutCategory.TRACKER) + TRACKER_VOLUME_UP_COARSE = ("TrackerVolumeUpCoarse", ShortcutCategory.TRACKER) + TRACKER_VOLUME_DOWN_COARSE = ("TrackerVolumeDownCoarse", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index c4f8de5b..04249d7b 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -80,6 +80,16 @@ def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + @property + def columns(self) -> Tuple[Optional[GeneratorName], ...]: + """The columns the region reaches, each named once and in the order the axis lays them out. + + A region names its edges as subcolumns, while a gesture acting on whole cells — a transpose + or a volume shift — reaches the columns behind them. The sample column reads ``None``, as it + does everywhere the axis is read. + """ + return tuple(dict.fromkeys(slot.generator for slot in self.slots)) + def covers(self, row: int, slot: TrackerSlot) -> bool: """Whether a cell of the grid falls inside the rectangle. diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index c0a88d7a..6da80187 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -107,6 +107,14 @@ bindings: TrackerCopyBlock: {combination: "Ctrl+C"} TrackerCutBlock: {combination: "Ctrl+X"} TrackerPasteBlock: {combination: "Ctrl+V"} + TrackerTransposeUp: {combination: "Ctrl+Up"} + TrackerTransposeDown: {combination: "Ctrl+Down"} + TrackerTransposeOctaveUp: {combination: "Ctrl+Shift+Up"} + TrackerTransposeOctaveDown: {combination: "Ctrl+Shift+Down"} + TrackerVolumeUp: {combination: "Alt+Up"} + TrackerVolumeDown: {combination: "Alt+Down"} + TrackerVolumeUpCoarse: {combination: "Alt+Shift+Up"} + TrackerVolumeDownCoarse: {combination: "Alt+Shift+Down"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 0b70e729..1e127e28 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -107,6 +107,14 @@ bindings: TrackerCopyBlock: {combination: "Cmd+C"} TrackerCutBlock: {combination: "Cmd+X"} TrackerPasteBlock: {combination: "Cmd+V"} + TrackerTransposeUp: {combination: "Cmd+Alt+Up"} + TrackerTransposeDown: {combination: "Cmd+Alt+Down"} + TrackerTransposeOctaveUp: {combination: "Cmd+Alt+Shift+Up"} + TrackerTransposeOctaveDown: {combination: "Cmd+Alt+Shift+Down"} + TrackerVolumeUp: {combination: "Cmd+Alt+Right"} + TrackerVolumeDown: {combination: "Cmd+Alt+Left"} + TrackerVolumeUpCoarse: {combination: "Cmd+Alt+Shift+Right"} + TrackerVolumeDownCoarse: {combination: "Cmd+Alt+Shift+Left"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index fdffff2e..8c9f5d69 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -818,6 +818,14 @@ settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selecti settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_cut_block: "Cut selection" settings.keybindings.label.tracker_paste_block: "Paste selection" +settings.keybindings.label.tracker_transpose_up: "Transpose up" +settings.keybindings.label.tracker_transpose_down: "Transpose down" +settings.keybindings.label.tracker_transpose_octave_up: "Transpose octave up" +settings.keybindings.label.tracker_transpose_octave_down: "Transpose octave down" +settings.keybindings.label.tracker_volume_up: "Volume up" +settings.keybindings.label.tracker_volume_down: "Volume down" +settings.keybindings.label.tracker_volume_up_coarse: "Volume up (coarse)" +settings.keybindings.label.tracker_volume_down_coarse: "Volume down (coarse)" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 835fe41e..1baec865 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -175,7 +175,15 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: controller = _controller() formatter = _formatter(controller) - segments = formatter.adjust_transpose(0, GeneratorName.PULSE2, -3) + segments = formatter.adjust_transpose( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + ), + -3, + ) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -184,6 +192,28 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: ("-03", HistoryDetailRole.TRANSPOSE), ] + def test_adjust_volume_reads_the_rows_it_covers(self) -> None: + """A shift over a selection names the span it reached, the way a block gesture does.""" + controller = _controller() + formatter = _formatter(controller) + + segments = formatter.adjust_volume( + TrackerRegion( + first_row=0, + last_row=3, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + ), + -1, + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("Pp", HistoryDetailRole.CHANNEL), + ("00-03", HistoryDetailRole.ROW), + ("-1", HistoryDetailRole.VOLUME), + ] + class TestOrderDetails: def test_add_frame_reports_the_landing_index(self) -> None: diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py new file mode 100644 index 00000000..20da9fe3 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py @@ -0,0 +1,312 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerRegionAdjuster, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction + +FRAME_ROWS: Final[int] = 3 +EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +LEAD: Final[str] = "00" + + +@dataclass(frozen=True, kw_only=True) +class Grid: + """A three-row frame with a sample over two channels, the state every case starts from.""" + + controller: ProjectController + logic: SequencerTrackerLogic + adjuster: TrackerRegionAdjuster + sample_ids: Tuple[str, ...] + + +@pytest.fixture +def grid() -> Grid: + """A frame short enough for a case to state whole, holding a sample over two of the channels. + + Which channels a sample governs is what the sample column fans a shift out over, so a governed + row and an ungoverned one both stand available to a case. + """ + controller = ProjectController(ProjectManager()) + logic = SequencerTrackerLogic(controller) + logic.set_rows_per_pattern(FRAME_ROWS) + lead = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="lead", + ) + return Grid( + controller=controller, + logic=logic, + adjuster=TrackerRegionAdjuster(logic), + sample_ids=(lead.id,), + ) + + +def _region( + first: Tuple[Optional[GeneratorName], SubColumn], + last: Tuple[Optional[GeneratorName], SubColumn], + *, + first_row: int = 0, + last_row: int = 0, +) -> TrackerRegion: + """The rectangle a pair of slots bounds, each stated as the column and subcolumn it addresses.""" + return TrackerRegion( + first_row=first_row, + last_row=last_row, + first_slot=TrackerSlot(*first).flat_index, + last_slot=TrackerSlot(*last).flat_index, + ) + + +class TestAdjustTranspose(BaseTestSuite): + """Which cells a transpose shift reaches, stated as the whole frame it leaves behind. + + A shift acts on whole cells while a region names its edges as subcolumns, so each case states + the subcolumns its region begins and ends on and reads the columns behind them in the result. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + region: TrackerRegion + delta: int + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a cell alone shifts its own channel", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ), + delta=1, + expected=( + ".. +01 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region standing on another subcolumn still shifts the transpose", + region=_region( + (GeneratorName.TRIANGLE, SubColumn.VOLUME), + (GeneratorName.TRIANGLE, SubColumn.VOLUME), + ), + delta=-1, + expected=( + ".. ... . | .. ... . | .. -01 . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift adds to the transpose a cell already holds", + frame=(".. +02 . | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ), + delta=12, + expected=( + ".. +0E . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region across columns shifts each of them", + region=_region( + (GeneratorName.PULSE2, SubColumn.VOLUME), + (GeneratorName.NOISE, SubColumn.INSTRUMENT), + ), + delta=1, + expected=( + ".. ... . | .. +01 . | .. +01 . | .. +01 .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region across rows shifts each of them", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + first_row=1, + last_row=2, + ), + delta=2, + expected=( + EMPTY, + ".. +02 . | .. ... . | .. ... . | .. ... .", + ".. +02 . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="an ungoverned sample column reaches every channel", + region=_region( + (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOLUME), + ), + delta=3, + expected=( + ".. +03 . | .. +03 . | .. +03 . | .. +03 .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a governed sample column reaches the channels its sample uses", + frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOLUME), + ), + delta=3, + expected=( + "00 +03 . | 00 +03 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel covered beside the sample column moves a single step", + frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=1, + expected=( + "00 +01 . | 00 +01 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift stops at the transpose range", + frame=(".. +20 . | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ), + delta=12, + expected=( + ".. +24 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_shift( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + + grid.adjuster.adjust_transpose(test_case.region, test_case.delta) + + assert render_frame(grid.logic) == test_case.expected + + +class TestAdjustVolume(BaseTestSuite): + """Which cells a volume shift reaches, read the same way a transpose shift is.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + region: TrackerRegion + delta: int + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="an unset cell steps down from full", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ), + delta=-1, + expected=( + ".. ... E | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a coarse step moves the whole region", + frame=(".. ... 8 | .. ... 8 | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.VOLUME), + (GeneratorName.PULSE2, SubColumn.VOLUME), + ), + delta=-4, + expected=( + ".. ... 4 | .. ... 4 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift stops at silence", + frame=(".. ... 1 | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.VOLUME), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=-4, + expected=( + ".. ... 0 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel covered beside the sample column moves a single step", + frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=-1, + expected=( + "00 ... 7 | 00 ... 7 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_shift( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + + grid.adjuster.adjust_volume(test_case.region, test_case.delta) + + assert render_frame(grid.logic) == test_case.expected diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 52987c99..bee5646f 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -195,34 +195,6 @@ def test_the_sample_column_cuts_every_channel(self) -> None: assert isinstance(_row(controller, generator).command, NoteOff) -class TestAdjustCell: - def test_a_channel_cell_shifts_only_that_channel(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - - logic.adjust_cell_volume(0, GeneratorName.PULSE1, -1) - - assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1 - assert _row(controller, GeneratorName.PULSE2).volume is None - - def test_the_sample_column_shifts_the_sample_channels(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_cell_transpose(0, None, 3) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).transpose == 3 - - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).transpose is None - - class TestFrameRowCount: def test_counts_the_rows_the_grid_builds(self) -> None: controller = _controller() @@ -512,39 +484,6 @@ def test_clamps_to_zero(self) -> None: assert _row(controller, GeneratorName.PULSE1).volume == 0 -class TestAdjustSampleColumn: - def test_sample_transpose_shifts_only_relevant_channels(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_sample_transpose(0, 3) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).transpose == 3 - - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).transpose is None - - def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_sample_volume(0, -1) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).volume == MAX_VOLUME - 1 - - class TestBuildTrackerAggregation: def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 2186b19a..3dd19e0c 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,6 +5,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, @@ -24,6 +25,7 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -36,13 +38,16 @@ @dataclass class Gestures: - """What each block hook was handed, which is the whole of what a press reaches the grid with.""" + """What each of the tracker's hooks was handed, which is the whole of what a press reaches the + grid with.""" copied: List[TrackerRegion] = field(default_factory=list) cut: List[TrackerRegion] = field(default_factory=list) deleted: List[TrackerRegion] = field(default_factory=list) pasted: List[TrackerCell] = field(default_factory=list) cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) + transposed: List[Tuple[TrackerRegion, int]] = field(default_factory=list) + volume_shifted: List[Tuple[TrackerRegion, int]] = field(default_factory=list) @dataclass @@ -84,6 +89,8 @@ def _panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) + panel.on_adjust_transpose = lambda region, delta: gestures.transposed.append((region, delta)) + panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state: None) @@ -220,6 +227,55 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] +class TestTrackerAdjustKeys: + """The shifts reach the same block the clipboard keys do, so a selection moves whole.""" + + def test_a_selection_is_transposed_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+Up")) is True + assert gestures.transposed == [ + ( + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ), + SEMITONE_STEP, + ) + ] + + def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Alt+Down")) is True + region, delta = gestures.volume_shifted[-1] + assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert delta == -tracker_module.VOLUME_FINE_STEP + + def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Ctrl+Shift+Up")) is True + assert panel._on_key_pressed(_press("Alt+Shift+Up")) is True + assert gestures.transposed[-1][1] == OCTAVE_SEMITONES + assert gestures.volume_shifted[-1][1] == tracker_module.VOLUME_COARSE_STEP + + def test_a_grid_with_no_cursor_shifts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = TrackerInputState() + + assert panel._on_key_pressed(_press("Ctrl+Up")) is False + assert gestures.transposed == [] + + class TestOrderCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = OrderGestures() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index ceabdd53..fa7060d1 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -92,14 +92,6 @@ def add_menu_item(self, **kwargs: Any) -> int: "clear_subcolumn", "clear_cell", "clear_row", - "transpose_up", - "transpose_down", - "transpose_octave_up", - "transpose_octave_down", - "volume_up", - "volume_down", - "volume_up_coarse", - "volume_down_coarse", ) ORDER_LABELS = ( @@ -124,6 +116,13 @@ def _labels(panel: Any, names: Tuple[str, ...]) -> None: setattr(panel, f"_lbl_context_{name}", name) +def _adjust_labels(panel: Any) -> None: + """Gives the panel the words its transpose and volume items print, each reading as its element.""" + panel._lbl_adjust = { + element: element.value for element, _, _ in (*tracker_module.TRANSPOSE_ACTIONS, *tracker_module.VOLUME_ACTIONS) + } + + def _tracker_panel( gestures: Gestures, *, @@ -132,6 +131,7 @@ def _tracker_panel( """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) _labels(panel, TRACKER_LABELS) + _adjust_labels(panel) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState() panel._current_samples = None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index e08c2c58..58285015 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,7 +4,9 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, @@ -12,6 +14,7 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from tests.suite.shortcuts import shipped_source SENDER_WIDGET_ID = 6099 """A stand-in for the menu-item widget id DearPyGui passes as the callback's first @@ -21,14 +24,6 @@ _CONTEXT_LABELS = ( "_lbl_context_set_instrument", "_lbl_context_no_samples", - "_lbl_context_transpose_up", - "_lbl_context_transpose_down", - "_lbl_context_transpose_octave_up", - "_lbl_context_transpose_octave_down", - "_lbl_context_volume_up", - "_lbl_context_volume_down", - "_lbl_context_volume_up_coarse", - "_lbl_context_volume_down_coarse", ) @@ -36,13 +31,22 @@ def _panel() -> tracker_module.GUISequencerTrackerPanel: """Builds a panel without its DearPyGui-dependent constructor. The menu-dispatch methods touch only their hook attributes, the context - labels, and ``CallbackMixin.call``, so a fully wired GUI context is - unnecessary here. Labels carry no behaviour, so any placeholder text serves. + labels, the keys each item prints, and ``CallbackMixin.call``, so a fully + wired GUI context is unnecessary here. Labels carry no behaviour, so any + placeholder text serves. """ panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) for label in _CONTEXT_LABELS: setattr(panel, label, "") + panel._lbl_adjust = { + element: "" + for element, _, _ in ( + *tracker_module.TRANSPOSE_ACTIONS, + *tracker_module.VOLUME_ACTIONS, + ) + } + panel._shortcuts = shipped_source() return panel @@ -81,13 +85,19 @@ def _cell(row: int, generator: GeneratorName) -> TrackerCursor: return TrackerCursor(row, generator, SubColumn.INSTRUMENT) +def _target(row: int, generator: GeneratorName) -> TrackerTarget: + """The cell a menu was raised on, paired with the block of that cell alone.""" + cell = _cell(row, generator) + return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell)) + + class TestMenuDispatchPreservesPayload: def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] - panel.on_adjust_transpose = lambda row, generator, delta: deltas.append(delta) + panel.on_adjust_transpose = lambda region, delta: deltas.append(delta) - panel._add_transpose_items(_cell(2, GeneratorName.PULSE1)) + panel._add_transpose_items(_target(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -100,9 +110,9 @@ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecor def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] - panel.on_adjust_volume = lambda row, generator, delta: deltas.append(delta) + panel.on_adjust_volume = lambda region, delta: deltas.append(delta) - panel._add_volume_items(_cell(2, GeneratorName.PULSE1)) + panel._add_volume_items(_target(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -112,15 +122,16 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder -tracker_module.VOLUME_COARSE_STEP, ] - def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRecorder) -> None: + def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None: panel = _panel() - calls: List[Tuple[int, GeneratorName, int]] = [] - panel.on_adjust_transpose = lambda row, generator, delta: calls.append((row, generator, delta)) + calls: List[Tuple[TrackerRegion, int]] = [] + panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta)) + target = _target(7, GeneratorName.TRIANGLE) - panel._add_transpose_items(_cell(7, GeneratorName.TRIANGLE)) + panel._add_transpose_items(target) recorder.dispatch_as_dpg() - assert calls[0] == (7, GeneratorName.TRIANGLE, SEMITONE_STEP) + assert calls[0] == (target.region, SEMITONE_STEP) def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None: panel = _panel() diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index be485188..8f480a3b 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -119,6 +119,123 @@ def test_the_samples_panel_keeps_rename_on_its_function_key(self, shipped: Short assert shipped.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_SAMPLE +class TestTrackerAdjustKeys(BaseTestSuite): + """The keys the tracker's shifts answer to: Ctrl carries pitch, Alt carries volume, and Shift + makes the step the bigger one.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Ctrl+Up"), + TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Ctrl+Down"), + TestCase( + label="an octave up", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + expected="Ctrl+Shift+Up", + ), + TestCase( + label="an octave down", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + expected="Ctrl+Shift+Down", + ), + TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Alt+Up"), + TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Alt+Down"), + TestCase( + label="a coarse volume up", + shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, + expected="Alt+Shift+Up", + ), + TestCase( + label="a coarse volume down", + shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + expected="Alt+Shift+Down", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shift_reads_under_the_combination_it_answers( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + assert shipped.shortcut(test_case.shortcut_id).display() == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shift_answers_its_press_in_the_tracker( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(ShortcutCategory.TRACKER, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + +class TestMacosAdjustKeys(BaseTestSuite): + """What a Mac reaches the tracker's shifts through. + + The alternatives a Mac keyboard needs already answer on Cmd and Alt with the arrows, so the + shifts take Cmd+Alt there and read their axis from the direction: the arrows up and down carry + pitch, those left and right carry volume, and Shift makes the step the bigger one. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Cmd+Alt+Up"), + TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Cmd+Alt+Down"), + TestCase( + label="an octave up", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + expected="Cmd+Alt+Shift+Up", + ), + TestCase( + label="an octave down", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + expected="Cmd+Alt+Shift+Down", + ), + TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Cmd+Alt+Right"), + TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Cmd+Alt+Left"), + TestCase( + label="a coarse volume up", + shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, + expected="Cmd+Alt+Shift+Right", + ), + TestCase( + label="a coarse volume down", + shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + expected="Cmd+Alt+Shift+Left", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shift_reads_under_the_combination_a_mac_gives_it( + self, + test_case: TestCase, + macos: ShortcutScheme, + mac_keyboard: None, + ) -> None: + assert macos.shortcut(test_case.shortcut_id).display() == test_case.expected + + class TestMacosKeys(BaseTestSuite): """What a Mac reads its keys as, spelled the way that keyboard is labelled.""" From 36ed2755a61961b52e2b67d4c48561fc4db018e8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 13:21:38 +0200 Subject: [PATCH 21/28] Extracted: the grid edit surface --- .../coordinators/tabs/sequencer.py | 4 +- .../ui/panels/sequencer/grid/gestures.py | 44 +-- .../ui/panels/sequencer/grid/surface.py | 195 +++++++++++++ .../ui/panels/sequencer/order.py | 106 +++---- .../ui/panels/sequencer/tracker.py | 108 +++----- tests/suite/grid.py | 44 +++ .../ui/panels/sequencer/grid/test_gestures.py | 70 +---- .../ui/panels/sequencer/grid/test_surface.py | 261 ++++++++++++++++++ .../ui/panels/sequencer/test_block_keys.py | 8 + .../ui/panels/sequencer/test_block_menu.py | 77 +++--- 10 files changed, 636 insertions(+), 281 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface.py create mode 100644 tests/suite/grid.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 70df6d85..f2850168 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1438,6 +1438,6 @@ def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: The two hold one cursor between them, so the menu bar reaches whichever one has it. """ return ( - self._sequencer_tracker_panel, - self._sequencer_order_panel, + self._sequencer_tracker_panel.edit_surface, + self._sequencer_order_panel.edit_surface, ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py index 3b1edf44..19d5c84d 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -25,8 +25,7 @@ def anchor(self) -> CellT_co: ... class BlockGrid(Protocol[RegionT, CellT]): """What a grid states to the block gestures raised over it. - The hooks are the grid's own, so the coordinator keeps wiring them where it already does; the - two methods are what a key press needs, since it names its target through the cursor. + The hooks are the grid's own, so the coordinator keeps wiring them where it already does. """ on_copy_block: Optional[Callable[[RegionT], None]] @@ -35,22 +34,13 @@ class BlockGrid(Protocol[RegionT, CellT]): on_paste_block: Optional[Callable[[CellT], None]] can_paste_block: Optional[Callable[[], bool]] - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on.""" - - def cursor_target(self) -> Optional[BlockTarget[RegionT, CellT]]: - """The target the cursor names, once the grid holds a cursor.""" - class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): """The four gestures a grid's blocks answer to: copy, cut, paste and delete. - Three doors raise the same four. A key press acts at the cursor, and takes its target once the - entry being typed has landed, so a gesture carries the value the reader has just finished. A - cell menu and the menu bar's Edit menu each name the target they were built for and act on it - where it stands. Holding the four here is what has every door fire one implementation. - - The plain gestures act at the cursor; the ``_at`` gestures act on a target already named. + Three doors raise the same four, and each names the target it acts on: a cell menu and the menu + bar's Edit menu name the target they were built for, and a key press names the cursor's. + Holding the four here is what has every door fire one implementation. """ def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: @@ -60,18 +50,6 @@ def can_paste(self) -> bool: """Whether a block stands ready for a paste to write.""" return self.query(self._grid.can_paste_block, default=False) - def copy(self) -> None: - self._at_cursor(self.copy_at) - - def cut(self) -> None: - self._at_cursor(self.cut_at) - - def delete(self) -> None: - self._at_cursor(self.delete_at) - - def paste(self) -> None: - self._at_cursor(self.paste_at) - def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Takes what a target covers, leaving the grid as it stands.""" self.call(self._grid.on_copy_block, target.region) @@ -87,17 +65,3 @@ def delete_at(self, target: BlockTarget[RegionT, CellT]) -> None: def paste_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Writes the block in hand from a target's own cell, which is where it lands.""" self.call(self._grid.on_paste_block, target.anchor) - - def _at_cursor( - self, - gesture: Callable[[BlockTarget[RegionT, CellT]], None], - ) -> None: - """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. - - Committing ahead of the gesture is what lets a block carry the value the reader has just - finished typing. - """ - self._grid.commit_entry() - target = self._grid.cursor_target() - if target is not None: - gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface.py b/src/sampletones_application/ui/panels/sequencer/grid/surface.py new file mode 100644 index 00000000..24d25246 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface.py @@ -0,0 +1,195 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, Generic, Mapping, Optional, Protocol, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) +TargetT_co = TypeVar("TargetT_co", covariant=True) +TargetT_contra = TypeVar("TargetT_contra", contravariant=True) +CursorT_contra = TypeVar("CursorT_contra", contravariant=True) +RegionT_contra = TypeVar("RegionT_contra", contravariant=True) + +CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) + + +def clipboard_labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: + """The words every clipboard item prints, read from the vocabulary each grid shares.""" + return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} + + +@dataclass(frozen=True) +class BlockShortcuts: + """The keys one grid answers the clipboard gestures with. + + Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the + cell under the cursor otherwise, so the grid resolves it from the selection and its item prints + no key. + """ + + copy: ShortcutId + cut: ShortcutId + paste: ShortcutId + + +class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): + """How a grid's own target is built from the pair every target carries.""" + + def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... + + +class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): + """What a grid states to the edit surface built over it. + + The state carries the cursor and the selection a target is resolved from, and the grid states + its own actions for a target the surface hands back. Whether the grid owns those gestures at + this moment is the question its key scope already answers, so one predicate serves the keyboard + and the menu alike. + """ + + def owns_keys(self) -> bool: ... + + def input_state(self) -> GridInputState[CursorT, RegionT]: ... + + def add_action_items(self, target: TargetT_contra) -> None: ... + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" + + +class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): + """A sequencer grid as the menu bar's Edit menu reaches it. + + The menu bar asks the surface for the actions of whichever grid holds the cursor, and the + surface asks that grid to build them for the target the cursor names. Both grids reach the + Edit menu through one implementation, so the menu states what the next key press would. + + It also prints the clipboard four, which are the actions every grid carries: the words come + from the shared context vocabulary and the accelerators from the grid's own three bindings, so + a grid states only which keys it answers to and the items read the same in either. + """ + + def __init__( + self, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + blocks: BlockGestures[RegionT, CellT], + target: TargetFactory[CursorT, RegionT, TargetT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> None: + self._grid = grid + self._blocks = blocks + self._target = target + self._shortcuts = shortcuts + self._block_shortcuts = block_shortcuts + self._labels = labels + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._grid.owns_keys() + + def build_edit_actions(self) -> None: + """Builds the grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + target = self.cursor_target() + if target is not None: + self._grid.add_action_items(target) + + def target_at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on. + + The block is the selection the cell falls inside, or the cell alone, so a menu raised + within a selection reaches the whole of it and one raised elsewhere reaches what it names. + """ + return self._target( + cell=cell, + region=self._grid.input_state().region_at(cell), + ) + + def cursor_target(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._grid.input_state().cursor + if cursor is None: + return None + + return self.target_at(cursor) + + def copy(self) -> None: + self._at_cursor(self._blocks.copy_at) + + def cut(self) -> None: + self._at_cursor(self._blocks.cut_at) + + def delete(self) -> None: + self._at_cursor(self._blocks.delete_at) + + def paste(self) -> None: + self._at_cursor(self._blocks.paste_at) + + def can_paste(self) -> bool: + """Whether a block stands ready for a paste to write.""" + return self._blocks.can_paste() + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self.cursor_target() + if target is not None: + gesture(target) + + def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + """ + dpg.add_menu_item( + label=self._labels[ContextElements.COPY], + shortcut=self._shortcuts.display(self._block_shortcuts.copy), + callback=lambda: self._blocks.copy_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.CUT], + shortcut=self._shortcuts.display(self._block_shortcuts.cut), + callback=lambda: self._blocks.cut_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.PASTE], + shortcut=self._shortcuts.display(self._block_shortcuts.paste), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.DELETE], + callback=lambda: self._blocks.delete_at(target), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index f651783f..a465b85d 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -2,8 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerOrderElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager @@ -47,6 +45,11 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import ( + BlockShortcuts, + GridEditSurface, + clipboard_labels, +) from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -97,6 +100,7 @@ OnBlockRegionCallback = Callable[[OrderRegion], None] OnPasteBlockCallback = Callable[[OrderCell], None] CanPasteBlockQuery = Callable[[], bool] +OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget] MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, @@ -185,6 +189,18 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) + self._surface: OrderEditSurface = GridEditSurface( + grid=self, + blocks=self._blocks, + target=OrderTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.ORDER_COPY_BLOCK, + cut=ShortcutId.ORDER_CUT_BLOCK, + paste=ShortcutId.ORDER_PASTE_BLOCK, + ), + labels=clipboard_labels(language_manager), + ) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) self._load_context_labels(language_manager) @@ -219,10 +235,6 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) - self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) - self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) - self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) - self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -1012,7 +1024,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: - target = self._target_at(OrderCursor(generator, position)) + target = self._surface.target_at(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1023,82 +1035,34 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_action_items(target) - - def _target_at(self, cell: OrderCursor) -> OrderTarget: - """The cell a set of actions is raised on, paired with the block those actions act on.""" - return OrderTarget( - cell=cell, - region=self._input_state.region_at(cell), - ) - - def cursor_target(self) -> Optional[OrderTarget]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._input_state.cursor - if cursor is None: - return None + self.add_action_items(target) - return self._target_at(cursor) + @property + def edit_surface(self) -> OrderEditSurface: + """This table as the menu bar's Edit menu reaches it.""" + return self._surface - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this table's actions, which it does while it owns keys. + def input_state(self) -> OrderInputState: + """Where the cursor stands and what it has selected, which a target is resolved from.""" + return self._input_state - The menu offers what the next press would reach, so one question decides both. - """ + def owns_keys(self) -> bool: + """Whether the table owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def build_edit_actions(self) -> None: - """Builds this table's whole action set for the cell the cursor stands on. - - The menu bar asks while the table owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._add_action_items(target) - - def _add_action_items(self, target: OrderTarget) -> None: + def add_action_items(self, target: OrderTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. The table states its actions once, and whoever asks for them decides where they are shown: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ - self._add_block_items(target) + self._surface.add_block_items(target) dpg.add_separator() self._add_frame_items(target.cell.position) dpg.add_separator() self._add_move_items(target.cell.position) - def _add_block_items(self, target: OrderTarget) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - Delete prints no key of its own, because ``Del`` empties a selection while one stands and - clears the cell under the cursor otherwise. - """ - dpg.add_menu_item( - label=self._lbl_context_copy, - shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_cut, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_paste, - shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_delete, - callback=lambda: self._blocks.delete_at(target), - ) - def _add_frame_items(self, position: int) -> None: """Builds the frame operations, each acting on the whole frame the target cell sits in.""" dpg.add_menu_item( @@ -1262,13 +1226,13 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.ORDER_COPY_BLOCK: - self._blocks.copy() + self._surface.copy() case ShortcutId.ORDER_CUT_BLOCK: - self._blocks.cut() + self._surface.cut() case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: - self._blocks.delete() + self._surface.delete() case ShortcutId.ORDER_PASTE_BLOCK: - self._blocks.paste() + self._surface.paste() case _: return False diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index f960c4dd..c5b34c2b 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,8 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerTrackerElements, ) @@ -51,6 +49,11 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import ( + BlockShortcuts, + GridEditSurface, + clipboard_labels, +) from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, @@ -121,6 +124,7 @@ OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] CanPasteBlockQuery = Callable[[], bool] +TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] VOLUME_FINE_STEP: Final[int] = 1 @@ -262,6 +266,18 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) + self._surface: TrackerEditSurface = GridEditSurface( + grid=self, + blocks=self._blocks, + target=TrackerTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.TRACKER_COPY_BLOCK, + cut=ShortcutId.TRACKER_CUT_BLOCK, + paste=ShortcutId.TRACKER_PASTE_BLOCK, + ), + labels=clipboard_labels(language_manager), + ) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) self._lbl_tracker = self._label( @@ -308,10 +324,6 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) - self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) - self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) - self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) - self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) @@ -1279,7 +1291,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: - target = self._target_at(TrackerCursor(row_index, generator, subcolumn)) + target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1297,48 +1309,29 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_action_items(target) + self.add_action_items(target) - def _target_at(self, cell: TrackerCursor) -> TrackerTarget: - """The cell a set of actions is raised on, paired with the block those actions act on.""" - return TrackerTarget( - cell=cell, - region=self._input_state.region_at(cell), - ) - - def cursor_target(self) -> Optional[TrackerTarget]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._input_state.cursor - if cursor is None: - return None - - return self._target_at(cursor) + @property + def edit_surface(self) -> TrackerEditSurface: + """This grid as the menu bar's Edit menu reaches it.""" + return self._surface - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + def input_state(self) -> TrackerInputState: + """Where the cursor stands and what it has selected, which a target is resolved from.""" + return self._input_state - The menu offers what the next press would reach, so one question decides both. - """ + def owns_keys(self) -> bool: + """Whether the grid owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def build_edit_actions(self) -> None: - """Builds this grid's whole action set for the cell the cursor stands on. - - The menu bar asks while the grid owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._add_action_items(target) - - def _add_action_items(self, target: TrackerTarget) -> None: + def add_action_items(self, target: TrackerTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. The grid states its actions once, and whoever asks for them decides where they are shown: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ - self._add_block_items(target) + self._surface.add_block_items(target) dpg.add_separator() self._add_instrument_submenu(target.cell) dpg.add_menu_item( @@ -1352,35 +1345,6 @@ def _add_action_items(self, target: TrackerTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) - def _add_block_items(self, target: TrackerTarget) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - Delete prints no key of its own, because ``Del`` empties a selection while one stands and - clears the cell under the cursor otherwise. - """ - dpg.add_menu_item( - label=self._lbl_context_copy, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_cut, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_paste, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_delete, - callback=lambda: self._blocks.delete_at(target), - ) - def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () @@ -1586,13 +1550,13 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._blocks.copy() + self._surface.copy() case ShortcutId.TRACKER_CUT_BLOCK: - self._blocks.cut() + self._surface.cut() case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: - self._blocks.delete() + self._surface.delete() case ShortcutId.TRACKER_PASTE_BLOCK: - self._blocks.paste() + self._surface.paste() case _: return False @@ -1629,7 +1593,7 @@ def _adjust_at_cursor( finished typing, the rule the block gestures follow as well. """ self.commit_entry() - target = self.cursor_target() + target = self._surface.cursor_target() if target is not None: self.call(hook, target.region, delta) diff --git a/tests/suite/grid.py b/tests/suite/grid.py new file mode 100644 index 00000000..17737c2d --- /dev/null +++ b/tests/suite/grid.py @@ -0,0 +1,44 @@ +from typing import Any, Dict, Final + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.surface import BlockShortcuts, GridEditSurface +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + +CLIPBOARD_LABELS: Final[Dict[ContextElements, str]] = { + ContextElements.COPY: "Copy", + ContextElements.CUT: "Cut", + ContextElements.PASTE: "Paste", + ContextElements.DELETE: "Delete", +} + +TRACKER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts( + copy=ShortcutId.TRACKER_COPY_BLOCK, + cut=ShortcutId.TRACKER_CUT_BLOCK, + paste=ShortcutId.TRACKER_PASTE_BLOCK, +) + +ORDER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts( + copy=ShortcutId.ORDER_COPY_BLOCK, + cut=ShortcutId.ORDER_CUT_BLOCK, + paste=ShortcutId.ORDER_PASTE_BLOCK, +) + + +def attach_edit_surface( + panel: Any, + block_shortcuts: BlockShortcuts, + target: Any, +) -> None: + """Gives a hand-built grid panel the edit surface its menus and its cursor's target run through. + + A case that builds a panel without its constructor supplies the collaborators the panel would + have composed, and this is the one that resolves a target and prints the clipboard items. + """ + panel._surface = GridEditSurface( + grid=panel, + blocks=panel._blocks, + target=target, + shortcuts=panel._shortcuts, + block_shortcuts=block_shortcuts, + labels=CLIPBOARD_LABELS, + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py index e41bad8f..348eaeef 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -16,110 +16,64 @@ class _Target: anchor: str -CURSOR_TARGET: Final[_Target] = _Target(region="cursor block", anchor="cursor cell") NAMED_TARGET: Final[_Target] = _Target(region="named block", anchor="named cell") class _Grid: - """A grid recording what it was asked to do, in the order it was asked. - - The entry it settles and the hooks it announces through land in one list, so a test reads - both what a gesture reached and when the grid committed what was being typed. - """ - - def __init__( - self, - *, - target: Optional[_Target] = None, - can_paste: bool = True, - ) -> None: + """A grid recording the hooks it announced through, in the order it announced them.""" + + def __init__(self, *, can_paste: bool = True) -> None: self.events: List[str] = [] - self._target = target self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste - def commit_entry(self) -> None: - self.events.append("commit") - - def cursor_target(self) -> Optional[_Target]: - return self._target - @dataclass(frozen=True) class GestureCase: - """One of the four gestures, raised at the cursor and on a target a menu named.""" + """One of the four gestures, raised on the target its door named.""" name: str - at_cursor: Callable[[Gestures], None] at_target: Callable[[Gestures, _Target], None] - from_cursor: str - from_target: str + reaches: str CASES: Final[Tuple[GestureCase, ...]] = ( GestureCase( name="copy", - at_cursor=lambda gestures: gestures.copy(), at_target=lambda gestures, target: gestures.copy_at(target), - from_cursor="copy cursor block", - from_target="copy named block", + reaches="copy named block", ), GestureCase( name="cut", - at_cursor=lambda gestures: gestures.cut(), at_target=lambda gestures, target: gestures.cut_at(target), - from_cursor="cut cursor block", - from_target="cut named block", + reaches="cut named block", ), GestureCase( name="delete", - at_cursor=lambda gestures: gestures.delete(), at_target=lambda gestures, target: gestures.delete_at(target), - from_cursor="delete cursor block", - from_target="delete named block", + reaches="delete named block", ), GestureCase( name="paste", - at_cursor=lambda gestures: gestures.paste(), at_target=lambda gestures, target: gestures.paste_at(target), - from_cursor="paste cursor cell", - from_target="paste named cell", + reaches="paste named cell", ), ) -@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) -class TestAtTheCursor: - """A key press acts on the target the cursor names, once the entry being typed has landed.""" - - def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: - grid = _Grid(target=CURSOR_TARGET) - - case.at_cursor(BlockGestures(grid=grid)) - - assert grid.events == ["commit", case.from_cursor] - - def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: - grid = _Grid(target=None) - - case.at_cursor(BlockGestures(grid=grid)) - - assert grid.events == ["commit"] - - @pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) class TestOnANamedTarget: - """A menu item acts on the target it was built for, wherever the cursor happens to stand.""" + """Each door names the target it acts on, and the gesture reaches exactly that block.""" def test_a_gesture_reaches_the_target_it_was_handed(self, case: GestureCase) -> None: - grid = _Grid(target=CURSOR_TARGET) + grid = _Grid() case.at_target(BlockGestures(grid=grid), NAMED_TARGET) - assert grid.events == [case.from_target] + assert grid.events == [case.reaches] class TestPasteEnablement: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py new file mode 100644 index 00000000..9aacdf1d --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py @@ -0,0 +1,261 @@ +from dataclasses import dataclass +from typing import Any, Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid import surface as surface_module +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import GridEditSurface +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source + +CURSOR_CELL: Final[str] = "cursor cell" +CLICKED_CELL: Final[str] = "clicked cell" + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + + +@dataclass(frozen=True) +class _Target: + """The cell a set of actions was raised on, and the block those actions act on.""" + + cell: str + region: str + + @property + def anchor(self) -> str: + return f"{self.cell} anchor" + + +@dataclass(frozen=True) +class _State: + """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in.""" + + cursor: Optional[str] + + def region_at(self, cell: str) -> str: + return f"{cell} block" + + +def _target_for(cell: str) -> _Target: + return _Target(cell=cell, region=f"{cell} block") + + +CURSOR_TARGET: Final[_Target] = _target_for(CURSOR_CELL) +CLICKED_TARGET: Final[_Target] = _target_for(CLICKED_CELL) + + +class _Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles, the hooks it announces through and the action sets it was asked to build + land in one list, so a case reads both what a gesture reached and when the grid committed what + was being typed. + """ + + def __init__( + self, + *, + cursor: Optional[str] = CURSOR_CELL, + owns: bool = True, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self._cursor = cursor + self._owns = owns + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def owns_keys(self) -> bool: + return self._owns + + def input_state(self) -> _State: + return _State(cursor=self._cursor) + + def add_action_items(self, target: _Target) -> None: + self.events.append(f"actions {target.cell}") + + def commit_entry(self) -> None: + self.events.append("commit") + + +def _surface(grid: _Grid) -> GridEditSurface[str, str, str, _Target]: + return GridEditSurface( + grid=grid, + blocks=BlockGestures(grid=grid), + target=_Target, + shortcuts=shipped_source(), + block_shortcuts=TRACKER_BLOCK_SHORTCUTS, + labels=CLIPBOARD_LABELS, + ) + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures as a key press raises it, at the cursor's own target.""" + + name: str + at_cursor: Callable[[GridEditSurface[str, str, str, _Target]], None] + reaches: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda surface: surface.copy(), + reaches=f"copy {CURSOR_TARGET.region}", + ), + GestureCase( + name="cut", + at_cursor=lambda surface: surface.cut(), + reaches=f"cut {CURSOR_TARGET.region}", + ), + GestureCase( + name="delete", + at_cursor=lambda surface: surface.delete(), + reaches=f"delete {CURSOR_TARGET.region}", + ), + GestureCase( + name="paste", + at_cursor=lambda surface: surface.paste(), + reaches=f"paste {CURSOR_TARGET.anchor}", + ), +) + + +@dataclass +class RecordedItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[RecordedItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + RecordedItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(surface_module.dpg, "add_menu_item", recorded.add_menu_item) + return recorded + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = _Grid() + + case.at_cursor(_surface(grid)) + + assert grid.events == ["commit", case.reaches] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = _Grid(cursor=None) + + case.at_cursor(_surface(grid)) + + assert grid.events == ["commit"] + + +class TestEditActions: + def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: + grid = _Grid() + + _surface(grid).build_edit_actions() + + assert grid.events == [f"actions {CURSOR_CELL}"] + + def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: + """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" + grid = _Grid(cursor=None) + + _surface(grid).build_edit_actions() + + assert grid.events == [] + + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert _surface(_Grid(cursor=None)).cursor_target() is None + + def test_the_cursor_names_its_own_target(self) -> None: + assert _surface(_Grid()).cursor_target() == CURSOR_TARGET + + def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: + """The menu offers what the next press would reach, so one question decides both.""" + assert _surface(_Grid(owns=True)).owns_edit_actions() + assert not _surface(_Grid(owns=False)).owns_edit_actions() + + +class TestBlockItems: + def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert [item.label for item in recorder.items] == [ + CLIPBOARD_LABELS[ContextElements.COPY], + CLIPBOARD_LABELS[ContextElements.CUT], + CLIPBOARD_LABELS[ContextElements.PASTE], + CLIPBOARD_LABELS[ContextElements.DELETE], + ] + + def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: + """Each grid states its own three bindings, and an item prints exactly the one it fires.""" + shortcuts = shipped_source() + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK) + assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK) + assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK) + + def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: + """``Del`` empties a selection while one stands and clears the cell under the cursor + otherwise, so the grid resolves it from the selection rather than from one binding.""" + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert recorder.items[DELETE_ITEM].shortcut == "" + + def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: + """A menu item names its target when it is built, so it reaches that block wherever the + cursor happens to stand.""" + grid = _Grid() + _surface(grid).add_block_items(CLICKED_TARGET) + + for item in recorder.items: + item.callback() + + assert grid.events == [ + f"copy {CLICKED_TARGET.region}", + f"cut {CLICKED_TARGET.region}", + f"paste {CLICKED_TARGET.anchor}", + f"delete {CLICKED_TARGET.region}", + ] + + def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: + _surface(_Grid(can_paste=False)).add_block_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 3dd19e0c..b82dcdae 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -11,6 +11,7 @@ OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel @@ -26,6 +27,11 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from tests.suite.grid import ( + ORDER_BLOCK_SHORTCUTS, + TRACKER_BLOCK_SHORTCUTS, + attach_edit_surface, +) from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -93,6 +99,7 @@ def _panel( panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel @@ -115,6 +122,7 @@ def _order_panel( panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index fa7060d1..2d856a02 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,11 +8,13 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.grid import surface as surface_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.region import ( OrderCell, @@ -23,6 +25,11 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +from tests.suite.grid import ( + ORDER_BLOCK_SHORTCUTS, + TRACKER_BLOCK_SHORTCUTS, + attach_edit_surface, +) from tests.suite.shortcuts import shipped_source CLICKED_ROW = 4 @@ -78,13 +85,6 @@ def add_menu_item(self, **kwargs: Any) -> int: return 0 -CLIPBOARD_LABELS = { - "copy": "Copy", - "cut": "Cut", - "paste": "Paste", - "delete": "Delete", -} - TRACKER_LABELS = ( "note_off", "set_instrument", @@ -108,10 +108,7 @@ def add_menu_item(self, **kwargs: Any) -> int: def _labels(panel: Any, names: Tuple[str, ...]) -> None: - """Gives the panel the words its builders print, the clipboard four reading as they ship.""" - for name, text in CLIPBOARD_LABELS.items(): - setattr(panel, f"_lbl_context_{name}", text) - + """Gives the panel the words its own builders print, each reading as the action it names.""" for name in names: setattr(panel, f"_lbl_context_{name}", name) @@ -141,6 +138,7 @@ def _tracker_panel( panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) return panel @@ -161,6 +159,7 @@ def _order_panel( panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) return panel @@ -175,9 +174,11 @@ def _record_into( module: ModuleType, ) -> _MenuRecorder: recorder = _MenuRecorder() - monkeypatch.setattr(module.dpg, "add_menu_item", recorder.add_menu_item) - monkeypatch.setattr(module.dpg, "add_separator", lambda **_kwargs: 0) - monkeypatch.setattr(module.dpg, "menu", _submenu) + for target in (module, surface_module): + monkeypatch.setattr(target.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(target.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(target.dpg, "menu", _submenu) + return recorder @@ -218,7 +219,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._target_at( + target = panel._surface.target_at( TrackerCursor( CLICKED_ROW + 1, GeneratorName.PULSE1, @@ -232,7 +233,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._target_at( + target = panel._surface.target_at( TrackerCursor( CLICKED_ROW, GeneratorName.TRIANGLE, @@ -250,7 +251,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - target = panel._target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) + target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) @@ -260,20 +261,20 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == panel._input_state.region def test_a_grid_holding_no_cursor_names_no_target(self) -> None: - assert _tracker_panel(Gestures()).cursor_target() is None + assert _tracker_panel(Gestures())._surface.cursor_target() is None def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _tracker_panel(Gestures()) cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) panel._input_state = TrackerInputState(cursor=cursor) - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == TrackerRegion( @@ -295,7 +296,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -308,8 +309,8 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord gestures = Gestures() panel = _tracker_panel(gestures) - panel._add_block_items( - panel._target_at( + panel._surface.add_block_items( + panel._surface.target_at( TrackerCursor( CLICKED_ROW, GeneratorName.NOISE, @@ -324,7 +325,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -335,7 +336,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -345,7 +346,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) + target = panel._surface.target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) assert target.region == panel._input_state.region @@ -353,7 +354,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._target_at(_order_cell(None)) + target = panel._surface.target_at(_order_cell(None)) assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), @@ -365,7 +366,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - target = panel._target_at(_order_cell(GeneratorName.PULSE1)) + target = panel._surface.target_at(_order_cell(GeneratorName.PULSE1)) assert target.region == OrderRegion( first_row=PULSE1_ROW, @@ -379,20 +380,20 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == panel._input_state.region def test_a_table_holding_no_cursor_names_no_target(self) -> None: - assert _order_panel(Gestures()).cursor_target() is None + assert _order_panel(Gestures())._surface.cursor_target() is None def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _order_panel(Gestures()) cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) panel._input_state = OrderInputState(cursor=cursor) - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == OrderRegion( @@ -414,7 +415,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -426,7 +427,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(panel._target_at(_order_cell(None))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -434,7 +435,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -445,7 +446,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -459,7 +460,7 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_action_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -471,7 +472,7 @@ def test_the_order_action_set_opens_with_the_clipboard_items( ) -> None: panel = _order_panel(Gestures()) - panel._add_action_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -484,7 +485,7 @@ class TestMenuItemOrder: def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" From 193d60273bcac837faabc4db23a7a995d1ca6c1e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 13:46:57 +0200 Subject: [PATCH 22/28] Added: the samples panel to the Edit menu --- .../coordinators/tabs/sequencer.py | 6 +- .../ui/panels/sequencer/grid/surface.py | 195 ------------- .../panels/sequencer/grid/surface/__init__.py | 0 .../sequencer/grid/surface/clipboard.py | 89 ++++++ .../ui/panels/sequencer/grid/surface/edit.py | 126 +++++++++ .../panels/sequencer/grid/surface/protocol.py | 26 ++ .../panels/sequencer/grid/surface/targets.py | 54 ++++ .../ui/panels/sequencer/order.py | 10 +- .../ui/panels/sequencer/samples.py | 183 +++++++----- .../ui/panels/sequencer/tracker.py | 10 +- tests/suite/grid.py | 5 +- tests/suite/surface.py | 108 ++++++++ .../panels/sequencer/grid/surface/__init__.py | 0 .../sequencer/grid/surface/test_clipboard.py | 98 +++++++ .../sequencer/grid/surface/test_edit.py | 81 ++++++ .../sequencer/grid/surface/test_targets.py | 29 ++ .../ui/panels/sequencer/grid/test_surface.py | 261 ------------------ .../ui/panels/sequencer/test_block_menu.py | 4 +- .../ui/panels/sequencer/test_samples_menu.py | 219 +++++++++++++++ 19 files changed, 964 insertions(+), 540 deletions(-) delete mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py create mode 100644 tests/suite/surface.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py delete mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index f2850168..a28525e0 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1433,11 +1433,13 @@ def player(self) -> AudioPlayerProtocol: @property def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: - """The grids offering editing gestures on the cell they hold a cursor in. + """The panels offering editing gestures on what they hold selected. - The two hold one cursor between them, so the menu bar reaches whichever one has it. + The three hold one selection between them — a cursor in either grid, a row in the samples + list — so the menu bar reaches whichever one has it. """ return ( self._sequencer_tracker_panel.edit_surface, self._sequencer_order_panel.edit_surface, + self._sequencer_samples_panel, ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface.py b/src/sampletones_application/ui/panels/sequencer/grid/surface.py deleted file mode 100644 index 24d25246..00000000 --- a/src/sampletones_application/ui/panels/sequencer/grid/surface.py +++ /dev/null @@ -1,195 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Dict, Final, Generic, Mapping, Optional, Protocol, Tuple, TypeVar - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget -from sampletones_application.ui.panels.sequencer.input.state import GridInputState -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_application.utils.gui.shortcuts.source import ShortcutSource - -CursorT = TypeVar("CursorT") -RegionT = TypeVar("RegionT") -CellT = TypeVar("CellT") -TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) -TargetT_co = TypeVar("TargetT_co", covariant=True) -TargetT_contra = TypeVar("TargetT_contra", contravariant=True) -CursorT_contra = TypeVar("CursorT_contra", contravariant=True) -RegionT_contra = TypeVar("RegionT_contra", contravariant=True) - -CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( - ContextElements.COPY, - ContextElements.CUT, - ContextElements.PASTE, - ContextElements.DELETE, -) - - -def clipboard_labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: - """The words every clipboard item prints, read from the vocabulary each grid shares.""" - return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} - - -@dataclass(frozen=True) -class BlockShortcuts: - """The keys one grid answers the clipboard gestures with. - - Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the - cell under the cursor otherwise, so the grid resolves it from the selection and its item prints - no key. - """ - - copy: ShortcutId - cut: ShortcutId - paste: ShortcutId - - -class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): - """How a grid's own target is built from the pair every target carries.""" - - def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... - - -class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): - """What a grid states to the edit surface built over it. - - The state carries the cursor and the selection a target is resolved from, and the grid states - its own actions for a target the surface hands back. Whether the grid owns those gestures at - this moment is the question its key scope already answers, so one predicate serves the keyboard - and the menu alike. - """ - - def owns_keys(self) -> bool: ... - - def input_state(self) -> GridInputState[CursorT, RegionT]: ... - - def add_action_items(self, target: TargetT_contra) -> None: ... - - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on.""" - - -class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): - """A sequencer grid as the menu bar's Edit menu reaches it. - - The menu bar asks the surface for the actions of whichever grid holds the cursor, and the - surface asks that grid to build them for the target the cursor names. Both grids reach the - Edit menu through one implementation, so the menu states what the next key press would. - - It also prints the clipboard four, which are the actions every grid carries: the words come - from the shared context vocabulary and the accelerators from the grid's own three bindings, so - a grid states only which keys it answers to and the items read the same in either. - """ - - def __init__( - self, - *, - grid: EditGrid[CursorT, RegionT, TargetT], - blocks: BlockGestures[RegionT, CellT], - target: TargetFactory[CursorT, RegionT, TargetT], - shortcuts: ShortcutSource, - block_shortcuts: BlockShortcuts, - labels: Mapping[ContextElements, str], - ) -> None: - self._grid = grid - self._blocks = blocks - self._target = target - self._shortcuts = shortcuts - self._block_shortcuts = block_shortcuts - self._labels = labels - - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. - - The menu offers what the next press would reach, so one question decides both. - """ - return self._grid.owns_keys() - - def build_edit_actions(self) -> None: - """Builds the grid's whole action set for the cell the cursor stands on. - - The menu bar asks while the grid owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._grid.add_action_items(target) - - def target_at(self, cell: CursorT) -> TargetT: - """The cell a set of actions is raised on, paired with the block those actions act on. - - The block is the selection the cell falls inside, or the cell alone, so a menu raised - within a selection reaches the whole of it and one raised elsewhere reaches what it names. - """ - return self._target( - cell=cell, - region=self._grid.input_state().region_at(cell), - ) - - def cursor_target(self) -> Optional[TargetT]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._grid.input_state().cursor - if cursor is None: - return None - - return self.target_at(cursor) - - def copy(self) -> None: - self._at_cursor(self._blocks.copy_at) - - def cut(self) -> None: - self._at_cursor(self._blocks.cut_at) - - def delete(self) -> None: - self._at_cursor(self._blocks.delete_at) - - def paste(self) -> None: - self._at_cursor(self._blocks.paste_at) - - def can_paste(self) -> bool: - """Whether a block stands ready for a paste to write.""" - return self._blocks.can_paste() - - def _at_cursor( - self, - gesture: Callable[[BlockTarget[RegionT, CellT]], None], - ) -> None: - """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. - - Committing ahead of the gesture is what lets a block carry the value the reader has just - finished typing. - """ - self._grid.commit_entry() - target = self.cursor_target() - if target is not None: - gesture(target) - - def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - """ - dpg.add_menu_item( - label=self._labels[ContextElements.COPY], - shortcut=self._shortcuts.display(self._block_shortcuts.copy), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.CUT], - shortcut=self._shortcuts.display(self._block_shortcuts.cut), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.PASTE], - shortcut=self._shortcuts.display(self._block_shortcuts.paste), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.DELETE], - callback=lambda: self._blocks.delete_at(target), - ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py new file mode 100644 index 00000000..56aeb838 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from typing import Dict, Final, Generic, Mapping, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") + +CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) + + +@dataclass(frozen=True) +class BlockShortcuts: + """The keys one grid answers the clipboard gestures with. + + Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the + cell under the cursor otherwise, so the grid resolves it from the selection and its item prints + no key. + """ + + copy: ShortcutId + cut: ShortcutId + paste: ShortcutId + + +class ClipboardItems(Generic[RegionT, CellT]): + """The four items every grid's menus print: copy, cut, paste and delete. + + The words come from the shared context vocabulary and the accelerators from the grid's own + three bindings, so a grid states only which keys it answers to and the items read the same in + either grid. + """ + + def __init__( + self, + *, + blocks: BlockGestures[RegionT, CellT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> None: + self._blocks = blocks + self._shortcuts = shortcuts + self._block_shortcuts = block_shortcuts + self._labels = labels + + @staticmethod + def labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: + """The words every clipboard item prints, read from the vocabulary each grid shares.""" + return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} + + def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the four items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + """ + dpg.add_menu_item( + label=self._labels[ContextElements.COPY], + shortcut=self._shortcuts.display(self._block_shortcuts.copy), + callback=lambda: self._blocks.copy_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.CUT], + shortcut=self._shortcuts.display(self._block_shortcuts.cut), + callback=lambda: self._blocks.cut_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.PASTE], + shortcut=self._shortcuts.display(self._block_shortcuts.paste), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.DELETE], + callback=lambda: self._blocks.delete_at(target), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py new file mode 100644 index 00000000..19c429e1 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any, Callable, Generic, Mapping, Optional, TypeVar + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts, ClipboardItems +from sampletones_application.ui.panels.sequencer.grid.surface.protocol import EditGrid +from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets, TargetFactory +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) + + +class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): + """A sequencer grid as the menu bar's Edit menu and its own keys reach it. + + The menu bar asks the surface for the actions of whichever grid holds the cursor, and the + surface asks that grid to build them for the target the cursor names. A key press acts on that + same target, so one place resolves what the cursor stands on and every door agrees on it. + + Both grids reach the Edit menu through one implementation, so the menu states what the next key + press would. + """ + + def __init__( + self, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + targets: CursorTargets[CursorT, RegionT, TargetT], + clipboard: ClipboardItems[RegionT, CellT], + blocks: BlockGestures[RegionT, CellT], + ) -> None: + self._grid = grid + self._targets = targets + self._clipboard = clipboard + self._blocks = blocks + + @classmethod + def build( + cls, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + blocks: BlockGestures[RegionT, CellT], + target: TargetFactory[CursorT, RegionT, TargetT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> GridEditSurface[CursorT, RegionT, CellT, TargetT]: + """Composes the surface a grid states itself through, from the parts that grid supplies. + + A grid names its own target type, the three keys its clipboard items print and the gestures + its hooks answer; the collaborators built from those are the same in either grid. + """ + return cls( + grid=grid, + targets=CursorTargets( + state=grid.input_state, + target=target, + ), + clipboard=ClipboardItems( + blocks=blocks, + shortcuts=shortcuts, + block_shortcuts=block_shortcuts, + labels=labels, + ), + blocks=blocks, + ) + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._grid.owns_keys() + + def build_edit_actions(self) -> None: + """Builds the grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + target = self.cursor_target() + if target is not None: + self._grid.add_action_items(target) + + def target_at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return self._targets.at(cell) + + def cursor_target(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + return self._targets.at_cursor() + + def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the clipboard items, acting on the block the actions were raised on.""" + self._clipboard.add_items(target) + + def copy(self) -> None: + self._at_cursor(self._blocks.copy_at) + + def cut(self) -> None: + self._at_cursor(self._blocks.cut_at) + + def delete(self) -> None: + self._at_cursor(self._blocks.delete_at) + + def paste(self) -> None: + self._at_cursor(self._blocks.paste_at) + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self.cursor_target() + if target is not None: + gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py new file mode 100644 index 00000000..a5e84734 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py @@ -0,0 +1,26 @@ +from typing import Protocol, TypeVar + +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +TargetT_contra = TypeVar("TargetT_contra", contravariant=True) + + +class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): + """What a grid states to the edit surface built over it. + + The state carries the cursor and the selection a target is resolved from, and the grid states + its own actions for a target the surface hands back. Whether the grid owns those gestures at + this moment is the question its key scope already answers, so one predicate serves the keyboard + and the menu alike. + """ + + def owns_keys(self) -> bool: ... + + def input_state(self) -> GridInputState[CursorT, RegionT]: ... + + def add_action_items(self, target: TargetT_contra) -> None: ... + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py new file mode 100644 index 00000000..bd52e15f --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py @@ -0,0 +1,54 @@ +from typing import Any, Callable, Generic, Optional, Protocol, TypeVar + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockTarget +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) +TargetT_co = TypeVar("TargetT_co", covariant=True) +CursorT_contra = TypeVar("CursorT_contra", contravariant=True) +RegionT_contra = TypeVar("RegionT_contra", contravariant=True) + + +class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): + """How a grid's own target is built from the pair every target carries.""" + + def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... + + +class CursorTargets(Generic[CursorT, RegionT, TargetT]): + """Which block a cell reaches, in the grid whose state names the selection. + + Every door raises its actions on a target, and all three resolve one the same way: the cell is + paired with the block it falls inside. Reading the state afresh on each call is what keeps the + pair current, since a grid rebinds a frozen state on every edit. + """ + + def __init__( + self, + *, + state: Callable[[], GridInputState[CursorT, RegionT]], + target: TargetFactory[CursorT, RegionT, TargetT], + ) -> None: + self._state = state + self._target = target + + def at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on. + + The block is the selection the cell falls inside, or the cell alone, so a menu raised + within a selection reaches the whole of it and one raised elsewhere reaches what it names. + """ + return self._target( + cell=cell, + region=self._state().region_at(cell), + ) + + def at_cursor(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._state().cursor + if cursor is None: + return None + + return self.at(cursor) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index a465b85d..6453f737 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -45,11 +45,11 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import ( +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, - GridEditSurface, - clipboard_labels, + ClipboardItems, ) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -189,7 +189,7 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) - self._surface: OrderEditSurface = GridEditSurface( + self._surface: OrderEditSurface = GridEditSurface.build( grid=self, blocks=self._blocks, target=OrderTarget, @@ -199,7 +199,7 @@ def __init__( cut=ShortcutId.ORDER_CUT_BLOCK, paste=ShortcutId.ORDER_PASTE_BLOCK, ), - labels=clipboard_labels(language_manager), + labels=ClipboardItems.labels(language_manager), ) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 341b56c1..1ccc4c98 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -1,7 +1,12 @@ +from dataclasses import dataclass from typing import Callable, Dict, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag @@ -43,12 +48,40 @@ FROZEN_HEADER_ROWS: Final[int] = 1 -MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { - ShortcutId.SAMPLES_MOVE_SAMPLE_UP: MoveDirection.PREVIOUS, - ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN: MoveDirection.NEXT, - ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP: MoveDirection.FIRST, - ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM: MoveDirection.LAST, -} + +@dataclass(frozen=True) +class SampleMove: + """One of the four moves, as its key press and its menu item each name it.""" + + element: SequencerInstrumentsElements + shortcut: ShortcutId + direction: MoveDirection + + +SAMPLE_MOVES: Final[Tuple[SampleMove, ...]] = ( + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_UP, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP, + direction=MoveDirection.PREVIOUS, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_DOWN, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN, + direction=MoveDirection.NEXT, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_TOP, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP, + direction=MoveDirection.FIRST, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_BOTTOM, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM, + direction=MoveDirection.LAST, + ), +) + +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in SAMPLE_MOVES} class GUISequencerSamplesPanel(GUIPanel): @@ -92,7 +125,7 @@ def __init__( def create_panel(self, parent: str) -> None: with self._collapsible_card( parent, - self._language_manager["sequencer.instruments.label.instruments_text"], + self._label(self._language_manager, SequencerInstrumentsElements.INSTRUMENTS_TEXT), glyph=self._glyphs.headers.samples, ): self._create_samples_table() @@ -142,17 +175,17 @@ def _create_samples_table(self) -> None: ), ): dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_id"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_ID), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.id, ) dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_name"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_NAME), width_stretch=True, init_width_or_weight=self._layout.table_cells.instrument.name, ) dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_loop"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_LOOP), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.loop, ) @@ -489,77 +522,91 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: if entry is None: return + target = SampleSelection( + sample_id=sample_id, + position=position, + name=entry.name, + ) with context_menu(): - header = dpg.add_text(display_sample_label(position, entry.name)) + header = dpg.add_text(target.label) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() add_play_menu_item( - self._language_manager["global.context.label.play"], + context_label(self._language_manager, ContextElements.PLAY), lambda: self.call( self.on_play_requested, sample_id, ), ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_edit"], - callback=lambda: self.call(self.on_sample_edit_requested, sample_id), - ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_rename"], - callback=lambda: self._start_rename(sample_id), - ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_duplicate"], - callback=lambda: self.call(self.on_duplicate_requested, sample_id), - ) dpg.add_separator() - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_remove"], - callback=lambda: self.call(self.on_remove_requested, sample_id), - ) - dpg.add_separator() - count = len(self._entries) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_up"], - sample_id, - position, - count, - MoveDirection.PREVIOUS, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_down"], - sample_id, - position, - count, - MoveDirection.NEXT, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_top"], - sample_id, - position, - count, - MoveDirection.FIRST, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_bottom"], - sample_id, - position, - count, - MoveDirection.LAST, - ) + self.add_action_items(target) + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this panel's actions, which it does while it holds a sample. + + The menu offers what the next press would reach, so the key scope decides it, and the + selection those keys act on is the one the actions are built for. + """ + return self._keys_active() and self.selection is not None + + def build_edit_actions(self) -> None: + """Builds the panel's whole action set for the sample the selection holds.""" + selection = self.selection + if selection is not None: + self.add_action_items(selection) + + def add_action_items(self, target: SampleSelection) -> None: + """Builds every action a sample offers, in the order each menu prints them. + + The panel states its actions once, and whoever asks for them decides where they are shown: + the row menu asks for the sample a pointer landed on, and the menu bar asks for the one the + selection holds. An action added here reaches both, printing the key it answers to. + """ + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_EDIT), + callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id), + ) + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_RENAME), + shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), + callback=lambda: self._start_rename(target.sample_id), + ) + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_DUPLICATE), + callback=lambda: self.call(self.on_duplicate_requested, target.sample_id), + ) + dpg.add_separator() + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_REMOVE), + shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), + callback=lambda: self.call(self.on_remove_requested, target.sample_id), + ) + dpg.add_separator() + for move in SAMPLE_MOVES: + self._add_move_item(move, target) def _add_move_item( self, - label: str, - sample_id: str, - position: int, - count: int, - direction: MoveDirection, + move: SampleMove, + target: SampleSelection, ) -> None: - """Add a move item, greyed out (disabled) when the move would have no effect.""" - target = direction.target(position, count) + """Builds one move item, offered while the move carries the sample somewhere new.""" + position = move.direction.target(target.position, len(self._entries)) dpg.add_menu_item( - label=label, - enabled=target is not None, - callback=lambda: self.call(self.on_move_requested, sample_id, target), + label=self._label(self._language_manager, move.element), + shortcut=self._shortcuts.display(move.shortcut), + enabled=position is not None, + callback=lambda: self.call(self.on_move_requested, target.sample_id, position), ) + + @staticmethod + def _label( + language_manager: LanguageManager, + element: SequencerInstrumentsElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.INSTRUMENTS, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index c5b34c2b..ef623556 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -49,11 +49,11 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import ( +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, - GridEditSurface, - clipboard_labels, + ClipboardItems, ) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, @@ -266,7 +266,7 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) - self._surface: TrackerEditSurface = GridEditSurface( + self._surface: TrackerEditSurface = GridEditSurface.build( grid=self, blocks=self._blocks, target=TrackerTarget, @@ -276,7 +276,7 @@ def __init__( cut=ShortcutId.TRACKER_CUT_BLOCK, paste=ShortcutId.TRACKER_PASTE_BLOCK, ), - labels=clipboard_labels(language_manager), + labels=ClipboardItems.labels(language_manager), ) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) diff --git a/tests/suite/grid.py b/tests/suite/grid.py index 17737c2d..d20ccbb4 100644 --- a/tests/suite/grid.py +++ b/tests/suite/grid.py @@ -1,7 +1,8 @@ from typing import Any, Dict, Final from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.ui.panels.sequencer.grid.surface import BlockShortcuts, GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.utils.gui.shortcuts.ids import ShortcutId CLIPBOARD_LABELS: Final[Dict[ContextElements, str]] = { @@ -34,7 +35,7 @@ def attach_edit_surface( A case that builds a panel without its constructor supplies the collaborators the panel would have composed, and this is the one that resolves a target and prints the clipboard items. """ - panel._surface = GridEditSurface( + panel._surface = GridEditSurface.build( grid=panel, blocks=panel._blocks, target=target, diff --git a/tests/suite/surface.py b/tests/suite/surface.py new file mode 100644 index 00000000..e13a4227 --- /dev/null +++ b/tests/suite/surface.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass +from typing import Callable, Final, List, Optional + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ClipboardItems +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source + +CURSOR_CELL: Final[str] = "cursor cell" +CLICKED_CELL: Final[str] = "clicked cell" + + +@dataclass(frozen=True) +class Target: + """The cell a set of actions was raised on, and the block those actions act on.""" + + cell: str + region: str + + @classmethod + def at(cls, cell: str) -> "Target": + """The target a cell resolves to, which is the pair the fake state states for it.""" + return cls(cell=cell, region=f"{cell} block") + + @property + def anchor(self) -> str: + return f"{self.cell} anchor" + + +@dataclass(frozen=True) +class State(GridInputState[str, str]): + """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in. + + A cell's own block reads as the cell it was bounded from, so a target names the cell that + raised it and the block it resolved to in one readable pair. + """ + + def _region_between(self, first: str, _second: str) -> str: + return f"{first} block" + + def _covers(self, region: str, cell: str) -> bool: + return region == f"{cell} block" + + +CURSOR_TARGET: Final[Target] = Target.at(CURSOR_CELL) +CLICKED_TARGET: Final[Target] = Target.at(CLICKED_CELL) + + +class Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles, the hooks it announces through and the action sets it was asked to build + land in one list, so a case reads both what a gesture reached and when the grid committed what + was being typed. + """ + + def __init__( + self, + *, + cursor: Optional[str] = CURSOR_CELL, + owns: bool = True, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self.cursor = cursor + self._owns = owns + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def owns_keys(self) -> bool: + return self._owns + + def input_state(self) -> State: + return State(cursor=self.cursor) + + def add_action_items(self, target: Target) -> None: + self.events.append(f"actions {target.cell}") + + def commit_entry(self) -> None: + self.events.append("commit") + + def cursor_targets(self) -> CursorTargets[str, str, Target]: + """The target resolver over this grid, which is what each door asks for a cell's block.""" + return CursorTargets(state=self.input_state, target=Target) + + def clipboard_items(self) -> ClipboardItems[str, str]: + """The four items over this grid, printing the tracker's own keys and stand-in words.""" + return ClipboardItems( + blocks=BlockGestures(grid=self), + shortcuts=shipped_source(), + block_shortcuts=TRACKER_BLOCK_SHORTCUTS, + labels=CLIPBOARD_LABELS, + ) + + def edit_surface(self) -> GridEditSurface[str, str, str, Target]: + """The surface over this grid, composed from the collaborators a real panel supplies.""" + return GridEditSurface( + grid=self, + targets=self.cursor_targets(), + clipboard=self.clipboard_items(), + blocks=BlockGestures(grid=self), + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py new file mode 100644 index 00000000..5d606de1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Any, Callable, List + +import pytest + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source +from tests.suite.surface import CLICKED_TARGET, Grid + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + + +@dataclass +class RecordedItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[RecordedItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + RecordedItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(clipboard_module.dpg, "add_menu_item", recorded.add_menu_item) + return recorded + + +class TestClipboardItems: + def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert [item.label for item in recorder.items] == [ + CLIPBOARD_LABELS[ContextElements.COPY], + CLIPBOARD_LABELS[ContextElements.CUT], + CLIPBOARD_LABELS[ContextElements.PASTE], + CLIPBOARD_LABELS[ContextElements.DELETE], + ] + + def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: + """Each grid states its own three bindings, and an item prints exactly the one it fires.""" + shortcuts = shipped_source() + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.copy) + assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.cut) + assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.paste) + + def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: + """``Del`` empties a selection while one stands and clears the cell under the cursor + otherwise, so the grid resolves it from the selection rather than from one binding.""" + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert recorder.items[DELETE_ITEM].shortcut == "" + + def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: + """A menu item names its target when it is built, so it reaches that block wherever the + cursor happens to stand.""" + grid = Grid() + grid.clipboard_items().add_items(CLICKED_TARGET) + + for item in recorder.items: + item.callback() + + assert grid.events == [ + f"copy {CLICKED_TARGET.region}", + f"cut {CLICKED_TARGET.region}", + f"paste {CLICKED_TARGET.anchor}", + f"delete {CLICKED_TARGET.region}", + ] + + def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: + Grid(can_paste=False).clipboard_items().add_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py new file mode 100644 index 00000000..fc1db3a1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from typing import Callable, Final, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from tests.suite.surface import CURSOR_CELL, CURSOR_TARGET, Grid, Target + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures as a key press raises it, at the cursor's own target.""" + + name: str + at_cursor: Callable[[GridEditSurface[str, str, str, Target]], None] + reaches: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda surface: surface.copy(), + reaches=f"copy {CURSOR_TARGET.region}", + ), + GestureCase( + name="cut", + at_cursor=lambda surface: surface.cut(), + reaches=f"cut {CURSOR_TARGET.region}", + ), + GestureCase( + name="delete", + at_cursor=lambda surface: surface.delete(), + reaches=f"delete {CURSOR_TARGET.region}", + ), + GestureCase( + name="paste", + at_cursor=lambda surface: surface.paste(), + reaches=f"paste {CURSOR_TARGET.anchor}", + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = Grid() + + case.at_cursor(grid.edit_surface()) + + assert grid.events == ["commit", case.reaches] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = Grid(cursor=None) + + case.at_cursor(grid.edit_surface()) + + assert grid.events == ["commit"] + + +class TestEditActions: + def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: + grid = Grid() + + grid.edit_surface().build_edit_actions() + + assert grid.events == [f"actions {CURSOR_CELL}"] + + def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: + """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" + grid = Grid(cursor=None) + + grid.edit_surface().build_edit_actions() + + assert grid.events == [] + + def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: + """The menu offers what the next press would reach, so one question decides both.""" + assert Grid(owns=True).edit_surface().owns_edit_actions() + assert not Grid(owns=False).edit_surface().owns_edit_actions() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py new file mode 100644 index 00000000..51d1e47c --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py @@ -0,0 +1,29 @@ +from tests.suite.surface import CLICKED_CELL, CLICKED_TARGET, CURSOR_TARGET, Grid + + +class TestTargetAtACell: + def test_a_cell_is_paired_with_the_block_it_falls_in(self) -> None: + assert Grid().cursor_targets().at(CLICKED_CELL) == CLICKED_TARGET + + def test_a_cell_away_from_the_cursor_names_its_own_block(self) -> None: + """A menu raised anywhere reaches what it names, so the cursor's cell has no say in it.""" + assert Grid().cursor_targets().at(CLICKED_CELL) != CURSOR_TARGET + + +class TestTargetAtTheCursor: + def test_the_cursor_names_its_own_target(self) -> None: + assert Grid().cursor_targets().at_cursor() == CURSOR_TARGET + + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert Grid(cursor=None).cursor_targets().at_cursor() is None + + def test_the_state_is_read_on_each_call(self) -> None: + """A grid rebinds a frozen state on every edit, so a target resolved once would go stale.""" + grid = Grid() + targets = grid.cursor_targets() + first = targets.at_cursor() + + grid.cursor = CLICKED_CELL + + assert first == CURSOR_TARGET + assert targets.at_cursor() == CLICKED_TARGET diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py deleted file mode 100644 index 9aacdf1d..00000000 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py +++ /dev/null @@ -1,261 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Final, List, Optional, Tuple - -import pytest - -from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.ui.panels.sequencer.grid import surface as surface_module -from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import GridEditSurface -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS -from tests.suite.shortcuts import shipped_source - -CURSOR_CELL: Final[str] = "cursor cell" -CLICKED_CELL: Final[str] = "clicked cell" - -COPY_ITEM = 0 -CUT_ITEM = 1 -PASTE_ITEM = 2 -DELETE_ITEM = 3 - - -@dataclass(frozen=True) -class _Target: - """The cell a set of actions was raised on, and the block those actions act on.""" - - cell: str - region: str - - @property - def anchor(self) -> str: - return f"{self.cell} anchor" - - -@dataclass(frozen=True) -class _State: - """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in.""" - - cursor: Optional[str] - - def region_at(self, cell: str) -> str: - return f"{cell} block" - - -def _target_for(cell: str) -> _Target: - return _Target(cell=cell, region=f"{cell} block") - - -CURSOR_TARGET: Final[_Target] = _target_for(CURSOR_CELL) -CLICKED_TARGET: Final[_Target] = _target_for(CLICKED_CELL) - - -class _Grid: - """A grid recording what it was asked to do, in the order it was asked. - - The entry it settles, the hooks it announces through and the action sets it was asked to build - land in one list, so a case reads both what a gesture reached and when the grid committed what - was being typed. - """ - - def __init__( - self, - *, - cursor: Optional[str] = CURSOR_CELL, - owns: bool = True, - can_paste: bool = True, - ) -> None: - self.events: List[str] = [] - self._cursor = cursor - self._owns = owns - self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") - self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") - self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") - self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") - self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste - - def owns_keys(self) -> bool: - return self._owns - - def input_state(self) -> _State: - return _State(cursor=self._cursor) - - def add_action_items(self, target: _Target) -> None: - self.events.append(f"actions {target.cell}") - - def commit_entry(self) -> None: - self.events.append("commit") - - -def _surface(grid: _Grid) -> GridEditSurface[str, str, str, _Target]: - return GridEditSurface( - grid=grid, - blocks=BlockGestures(grid=grid), - target=_Target, - shortcuts=shipped_source(), - block_shortcuts=TRACKER_BLOCK_SHORTCUTS, - labels=CLIPBOARD_LABELS, - ) - - -@dataclass(frozen=True) -class GestureCase: - """One of the four gestures as a key press raises it, at the cursor's own target.""" - - name: str - at_cursor: Callable[[GridEditSurface[str, str, str, _Target]], None] - reaches: str - - -CASES: Final[Tuple[GestureCase, ...]] = ( - GestureCase( - name="copy", - at_cursor=lambda surface: surface.copy(), - reaches=f"copy {CURSOR_TARGET.region}", - ), - GestureCase( - name="cut", - at_cursor=lambda surface: surface.cut(), - reaches=f"cut {CURSOR_TARGET.region}", - ), - GestureCase( - name="delete", - at_cursor=lambda surface: surface.delete(), - reaches=f"delete {CURSOR_TARGET.region}", - ), - GestureCase( - name="paste", - at_cursor=lambda surface: surface.paste(), - reaches=f"paste {CURSOR_TARGET.anchor}", - ), -) - - -@dataclass -class RecordedItem: - """One item as it was registered, which is the whole of what a reader sees and clicks.""" - - label: str - shortcut: str - enabled: bool - callback: Callable[[], None] - - -class _MenuRecorder: - def __init__(self) -> None: - self.items: List[RecordedItem] = [] - - def add_menu_item(self, **kwargs: Any) -> int: - self.items.append( - RecordedItem( - label=kwargs["label"], - shortcut=kwargs.get("shortcut", ""), - enabled=kwargs.get("enabled", True), - callback=kwargs["callback"], - ) - ) - return 0 - - -@pytest.fixture -def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: - recorded = _MenuRecorder() - monkeypatch.setattr(surface_module.dpg, "add_menu_item", recorded.add_menu_item) - return recorded - - -@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) -class TestAtTheCursor: - """A key press acts on the target the cursor names, once the entry being typed has landed.""" - - def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: - grid = _Grid() - - case.at_cursor(_surface(grid)) - - assert grid.events == ["commit", case.reaches] - - def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: - grid = _Grid(cursor=None) - - case.at_cursor(_surface(grid)) - - assert grid.events == ["commit"] - - -class TestEditActions: - def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: - grid = _Grid() - - _surface(grid).build_edit_actions() - - assert grid.events == [f"actions {CURSOR_CELL}"] - - def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: - """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" - grid = _Grid(cursor=None) - - _surface(grid).build_edit_actions() - - assert grid.events == [] - - def test_a_grid_holding_no_cursor_names_no_target(self) -> None: - assert _surface(_Grid(cursor=None)).cursor_target() is None - - def test_the_cursor_names_its_own_target(self) -> None: - assert _surface(_Grid()).cursor_target() == CURSOR_TARGET - - def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: - """The menu offers what the next press would reach, so one question decides both.""" - assert _surface(_Grid(owns=True)).owns_edit_actions() - assert not _surface(_Grid(owns=False)).owns_edit_actions() - - -class TestBlockItems: - def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert [item.label for item in recorder.items] == [ - CLIPBOARD_LABELS[ContextElements.COPY], - CLIPBOARD_LABELS[ContextElements.CUT], - CLIPBOARD_LABELS[ContextElements.PASTE], - CLIPBOARD_LABELS[ContextElements.DELETE], - ] - - def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: - """Each grid states its own three bindings, and an item prints exactly the one it fires.""" - shortcuts = shipped_source() - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK) - assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK) - assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK) - - def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: - """``Del`` empties a selection while one stands and clears the cell under the cursor - otherwise, so the grid resolves it from the selection rather than from one binding.""" - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert recorder.items[DELETE_ITEM].shortcut == "" - - def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: - """A menu item names its target when it is built, so it reaches that block wherever the - cursor happens to stand.""" - grid = _Grid() - _surface(grid).add_block_items(CLICKED_TARGET) - - for item in recorder.items: - item.callback() - - assert grid.events == [ - f"copy {CLICKED_TARGET.region}", - f"cut {CLICKED_TARGET.region}", - f"paste {CLICKED_TARGET.anchor}", - f"delete {CLICKED_TARGET.region}", - ] - - def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: - _surface(_Grid(can_paste=False)).add_block_items(CLICKED_TARGET) - - assert not recorder.items[PASTE_ITEM].enabled - assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 2d856a02..9f4880ae 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,8 +8,8 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.grid import surface as surface_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -174,7 +174,7 @@ def _record_into( module: ModuleType, ) -> _MenuRecorder: recorder = _MenuRecorder() - for target in (module, surface_module): + for target in (module, clipboard_module): monkeypatch.setattr(target.dpg, "add_menu_item", recorder.add_menu_item) monkeypatch.setattr(target.dpg, "add_separator", lambda **_kwargs: 0) monkeypatch.setattr(target.dpg, "menu", _submenu) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py new file mode 100644 index 00000000..c2a64353 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -0,0 +1,219 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.ui.panels.sequencer import samples as samples_module +from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel +from tests.suite.shortcuts import shipped_source + +ENTRIES: Tuple[SampleEntryViewModel, ...] = ( + SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False), + SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True), + SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False), +) + +SELECTED_ID = "bass-id" +SELECTED_ROW = 1 + +EDIT_ITEM = 0 +RENAME_ITEM = 1 +DUPLICATE_ITEM = 2 +REMOVE_ITEM = 3 +MOVE_UP_ITEM = 4 +MOVE_DOWN_ITEM = 5 +MOVE_TOP_ITEM = 6 +MOVE_BOTTOM_ITEM = 7 + + +@dataclass +class MenuItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +@dataclass +class Requests: + """What each sample hook was handed when its menu item fired.""" + + edited: List[str] = field(default_factory=list) + renamed: List[str] = field(default_factory=list) + duplicated: List[str] = field(default_factory=list) + removed: List[str] = field(default_factory=list) + moved: List[Tuple[str, Optional[int]]] = field(default_factory=list) + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[MenuItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + MenuItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(samples_module.dpg, "add_separator", lambda **_kwargs: 0) + return recorded + + +@dataclass +class SamplesPanelFixture: + """A panel holding a selection, with the calls each menu item makes recorded.""" + + panel: GUISequencerSamplesPanel + requests: Requests + + +def _panel( + monkeypatch: pytest.MonkeyPatch, + *, + selected_row: Optional[int] = SELECTED_ROW, + tab_active: bool = True, + editing: Optional[str] = None, + field_focused: bool = False, +) -> SamplesPanelFixture: + """A samples panel whose menu builder can run with no DearPyGui context behind it.""" + panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) + panel._language_manager = _Labels() + panel._shortcuts = shipped_source() + panel._entries = ENTRIES + panel._selected_sample_id = None if selected_row is None else SELECTED_ID + panel._selected_row = selected_row + panel._editing_sample_id = editing + panel._tab_active = lambda: tab_active + panel._router = _Router(field_focused=field_focused) + + requests = Requests() + panel.on_sample_edit_requested = requests.edited.append + panel.on_duplicate_requested = requests.duplicated.append + panel.on_remove_requested = requests.removed.append + panel.on_move_requested = lambda sample_id, target: requests.moved.append((sample_id, target)) + monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) + return SamplesPanelFixture(panel=panel, requests=requests) + + +class _Labels: + """A language manager printing each key's own element, so an item reads as the action it names.""" + + def __getitem__(self, key: Tuple[Any, ...]) -> str: + return str(key[-1].value) + + +@dataclass(frozen=True) +class _Router: + """The key router as the panel's own scope reads it.""" + + field_focused: bool + + @property + def is_field_focused(self) -> bool: + return self.field_focused + + +class TestActionItems: + def test_the_menu_reads_as_the_sample_actions( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch).panel.build_edit_actions() + + assert [item.label for item in recorder.items] == [ + SequencerInstrumentsElements.CONTEXT_EDIT.value, + SequencerInstrumentsElements.CONTEXT_RENAME.value, + SequencerInstrumentsElements.CONTEXT_DUPLICATE.value, + SequencerInstrumentsElements.CONTEXT_REMOVE.value, + *(move.element.value for move in SAMPLE_MOVES), + ] + + def test_the_items_print_the_keys_the_panel_answers_to( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """The panel has always answered these presses, and an item prints the one it fires.""" + shortcuts = shipped_source() + _panel(monkeypatch).panel.build_edit_actions() + + assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE) + assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE) + assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [ + shortcuts.display(move.shortcut) for move in SAMPLE_MOVES + ] + + def test_the_items_act_on_the_sample_they_were_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + fixture = _panel(monkeypatch) + fixture.panel.build_edit_actions() + + for item in recorder.items: + item.callback() + + assert fixture.requests.edited == [SELECTED_ID] + assert fixture.requests.renamed == [SELECTED_ID] + assert fixture.requests.duplicated == [SELECTED_ID] + assert fixture.requests.removed == [SELECTED_ID] + assert fixture.requests.moved == [ + (SELECTED_ID, SELECTED_ROW - 1), + (SELECTED_ID, SELECTED_ROW + 1), + (SELECTED_ID, 0), + (SELECTED_ID, len(ENTRIES) - 1), + ] + + def test_a_move_with_nowhere_to_go_is_greyed_out( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, selected_row=0).panel.build_edit_actions() + + assert not recorder.items[MOVE_UP_ITEM].enabled + assert not recorder.items[MOVE_TOP_ITEM].enabled + assert recorder.items[MOVE_DOWN_ITEM].enabled + assert recorder.items[MOVE_BOTTOM_ITEM].enabled + + +class TestEditActions: + def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert _panel(monkeypatch).panel.owns_edit_actions() + + def test_a_panel_holding_no_selection_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The grids and this panel hold one selection between them, so one of them answers.""" + assert not _panel(monkeypatch, selected_row=None).panel.owns_edit_actions() + + def test_a_panel_on_a_tab_behind_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection outlives a move to another tab, and the Edit menu follows the tab in front.""" + assert not _panel(monkeypatch, tab_active=False).panel.owns_edit_actions() + + def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert not _panel(monkeypatch, field_focused=True).panel.owns_edit_actions() + + def test_a_panel_holding_no_selection_builds_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, selected_row=None).panel.build_edit_actions() + + assert recorder.items == [] From 2da4f2c7594c1d5017995589b67334b964df969b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 14:30:15 +0200 Subject: [PATCH 23/28] Added: selecting a whole grid, column and subcolumn --- .../categories/elements/sequencer.py | 5 + .../categories/elements/settings.py | 5 + .../ui/panels/sequencer/input/order.py | 31 ++++ .../ui/panels/sequencer/input/state.py | 9 + .../ui/panels/sequencer/input/tracker.py | 44 +++++ .../ui/panels/sequencer/order.py | 51 ++++++ .../ui/panels/sequencer/tracker.py | 71 ++++++++ .../utils/gui/shortcuts/ids.py | 5 + .../keybindings/default.yaml | 9 +- src/sampletones_config/keybindings/macos.yaml | 9 +- src/sampletones_config/lang/en.yaml | 10 ++ .../panels/sequencer/input/test_grid_input.py | 27 +++ .../sequencer/input/test_order_input.py | 43 +++++ .../sequencer/input/test_tracker_input.py | 57 +++++- .../ui/panels/sequencer/test_block_menu.py | 118 ++++++++++++- .../ui/panels/sequencer/test_order_keys.py | 4 +- .../panels/sequencer/test_selection_keys.py | 74 +++++++- .../utils/gui/shortcuts/test_manager.py | 11 +- .../utils/gui/shortcuts/test_shipped.py | 164 ++++++++++++++++++ 19 files changed, 729 insertions(+), 18 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 51db2e5d..448a0264 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,6 +30,9 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" + CONTEXT_SELECT_ALL = "context_select_all" + CONTEXT_SELECT_COLUMN = "context_select_column" + CONTEXT_SELECT_SUBCOLUMN = "context_select_subcolumn" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -62,6 +65,8 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" + CONTEXT_SELECT_ALL = "context_select_all" + CONTEXT_SELECT_ROW = "context_select_row" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 7b96b8e0..676463a1 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -95,6 +95,8 @@ class KeybindingActionElements(AbstractElement): ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" + ORDER_SELECT_ALL = "order_select_all" + ORDER_SELECT_ROW = "order_select_row" ORDER_COPY_BLOCK = "order_copy_block" ORDER_CUT_BLOCK = "order_cut_block" ORDER_PASTE_BLOCK = "order_paste_block" @@ -126,6 +128,9 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" + TRACKER_SELECT_ALL = "tracker_select_all" + TRACKER_SELECT_COLUMN = "tracker_select_column" + TRACKER_SELECT_SUBCOLUMN = "tracker_select_subcolumn" TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_CUT_BLOCK = "tracker_cut_block" TRACKER_PASTE_BLOCK = "tracker_paste_block" diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 8f702e60..43333b5b 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -50,6 +50,37 @@ def _region_between( def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool: return region.covers(cell.generator, cell.position) + def select_all(self, position_count: int) -> OrderInputState: + """Selects the whole order: every channel row, across every position it holds.""" + return self._select_rows(CHANNEL_AXIS[0], CHANNEL_AXIS[-1], position_count) + + def select_row( + self, + cell: OrderCursor, + position_count: int, + ) -> OrderInputState: + """Selects the row ``cell`` stands in: that channel, across every position. + + The master row is an ordinary member of the axis here, so selecting it selects a row the + way selecting a channel does. + """ + return self._select_rows(cell.generator, cell.generator, position_count) + + def _select_rows( + self, + first_generator: Optional[GeneratorName], + last_generator: Optional[GeneratorName], + position_count: int, + ) -> OrderInputState: + """Selects a run of rows across the whole order, the cursor landing on its far corner.""" + if position_count == 0: + return self + + return self.select_between( + OrderCursor(first_generator, 0), + OrderCursor(last_generator, position_count - 1), + ) + def extend_position( self, value: int, diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index cb5f3373..fa27781d 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -79,6 +79,15 @@ def extend_to(self, cursor: CursorT) -> Self: anchor=self.anchor if self.anchor is not None else self.cursor, ) + def select_between(self, anchor: CursorT, cursor: CursorT) -> Self: + """Stands a selection between two cells, the cursor landing on the second. + + A select gesture names a shape by the two corners bounding it, and leaves the cursor on the + far one: the next extending press then grows or shrinks the selection from the edge the + reader has just reached. + """ + return type(self)(cursor=cursor, pending="", anchor=anchor) + def cancel(self) -> Self: """Drops a partial entry and any selection, which is what Escape asks of a grid.""" return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py index 08f5a528..665e0c51 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -14,6 +14,7 @@ SLOT_COUNT, SUBCOLUMNS, TrackerSlot, + column_slot_base, slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn @@ -97,6 +98,49 @@ def _region_between( def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool: return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn)) + def select_all(self, row_count: int) -> TrackerInputState: + """Selects the whole frame: every row of it, across every slot the axis lays out.""" + return self._select_slots(0, SLOT_COUNT - 1, row_count) + + def select_column( + self, + cell: TrackerCursor, + row_count: int, + ) -> TrackerInputState: + """Selects the column ``cell`` stands in: every row of it, across that column's subcolumns. + + The sample column is an ordinary member of the axis here, so selecting it selects a column + the way selecting a channel does. + """ + base = column_slot_base(cell.generator) + return self._select_slots(base, base + len(SUBCOLUMNS) - 1, row_count) + + def select_subcolumn( + self, + cell: TrackerCursor, + row_count: int, + ) -> TrackerInputState: + """Selects the subcolumn ``cell`` stands in: every row of it, at that one slot.""" + slot = TrackerSlot(cell.generator, cell.subcolumn).flat_index + return self._select_slots(slot, slot, row_count) + + def _select_slots( + self, + first_slot: int, + last_slot: int, + row_count: int, + ) -> TrackerInputState: + """Selects a run of slots down the whole frame, the cursor landing on its far corner.""" + if row_count == 0: + return self + + first = slot_from_flat(first_slot) + last = slot_from_flat(last_slot) + return self.select_between( + TrackerCursor(0, first.generator, first.subcolumn), + TrackerCursor(row_count - 1, last.generator, last.subcolumn), + ) + def extend_row( self, value: int, diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 6453f737..5e629f86 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -235,6 +235,8 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) + self._lbl_context_select_all = label(SequencerOrderElements.CONTEXT_SELECT_ALL) + self._lbl_context_select_row = label(SequencerOrderElements.CONTEXT_SELECT_ROW) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -1057,12 +1059,32 @@ def add_action_items(self, target: OrderTarget) -> None: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ + self._add_select_items(target.cell) + dpg.add_separator() self._surface.add_block_items(target) dpg.add_separator() self._add_frame_items(target.cell.position) dpg.add_separator() self._add_move_items(target.cell.position) + def _add_select_items(self, cell: OrderCursor) -> None: + """Builds the two shapes a selection takes, the whole order and one row of it. + + Each item fires the gesture its key fires, on the cell the menu names: a row selected from + a cell menu is the row that cell stands in, and one selected from the menu bar is the row + the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_context_select_all, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), + callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_row, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), + callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ROW, cell), + ) + def _add_frame_items(self, position: int) -> None: """Builds the frame operations, each acting on the whole frame the target cell sits in.""" dpg.add_menu_item( @@ -1165,6 +1187,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._select_shape(shortcut_id, cursor): + return True + if self._block_action(shortcut_id): return True @@ -1217,6 +1242,32 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _select_shape( + self, + shortcut_id: ShortcutId, + cell: OrderCursor, + ) -> bool: + """Selects a rectangle of the table, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.ORDER_SELECT_ALL: + self._select_all() + case ShortcutId.ORDER_SELECT_ROW: + self._select_row(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._apply_state(self._committed_state().select_all(self._position_count)) + + def _select_row(self, cell: OrderCursor) -> None: + self._apply_state(self._committed_state().select_row(cell, self._position_count)) + def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index ef623556..de013cb8 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -324,6 +324,9 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + self._lbl_context_select_all = label(SequencerTrackerElements.CONTEXT_SELECT_ALL) + self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN) + self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) @@ -1331,6 +1334,8 @@ def add_action_items(self, target: TrackerTarget) -> None: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ + self._add_select_items(target.cell) + dpg.add_separator() self._surface.add_block_items(target) dpg.add_separator() self._add_instrument_submenu(target.cell) @@ -1345,6 +1350,29 @@ def add_action_items(self, target: TrackerTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) + def _add_select_items(self, cell: TrackerCursor) -> None: + """Builds the three shapes a selection takes, from the whole frame down to one subcolumn. + + Each item fires the gesture its key fires, on the cell the menu names: a column selected + from a cell menu is the column that cell stands in, and one selected from the menu bar is + the column the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_context_select_all, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_column, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_subcolumn, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell), + ) + def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () @@ -1481,6 +1509,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._select_shape(shortcut_id, cursor): + return True + if self._block_action(shortcut_id): return True @@ -1541,6 +1572,46 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _select_shape( + self, + shortcut_id: ShortcutId, + cell: TrackerCursor, + ) -> bool: + """Selects a rectangle of the grid, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.TRACKER_SELECT_ALL: + self._select_all() + case ShortcutId.TRACKER_SELECT_COLUMN: + self._select_column(cell) + case ShortcutId.TRACKER_SELECT_SUBCOLUMN: + self._select_subcolumn(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._select(self._committed_state().select_all(self._current_row_count)) + + def _select_column(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_column(cell, self._current_row_count)) + + def _select_subcolumn(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_subcolumn(cell, self._current_row_count)) + + def _select(self, new_state: TrackerInputState) -> None: + """Stands a selected shape, revealing the row its cursor landed on. + + A shape ends at the frame's last row, so the reveal carries the grid to the end the cursor + now holds — the same landing a Shift+End reach makes. + """ + self._apply_state(new_state) + self._scroll_cursor_into_view() + def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index bfd7b9ef..8fc11fef 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -107,6 +107,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "OrderExtendSelectionToLastPosition", ShortcutCategory.ORDER, ) + ORDER_SELECT_ALL = ("OrderSelectAll", ShortcutCategory.ORDER) + ORDER_SELECT_ROW = ("OrderSelectRow", ShortcutCategory.ORDER) ORDER_COPY_BLOCK = ("OrderCopyBlock", ShortcutCategory.ORDER) ORDER_CUT_BLOCK = ("OrderCutBlock", ShortcutCategory.ORDER) ORDER_PASTE_BLOCK = ("OrderPasteBlock", ShortcutCategory.ORDER) @@ -144,6 +146,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "TrackerExtendSelectionToLastRow", ShortcutCategory.TRACKER, ) + TRACKER_SELECT_ALL = ("TrackerSelectAll", ShortcutCategory.TRACKER) + TRACKER_SELECT_COLUMN = ("TrackerSelectColumn", ShortcutCategory.TRACKER) + TRACKER_SELECT_SUBCOLUMN = ("TrackerSelectSubcolumn", ShortcutCategory.TRACKER) TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 6da80187..d1f0b3ad 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -50,10 +50,10 @@ bindings: UnmuteAllChannels: {combination: ~} # view - AudioSettings: {combination: "Ctrl+A"} + AudioSettings: {combination: "Ctrl+U"} DisplaySettings: {combination: "Ctrl+D"} KeyboardSettings: {combination: "Ctrl+K"} - ToggleAdvancedSettings: {combination: "Ctrl+Shift+A"} + ToggleAdvancedSettings: {combination: "Ctrl+Alt+T"} ToggleFullscreen: {combination: "F11"} AboutDialog: {combination: ~} NextTab: {combination: "Ctrl+PgDn", field_transparent: true} @@ -72,6 +72,8 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} OrderExtendSelectionToLastPosition: {combination: "Shift+End"} + OrderSelectAll: {combination: "Ctrl+A"} + OrderSelectRow: {combination: "Ctrl+Shift+A"} OrderCopyBlock: {combination: "Ctrl+C"} OrderCutBlock: {combination: "Ctrl+X"} OrderPasteBlock: {combination: "Ctrl+V"} @@ -104,6 +106,9 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} + TrackerSelectAll: {combination: "Ctrl+A"} + TrackerSelectColumn: {combination: "Ctrl+Shift+A"} + TrackerSelectSubcolumn: {combination: "Ctrl+Alt+A"} TrackerCopyBlock: {combination: "Ctrl+C"} TrackerCutBlock: {combination: "Ctrl+X"} TrackerPasteBlock: {combination: "Ctrl+V"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 1e127e28..9613c344 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -50,10 +50,10 @@ bindings: UnmuteAllChannels: {combination: ~} # view - AudioSettings: {combination: "Cmd+A"} + AudioSettings: {combination: "Cmd+U"} DisplaySettings: {combination: "Cmd+D"} KeyboardSettings: {combination: "Cmd+K"} - ToggleAdvancedSettings: {combination: "Cmd+Shift+A"} + ToggleAdvancedSettings: {combination: "Cmd+Alt+T"} ToggleFullscreen: {combination: "Cmd+Ctrl+F"} AboutDialog: {combination: ~} NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} @@ -72,6 +72,8 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} + OrderSelectAll: {combination: "Cmd+A"} + OrderSelectRow: {combination: "Cmd+Shift+A"} OrderCopyBlock: {combination: "Cmd+C"} OrderCutBlock: {combination: "Cmd+X"} OrderPasteBlock: {combination: "Cmd+V"} @@ -104,6 +106,9 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} + TrackerSelectAll: {combination: "Cmd+A"} + TrackerSelectColumn: {combination: "Cmd+Shift+A"} + TrackerSelectSubcolumn: {combination: "Cmd+Alt+A"} TrackerCopyBlock: {combination: "Cmd+C"} TrackerCutBlock: {combination: "Cmd+X"} TrackerPasteBlock: {combination: "Cmd+V"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8c9f5d69..827d625b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -454,6 +454,9 @@ sequencer.tracker.label.column_triangle: "Triangle" sequencer.tracker.label.column_noise: "Noise" sequencer.tracker.label.context_play: "Play from here" sequencer.tracker.label.context_play_from_frame: "Play from this frame" +sequencer.tracker.label.context_select_all: "Select all" +sequencer.tracker.label.context_select_column: "Select column" +sequencer.tracker.label.context_select_subcolumn: "Select subcolumn" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -487,6 +490,8 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" +sequencer.order.label.context_select_all: "Select all" +sequencer.order.label.context_select_row: "Select row" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" @@ -785,6 +790,8 @@ settings.keybindings.label.order_extend_selection_left: "Extend selection left" settings.keybindings.label.order_extend_selection_right: "Extend selection right" settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" +settings.keybindings.label.order_select_all: "Select the whole order" +settings.keybindings.label.order_select_row: "Select the current row" settings.keybindings.label.order_copy_block: "Copy selection" settings.keybindings.label.order_cut_block: "Cut selection" settings.keybindings.label.order_paste_block: "Paste selection" @@ -815,6 +822,9 @@ settings.keybindings.label.tracker_extend_selection_left: "Extend selection left settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" +settings.keybindings.label.tracker_select_all: "Select the whole frame" +settings.keybindings.label.tracker_select_column: "Select the current column" +settings.keybindings.label.tracker_select_subcolumn: "Select the current subcolumn" settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_cut_block: "Cut selection" settings.keybindings.label.tracker_paste_block: "Paste selection" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index e508fc39..510e60be 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -100,6 +100,33 @@ def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: assert isinstance(_state().extend_to(_Cell(4, 3)), _GridState) assert isinstance(_state().reset_pending(), _GridState) assert isinstance(_state().collapse(), _GridState) + assert isinstance(_state().select_between(_Cell(0, 0), _Cell(4, 3)), _GridState) + + +class TestSelectBetween: + """A select gesture names a shape by its corners, which is how each grid states its own shapes.""" + + def test_the_selection_covers_the_rectangle_the_two_cells_bound(self) -> None: + selected = _state().select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5) + + def test_the_cursor_lands_on_the_far_corner(self) -> None: + """The next extending press then works from the edge the reader has just reached.""" + selected = _state().select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.anchor == _Cell(0, 0) + assert selected.cursor == _Cell(6, 5) + + def test_a_shape_takes_over_from_the_selection_standing(self) -> None: + held = _state().extend_to(_Cell(4, 3)) + + selected = held.select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5) + + def test_a_shape_settles_a_partial_entry(self) -> None: + assert _state(pending="5").select_between(_Cell(0, 0), _Cell(6, 5)).pending == "" class TestTarget: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index f4140920..00e132c8 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -127,6 +127,49 @@ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: assert selected.region_at(cell) == selected.region +class TestSelectShapes: + """The two shapes the table states, each running every position and ending at its far corner.""" + + def test_selecting_all_reaches_every_row_and_every_position(self) -> None: + selected = _state(position=2).select_all(POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == CHANNEL_AXIS + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_selecting_a_row_reaches_the_cursor_s_channel_across_the_order(self) -> None: + cell = OrderCursor(GeneratorName.TRIANGLE, 2) + + selected = _state(GeneratorName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == (GeneratorName.TRIANGLE,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_the_master_row_is_a_row_like_any_other(self) -> None: + cell = OrderCursor(None, 2) + + selected = _state(None, position=2).select_row(cell, POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == (None,) + + def test_a_shape_stands_the_cursor_on_the_last_position_it_reaches(self) -> None: + """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" + selected = _state(position=2).select_all(POSITION_COUNT) + + assert selected.anchor == OrderCursor(CHANNEL_AXIS[0], 0) + assert selected.cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1) + + def test_an_order_holding_no_positions_selects_nothing(self) -> None: + state = _state() + + assert state.select_all(0) is state + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 27bec8f2..b4731cb4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -1,7 +1,7 @@ from typing import Optional from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -151,6 +151,61 @@ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: assert selected.region_at(cell) == selected.region +class TestSelectShapes: + """The three shapes the grid states, each running the whole frame and ending at its far corner.""" + + def test_selecting_all_reaches_every_row_and_every_slot(self) -> None: + selected = _state(SubColumn.TRANSPOSE, row=4).select_all(ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) + + def test_selecting_a_column_reaches_the_cursor_s_channel_and_its_subcolumns(self) -> None: + cell = TrackerCursor(4, GeneratorName.TRIANGLE, SubColumn.TRANSPOSE) + + selected = _state(SubColumn.TRANSPOSE, row=4).select_column(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + + def test_the_sample_column_is_a_column_like_any_other(self) -> None: + cell = TrackerCursor(4, None, SubColumn.VOLUME) + + selected = _state(SubColumn.VOLUME, row=4, generator=None).select_column(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert region.columns == (None,) + + def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) -> None: + cell = TrackerCursor(4, GeneratorName.NOISE, SubColumn.VOLUME) + + selected = _state(SubColumn.VOLUME, row=4, generator=GeneratorName.NOISE).select_subcolumn(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert region.slots == (TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME),) + + def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None: + """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" + cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT) + + assert selected.cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.PULSE1, SubColumn.VOLUME) + assert selected.anchor == TrackerCursor(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + def test_a_frame_holding_no_rows_selects_nothing(self) -> None: + state = _state(SubColumn.INSTRUMENT) + + assert state.select_all(0) is state + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 9f4880ae..53e5b214 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -22,7 +22,7 @@ TrackerCell, TrackerRegion, ) -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot, slot_from_flat from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from tests.suite.grid import ( @@ -42,6 +42,11 @@ PASTE_ITEM = 2 DELETE_ITEM = 3 +SELECT_ALL_ITEM = 0 +SELECT_COLUMN_ITEM = 1 +SELECT_SUBCOLUMN_ITEM = 2 +SELECT_ROW_ITEM = 1 + PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) @@ -52,6 +57,7 @@ class MenuItem: label: str enabled: bool callback: Callable[[], None] + shortcut: str = "" @dataclass @@ -80,12 +86,16 @@ def add_menu_item(self, **kwargs: Any) -> int: label=kwargs["label"], enabled=kwargs.get("enabled", True), callback=kwargs.get("callback", _prints_only), + shortcut=kwargs.get("shortcut", ""), ) ) return 0 TRACKER_LABELS = ( + "select_all", + "select_column", + "select_subcolumn", "note_off", "set_instrument", "no_samples", @@ -95,6 +105,8 @@ def add_menu_item(self, **kwargs: Any) -> int: ) ORDER_LABELS = ( + "select_all", + "select_row", "duplicate", "clone", "insert", @@ -202,6 +214,30 @@ def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor: return OrderCursor(generator, CLICKED_POSITION) +def _tracker_selections( + monkeypatch: pytest.MonkeyPatch, + panel: tracker_module.GUISequencerTrackerPanel, +) -> List[TrackerInputState]: + """The states a select item applies, on a grid holding a cursor and the rows to reach.""" + panel._input_state = TrackerInputState(cursor=_tracker_cell(GeneratorName.PULSE1)) + panel._current_row_count = ROW_COUNT + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + return states + + +def _order_selections( + monkeypatch: pytest.MonkeyPatch, + panel: order_module.GUISequencerOrderPanel, +) -> List[OrderInputState]: + """The states a select item applies, on a table holding a cursor and the positions to reach.""" + panel._input_state = OrderInputState(cursor=_order_cell(GeneratorName.PULSE1)) + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: states.append(state)) + return states + + def _selected_tracker_state() -> TrackerInputState: """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) @@ -454,7 +490,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( class TestActionSet: """One builder states each grid's actions, so every menu offering them prints the same set.""" - def test_the_tracker_action_set_opens_with_the_clipboard_items( + def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes( self, tracker_recorder: _MenuRecorder, ) -> None: @@ -463,10 +499,11 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] - assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert labels[:3] == ["select_all", "select_column", "select_subcolumn"] + assert labels[3:7] == ["Copy", "Cut", "Paste", "Delete"] assert panel._lbl_context_clear_row in labels - def test_the_order_action_set_opens_with_the_clipboard_items( + def test_the_order_action_set_leads_with_the_shapes_a_selection_takes( self, order_recorder: _MenuRecorder, ) -> None: @@ -475,7 +512,8 @@ def test_the_order_action_set_opens_with_the_clipboard_items( panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] - assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert labels[:2] == ["select_all", "select_row"] + assert labels[2:6] == ["Copy", "Cut", "Paste", "Delete"] assert panel._lbl_context_move_end in labels @@ -492,3 +530,73 @@ def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _Menu assert labels[CUT_ITEM] == "Cut" assert labels[PASTE_ITEM] == "Paste" assert labels[DELETE_ITEM] == "Delete" + + +class TestSelectItems: + """The shapes each grid states, printed with their keys and firing what those keys fire.""" + + def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_select_items(_tracker_cell(GeneratorName.PULSE1)) + + assert [item.shortcut for item in tracker_recorder.items] == [ + "Ctrl+A", + "Ctrl+Shift+A", + "Ctrl+Alt+A", + ] + + def test_a_tracker_item_selects_the_column_the_menu_was_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + tracker_recorder: _MenuRecorder, + ) -> None: + """A menu names the cell it was raised on, so the shape reaches that cell's own column.""" + panel = _tracker_panel(Gestures()) + states = _tracker_selections(monkeypatch, panel) + + panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + tracker_recorder.items[SELECT_COLUMN_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.columns == (GeneratorName.TRIANGLE,) + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + + def test_a_tracker_item_selects_the_whole_frame( + self, + monkeypatch: pytest.MonkeyPatch, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + states = _tracker_selections(monkeypatch, panel) + + panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + tracker_recorder.items[SELECT_ALL_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT)) + + def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None: + panel = _order_panel(Gestures()) + + panel._add_select_items(_order_cell(GeneratorName.PULSE1)) + + assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"] + + def test_an_order_item_selects_the_row_the_menu_was_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + states = _order_selections(monkeypatch, panel) + + panel._add_select_items(_order_cell(None)) + order_recorder.items[SELECT_ROW_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.generators == (None,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 1b6c90bd..204bfd08 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -136,8 +136,8 @@ def test_a_hex_key_types_into_the_cell_under_the_cursor(self, order: OrderPanelF assert order.states[-1].pending == "A" def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture) -> None: - """Ctrl+A opens the audio settings, so cell entry keeps the plain key alone.""" - assert order.panel._on_key_pressed(_press("Ctrl+A")) is False + """Ctrl+D opens the display settings, so cell entry keeps the plain key alone.""" + assert order.panel._on_key_pressed(_press("Ctrl+D")) is False assert order.states == [] def test_the_clear_cell_key_empties_the_cell_and_moves_on(self, order: OrderPanelFixture) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index a5308d61..2b2043bf 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -2,6 +2,7 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -12,7 +13,7 @@ from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from tests.suite.shortcuts import shipped_source @@ -178,3 +179,74 @@ def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPa assert panel._on_key_pressed(_press("Right")) is True assert states[-1].region is None + + +class TestTrackerSelectKeys: + """The A chord selects a shape of the grid, each shape wider than the one Shift and Alt add.""" + + def test_ctrl_a_selects_the_whole_frame(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) + + def test_ctrl_shift_a_selects_the_column_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker(generator=GeneratorName.TRIANGLE, subcolumn=SubColumn.VOLUME) + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True + region = states[-1].region + assert region is not None + assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + + def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker(subcolumn=SubColumn.VOLUME) + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Alt+A")) is True + region = states[-1].region + assert region is not None + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + + def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A Shift+Up straight after shrinks the selection from the row the shape ended on.""" + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.NOISE, SubColumn.VOLUME) + + +class TestOrderSelectKeys: + """The A chord selects a shape of the table, the whole order or the row the cursor stands in.""" + + def test_ctrl_a_selects_the_whole_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + region = states[-1].region + assert region is not None + assert region.generators == CHANNEL_AXIS + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_ctrl_shift_a_selects_the_row_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order(generator=None) + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True + region = states[-1].region + assert region is not None + assert region.generators == (None,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + assert states[-1].cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index f40f0a70..71eb277c 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -255,11 +255,12 @@ def test_text_field_keeps_its_editing_chord( source: ShortcutSource, field_kind: Dict[str, FieldKind], ) -> None: + """Ctrl+Z undoes the text being typed, so the application's own undo stays out of it.""" callback = Mock() - manager = _manager(source, ShortcutId.AUDIO_SETTINGS, callback) + manager = _manager(source, ShortcutId.UNDO, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY - claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL)) + claimed = manager._dispatch(_event(dpg.mvKey_Z, modifiers=CTRL)) assert not claimed callback.assert_not_called() @@ -269,12 +270,12 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for( source: ShortcutSource, field_kind: Dict[str, FieldKind], ) -> None: - """Ctrl+Shift+A carries a chord letter without being a text chord, so the shortcut fires.""" + """Ctrl+Shift+S is no text chord, so the shortcut fires while a field holds the keyboard.""" callback = Mock() - manager = _manager(source, ShortcutId.TOGGLE_ADVANCED_SETTINGS, callback) + manager = _manager(source, ShortcutId.SAVE_PROJECT_AS, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY - claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL_SHIFT)) + claimed = manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL_SHIFT)) assert claimed callback.assert_called_once() diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index 8f480a3b..ff7bdb28 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -71,6 +71,170 @@ def test_adding_a_frame_keeps_the_unmodified_insert(self, shipped: ShortcutSchem assert shipped.action(ShortcutCategory.ORDER, _press("Ins")) is ShortcutId.ORDER_ADD_FRAME +class TestSelectKeys(BaseTestSuite): + """The A chord is selection and nothing else, each modifier narrowing the shape it names.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + category: ShortcutCategory + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase( + label="the whole frame", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_ALL, + expected="Ctrl+A", + ), + TestCase( + label="a column", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_COLUMN, + expected="Ctrl+Shift+A", + ), + TestCase( + label="a subcolumn", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_SUBCOLUMN, + expected="Ctrl+Alt+A", + ), + TestCase( + label="the whole order", + category=ShortcutCategory.ORDER, + shortcut_id=ShortcutId.ORDER_SELECT_ALL, + expected="Ctrl+A", + ), + TestCase( + label="an order row", + category=ShortcutCategory.ORDER, + shortcut_id=ShortcutId.ORDER_SELECT_ROW, + expected="Ctrl+Shift+A", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shape_reads_under_the_combination_it_answers( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + assert shipped.shortcut(test_case.shortcut_id).display() == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shape_answers_its_press_in_the_grid_that_states_it( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(test_case.category, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_mac_reaches_the_shape_through_its_own_modifier( + self, + test_case: TestCase, + macos: ShortcutScheme, + ) -> None: + """A Mac spells the chord with Command, so the family reads the same on either keyboard.""" + combination = test_case.expected.replace("Ctrl", "Cmd") + + assert macos.action(test_case.category, _press(combination)) is test_case.shortcut_id + + +class TestDisplacedSettingsKeys(BaseTestSuite): + """Where the two settings the A chord displaced now answer, each keeping its family's shape.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + mac_expected: str + + test_cases = ( + TestCase( + label="audio settings", + shortcut_id=ShortcutId.AUDIO_SETTINGS, + expected="Ctrl+U", + mac_expected="Cmd+U", + ), + TestCase( + label="advanced settings", + shortcut_id=ShortcutId.TOGGLE_ADVANCED_SETTINGS, + expected="Ctrl+Alt+T", + mac_expected="Cmd+Alt+T", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_setting_reads_under_the_combination_it_answers( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + assert shipped.shortcut(test_case.shortcut_id).display() == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_setting_answers_its_press_wherever_no_grid_claims_it( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(ShortcutCategory.APPLICATION, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_setting_reads_under_the_combination_a_mac_gives_it( + self, + test_case: TestCase, + macos: ShortcutScheme, + mac_keyboard: None, + ) -> None: + assert macos.shortcut(test_case.shortcut_id).display() == test_case.mac_expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_grids_leave_the_settings_key_alone( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + """A grid is asked before the application is, so a dialog opens while the cursor stands in one.""" + press = _press(test_case.expected) + + assert shipped.action(ShortcutCategory.TRACKER, press) is None + assert shipped.action(ShortcutCategory.ORDER, press) is None + + class TestChannelKeys(BaseTestSuite): """The four channels sit on the four function keys, in the order the tracker shows them.""" From 9390ecc8c1f7c5d49d7d44047f7d332b490984ff Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:00:42 +0200 Subject: [PATCH 24/28] Added: auto-scroll while dragging --- .../panels/sequencer/grid/scroll/__init__.py | 0 .../ui/panels/sequencer/grid/scroll/axis.py | 55 +++++ .../ui/panels/sequencer/grid/scroll/band.py | 15 ++ .../ui/panels/sequencer/grid/scroll/travel.py | 97 ++++++++ .../ui/panels/sequencer/order.py | 31 +++ .../ui/panels/sequencer/tracker.py | 26 ++ .../panels/sequencer/grid/scroll/__init__.py | 0 .../panels/sequencer/grid/scroll/test_axis.py | 62 +++++ .../sequencer/grid/scroll/test_travel.py | 231 ++++++++++++++++++ .../panels/sequencer/test_selection_drag.py | 87 +++++++ 10 files changed, 604 insertions(+) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py new file mode 100644 index 00000000..a50db262 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py @@ -0,0 +1,55 @@ +from typing import Protocol + +import dearpygui.dearpygui as dpg + + +class ScrollAxis(Protocol): + """The axis one table scrolls along, and the pointer coordinate that runs past its edges.""" + + def pointer(self) -> float: ... + + def scroll(self) -> float: ... + + def scroll_max(self) -> float: ... + + def set_scroll(self, offset: float) -> None: ... + + +class VerticalScroll: + """A table whose rows run down the screen, so the pointer's height names the cell it stands on.""" + + def __init__(self, *, table: str) -> None: + self._table = table + + def pointer(self) -> float: + _, top = dpg.get_mouse_pos(local=False) + return float(top) + + def scroll(self) -> float: + return float(dpg.get_y_scroll(self._table)) + + def scroll_max(self) -> float: + return float(dpg.get_y_scroll_max(self._table)) + + def set_scroll(self, offset: float) -> None: + dpg.set_y_scroll(self._table, offset) + + +class HorizontalScroll: + """A table whose columns run across the screen, so the pointer's width names the cell it stands on.""" + + def __init__(self, *, table: str) -> None: + self._table = table + + def pointer(self) -> float: + left, _ = dpg.get_mouse_pos(local=False) + return float(left) + + def scroll(self) -> float: + return float(dpg.get_x_scroll(self._table)) + + def scroll_max(self) -> float: + return float(dpg.get_x_scroll_max(self._table)) + + def set_scroll(self, offset: float) -> None: + dpg.set_x_scroll(self._table, offset) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py new file mode 100644 index 00000000..eaf7a34a --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TravelBand: + """Where a grid's cells stand along the axis it scrolls, and how many of them it lays out. + + ``first_edge`` is the leading edge of the first cell in the coordinates the viewport is drawn + in, which travels with the scroll: adding the scroll back to it gives the edge the band on + screen begins at. + """ + + first_edge: float + cell_extent: float + cell_count: int diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py new file mode 100644 index 00000000..99536ae5 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py @@ -0,0 +1,97 @@ +from math import copysign +from typing import Callable, Final, Optional + +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ScrollAxis +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand + +TRAVEL_FLOOR_CELLS_PER_SECOND: Final[float] = 6.0 +TRAVEL_CEILING_CELLS_PER_SECOND: Final[float] = 45.0 +TRAVEL_FULL_PACE_OVERSHOOT_CELLS: Final[float] = 5.0 + + +class DragTravel: + """Carries a grid's view along while a held pointer stands past the band drawn on screen. + + A held pointer keeps reporting for as long as the button is down, wherever it has been carried + to, so the travel runs from that report and paces itself by the frame's own duration: the same + stretch of grid passes under the pointer however fast the frames arrive. Each step is added to + the offset last issued, because a table reports the scroll it was drawn with rather than the one + just set — reading it back would have the travel re-issue an offset it has already reached. + """ + + def __init__( + self, + *, + axis: ScrollAxis, + band: Callable[[], Optional[TravelBand]], + elapsed: Callable[[], float], + ) -> None: + self._axis = axis + self._band = band + self._elapsed = elapsed + self._offset: Optional[float] = None + + def advance(self) -> None: + """Travels one frame's worth toward whatever the pointer stands past, up to the grid's end. + + A pointer standing within the band leaves the grid where it is, and the drag then reaches + the cell it stands on the way it always has. A grid awaiting its first layout states no + band, and one that fits on screen has nowhere to travel to. + """ + band = self._band() + if band is None: + self.rest() + return + + scroll_max = self._axis.scroll_max() + if scroll_max <= 0.0: + self.rest() + return + + drawn = self._axis.scroll() + overshoot = self._overshoot(band, drawn, scroll_max) + if overshoot == 0.0: + self.rest() + return + + travel = self._pace(overshoot, band.cell_extent) * band.cell_extent * self._elapsed() + offset = self._offset if self._offset is not None else drawn + self._offset = min(max(offset + copysign(travel, overshoot), 0.0), scroll_max) + self._axis.set_scroll(self._offset) + + def rest(self) -> None: + """Ends the travel, so the next one sets out from the offset the grid is drawn with.""" + self._offset = None + + def _overshoot( + self, + band: TravelBand, + drawn: float, + scroll_max: float, + ) -> float: + """How far past the band the pointer stands, reading negative before its near edge. + + The band begins where the first cell's edge stands once the scroll carrying it is added + back, and it holds what the grid lays out less what it still has to scroll away. + """ + near = band.first_edge + drawn + far = near + band.cell_count * band.cell_extent - scroll_max + pointer = self._axis.pointer() + if pointer < near: + return pointer - near + + if pointer > far: + return pointer - far + + return 0.0 + + @staticmethod + def _pace(overshoot: float, cell_extent: float) -> float: + """How many cells a second the travel runs at: a floor at the edge, rising to a ceiling. + + The pace answers how far past the edge the pointer is carried, so a reader nudging the edge + creeps along and one reaching well past it covers the grid. + """ + reach = min(abs(overshoot) / (cell_extent * TRAVEL_FULL_PACE_OVERSHOOT_CELLS), 1.0) + span = TRAVEL_CEILING_CELLS_PER_SECOND - TRAVEL_FLOOR_CELLS_PER_SECOND + return TRAVEL_FLOOR_CELLS_PER_SECOND + span * reach diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 5e629f86..9dcfb3a7 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -45,6 +45,9 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import HorizontalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, @@ -151,6 +154,11 @@ def __init__( cell_at=self._cell_at, covered=self._selected_cells, ) + self._travel: DragTravel = DragTravel( + axis=HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None @@ -502,6 +510,7 @@ def _rebuild_table( self._highlighted = None self._highlighted_column = None self._selection.reset() + self._travel.rest() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -888,7 +897,11 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the table first, so the reach that follows reads the + positions the travel has brought into view. """ + self._travel.advance() reach = self._selection.hold(app_data) if reach is None: return @@ -907,6 +920,24 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: the cell it started from would otherwise have its selection taken down by its own click. """ self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the order's positions stand, which is the band a drag held beside them travels across. + + Two positions state the pitch the columns are laid out at, so an order holding one of them + travels nowhere — there is nothing beside it to reach. + """ + first = self._cell_left(0) + following = self._cell_left(1) + if first is None or following is None: + return None + + return TravelBand( + first_edge=first, + cell_extent=following - first, + cell_count=self._position_count, + ) def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index de013cb8..b1734fe3 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -49,6 +49,9 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import VerticalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, @@ -236,6 +239,11 @@ def __init__( cell_at=self._cell_at, covered=self._selected_cells, ) + self._travel: DragTravel = DragTravel( + axis=VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 @@ -559,6 +567,7 @@ def _rebuild_table( dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() self._selection.reset() + self._travel.rest() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -1160,7 +1169,11 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the grid first, so the reach that follows reads the rows + the travel has brought into view. """ + self._travel.advance() reach = self._selection.hold(app_data) if reach is None: return @@ -1179,6 +1192,19 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: the cell it started from would otherwise have its selection taken down by its own click. """ self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the frame's rows stand, which is the band a drag held below them travels across.""" + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + return TravelBand( + first_edge=first, + cell_extent=self._layout.tracker.row_height, + cell_count=self._current_row_count, + ) def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py new file mode 100644 index 00000000..ad0a7328 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py @@ -0,0 +1,62 @@ +from typing import List + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, + VerticalScroll, +) + +AXIS_MODULE = "sampletones_application.ui.panels.sequencer.grid.scroll.axis.dpg" +POINTER = [12.0, 34.0] +SCROLL = 7.0 +SCROLL_MAX = 70.0 +ISSUED = 5.0 + + +def _read_pointer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(f"{AXIS_MODULE}.get_mouse_pos", lambda local: POINTER) + + +class TestVerticalScroll: + """A table whose rows run down the screen travels by height.""" + + def test_the_pointer_reads_as_its_height(self, monkeypatch: pytest.MonkeyPatch) -> None: + _read_pointer(monkeypatch) + + assert VerticalScroll(table="tracker.table").pointer() == POINTER[1] + + def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + issued: List[float] = [] + monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll", lambda table: SCROLL) + monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll_max", lambda table: SCROLL_MAX) + monkeypatch.setattr(f"{AXIS_MODULE}.set_y_scroll", lambda table, offset: issued.append(offset)) + axis = VerticalScroll(table="tracker.table") + + axis.set_scroll(ISSUED) + + assert axis.scroll() == SCROLL + assert axis.scroll_max() == SCROLL_MAX + assert issued == [ISSUED] + + +class TestHorizontalScroll: + """A table whose columns run across the screen travels by width.""" + + def test_the_pointer_reads_as_its_width(self, monkeypatch: pytest.MonkeyPatch) -> None: + _read_pointer(monkeypatch) + + assert HorizontalScroll(table="order.table").pointer() == POINTER[0] + + def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + issued: List[float] = [] + monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll", lambda table: SCROLL) + monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll_max", lambda table: SCROLL_MAX) + monkeypatch.setattr(f"{AXIS_MODULE}.set_x_scroll", lambda table, offset: issued.append(offset)) + axis = HorizontalScroll(table="order.table") + + axis.set_scroll(ISSUED) + + assert axis.scroll() == SCROLL + assert axis.scroll_max() == SCROLL_MAX + assert issued == [ISSUED] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py new file mode 100644 index 00000000..5589d290 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py @@ -0,0 +1,231 @@ +from typing import List, Optional, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import ( + TRAVEL_CEILING_CELLS_PER_SECOND, + TRAVEL_FLOOR_CELLS_PER_SECOND, + TRAVEL_FULL_PACE_OVERSHOOT_CELLS, + DragTravel, +) + +FIRST_EDGE = 100.0 +CELL_EXTENT = 20.0 +CELL_COUNT = 60 +SCROLL_MAX = 800.0 +FRAME = 1.0 / 60.0 +BAND = TravelBand(first_edge=FIRST_EDGE, cell_extent=CELL_EXTENT, cell_count=CELL_COUNT) +BAND_NEAR = FIRST_EDGE +BAND_FAR = FIRST_EDGE + CELL_COUNT * CELL_EXTENT - SCROLL_MAX + + +class FakeAxis: + """An axis that stands wherever the test puts it, and records every offset issued to it.""" + + def __init__(self, *, pointer: float, scroll: float = 0.0, scroll_max: float = SCROLL_MAX) -> None: + self._pointer = pointer + self._scroll = scroll + self._scroll_max = scroll_max + self.issued: List[float] = [] + + def pointer(self) -> float: + return self._pointer + + def scroll(self) -> float: + return self._scroll + + def scroll_max(self) -> float: + return self._scroll_max + + def set_scroll(self, offset: float) -> None: + self.issued.append(offset) + + def stand_at(self, pointer: float) -> None: + self._pointer = pointer + + +def _travel( + axis: FakeAxis, + band: Optional[TravelBand] = BAND, + frame: float = FRAME, +) -> DragTravel: + return DragTravel(axis=axis, band=lambda: band, elapsed=lambda: frame) + + +def _grid( + pointer: float, + scroll: float = 0.0, + frame: float = FRAME, +) -> Tuple[FakeAxis, DragTravel]: + """A grid drawn at ``scroll``: its first cell stands that far back, so the band holds still.""" + axis = FakeAxis(pointer=pointer, scroll=scroll) + band = TravelBand( + first_edge=FIRST_EDGE - scroll, + cell_extent=CELL_EXTENT, + cell_count=CELL_COUNT, + ) + return axis, _travel(axis, band=band, frame=frame) + + +class TestPointerWithinTheBand: + """A pointer standing on the grid leaves it where it is.""" + + def test_a_pointer_in_the_middle_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=(BAND_NEAR + BAND_FAR) / 2) + + _travel(axis).advance() + + assert axis.issued == [] + + def test_a_pointer_on_either_edge_travels_nowhere(self) -> None: + for pointer in (BAND_NEAR, BAND_FAR): + axis = FakeAxis(pointer=pointer) + + _travel(axis).advance() + + assert axis.issued == [] + + def test_a_grid_awaiting_its_layout_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + + _travel(axis, band=None).advance() + + assert axis.issued == [] + + def test_a_grid_that_fits_on_screen_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0, scroll_max=0.0) + + _travel(axis).advance() + + assert axis.issued == [] + + +class TestPace: + """The travel answers how far past the edge the pointer is carried.""" + + def test_a_pointer_just_past_the_edge_travels_at_the_floor(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 0.5) + + _travel(axis).advance() + + assert axis.issued == [pytest.approx(TRAVEL_FLOOR_CELLS_PER_SECOND * CELL_EXTENT * FRAME, abs=0.5)] + + def test_a_pointer_carried_further_travels_faster(self) -> None: + near_edge = FakeAxis(pointer=BAND_FAR + CELL_EXTENT) + far_out = FakeAxis(pointer=BAND_FAR + 3 * CELL_EXTENT) + + _travel(near_edge).advance() + _travel(far_out).advance() + + assert far_out.issued[0] > near_edge.issued[0] + + def test_the_pace_stops_rising_at_the_ceiling(self) -> None: + at_full_pace = FakeAxis(pointer=BAND_FAR + TRAVEL_FULL_PACE_OVERSHOOT_CELLS * CELL_EXTENT) + far_beyond = FakeAxis(pointer=BAND_FAR + 100 * CELL_EXTENT) + + _travel(at_full_pace).advance() + _travel(far_beyond).advance() + + ceiling = TRAVEL_CEILING_CELLS_PER_SECOND * CELL_EXTENT * FRAME + assert at_full_pace.issued == [pytest.approx(ceiling)] + assert far_beyond.issued == [pytest.approx(ceiling)] + + def test_the_same_stretch_passes_however_fast_the_frames_arrive(self) -> None: + """Two frames of half the duration carry the grid exactly as far as one full one.""" + whole = FakeAxis(pointer=BAND_FAR + 200.0) + halves = FakeAxis(pointer=BAND_FAR + 200.0) + + _travel(whole).advance() + paced = _travel(halves, frame=FRAME / 2) + paced.advance() + paced.advance() + + assert halves.issued[-1] == pytest.approx(whole.issued[-1]) + + +class TestDirection: + """The travel carries the grid toward whichever edge the pointer stands past.""" + + def test_a_pointer_before_the_near_edge_travels_back(self) -> None: + axis, travel = _grid(pointer=BAND_NEAR - 100.0, scroll=400.0) + + travel.advance() + + assert axis.issued[0] < 400.0 + + def test_a_pointer_past_the_far_edge_travels_on(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 100.0, scroll=400.0) + + travel.advance() + + assert axis.issued[0] > 400.0 + + def test_the_band_travels_with_the_scroll(self) -> None: + """A scrolled grid draws its first cell further back, so the band stands where it always did.""" + axis = FakeAxis(pointer=BAND_NEAR + 10.0, scroll=300.0) + scrolled = TravelBand( + first_edge=FIRST_EDGE - 300.0, + cell_extent=CELL_EXTENT, + cell_count=CELL_COUNT, + ) + + _travel(axis, band=scrolled).advance() + + assert axis.issued == [] + + +class TestEnds: + """The travel stops where the grid does.""" + + def test_the_far_end_stops_at_the_scroll_extent(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=SCROLL_MAX - 1.0) + + travel.advance() + + assert axis.issued == [SCROLL_MAX] + + def test_the_near_end_stops_at_the_start(self) -> None: + axis, travel = _grid(pointer=BAND_NEAR - 500.0, scroll=1.0) + + travel.advance() + + assert axis.issued == [0.0] + + +class TestRunningOffset: + """Each step is added to the offset last issued, since a table reports the one it was drawn with.""" + + def test_travel_accumulates_while_the_grid_reports_the_offset_it_was_drawn_with(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + travel = _travel(axis) + + travel.advance() + travel.advance() + travel.advance() + + step = axis.issued[0] + assert axis.issued == [ + pytest.approx(step), + pytest.approx(2 * step), + pytest.approx(3 * step), + ] + + def test_a_pointer_returning_to_the_band_ends_the_travel(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + travel = _travel(axis) + + travel.advance() + axis.stand_at(BAND_NEAR + 10.0) + travel.advance() + + assert len(axis.issued) == 1 + + def test_a_travel_at_rest_sets_out_from_the_offset_the_grid_is_drawn_with(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=250.0) + + travel.advance() + travel.rest() + travel.advance() + + assert axis.issued[0] == pytest.approx(axis.issued[1]) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index ca00eb80..97353acc 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -10,8 +10,19 @@ LAYOUT_DIRECTORY, PALETTES_DIRECTORY, ) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_TRACKER_TABLE, +) from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, + ScrollAxis, + VerticalScroll, +) +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -44,6 +55,11 @@ def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: return layout_config.tabs.sequencer +def _resting_travel(axis: ScrollAxis) -> DragTravel: + """A travel over a grid that was never drawn: stating no band, it carries a drag nowhere.""" + return DragTravel(axis=axis, band=lambda: None, elapsed=lambda: 1.0 / 60.0) + + def _hold_modifiers( monkeypatch: pytest.MonkeyPatch, module: str, @@ -76,6 +92,7 @@ def _tracker( cell_at=lambda: panel._cell_at(), covered=panel._selected_cells, ) + panel._travel = _resting_travel(VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE)) states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -99,6 +116,7 @@ def _order( cell_at=lambda: panel._cell_at(), covered=panel._selected_cells, ) + panel._travel = _resting_travel(HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE)) states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -279,6 +297,75 @@ def test_a_grid_awaiting_its_rows_answers_nothing( assert panel._row_at(100.0) is None +class TestTravelBands: + """Each grid states the band a drag held past an edge travels across, in its own axis.""" + + def test_the_tracker_band_runs_from_the_first_row( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + assert panel._travel_band() == TravelBand( + first_edge=100.0, + cell_extent=sequencer_layout.tracker.row_height, + cell_count=ROW_COUNT, + ) + + def test_a_tracker_awaiting_its_rows_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: None) + + assert panel._travel_band() is None + + def test_an_empty_frame_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = 0 + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0) + + assert panel._travel_band() is None + + def test_the_order_band_runs_across_from_the_first_position( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._position_count = POSITION_COUNT + monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 + 25.0 * position) + + assert panel._travel_band() == TravelBand( + first_edge=40.0, + cell_extent=25.0, + cell_count=POSITION_COUNT, + ) + + def test_an_order_of_one_position_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A single position holds every width there is, so nothing states the pitch to travel by.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._position_count = 1 + monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 if position == 0 else None) + + assert panel._travel_band() is None + + class TestOrderDrag: """The order table reads a drag the same way, over its channels and positions.""" From 5f8f1812228b964e9b4be5ee7b4c84bb448cc8a4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:32:43 +0200 Subject: [PATCH 25/28] Added: sequencer blocks on the system clipboard --- .../coordinators/tabs/sequencer.py | 77 ++++- .../logic/sequencer/clipboard/__init__.py | 14 + .../logic/sequencer/clipboard/cache.py | 25 ++ .../logic/sequencer/clipboard/fields.py | 85 ++++++ .../logic/sequencer/clipboard/header.py | 88 ++++++ .../logic/sequencer/clipboard/order.py | 112 +++++++ .../logic/sequencer/clipboard/samples.py | 38 +++ .../{clipboard.py => clipboard/store.py} | 0 .../logic/sequencer/clipboard/tracker.py | 273 +++++++++++++++++ .../utils/gui/clipboard.py | 21 +- tests/suite/surface.py | 12 +- .../coordinators/tabs/test_sequencer.py | 136 ++++++++- .../logic/sequencer/clipboard/__init__.py | 0 .../logic/sequencer/clipboard/test_cache.py | 60 ++++ .../logic/sequencer/clipboard/test_header.py | 79 +++++ .../logic/sequencer/clipboard/test_order.py | 163 ++++++++++ .../logic/sequencer/clipboard/test_samples.py | 74 +++++ .../logic/sequencer/clipboard/test_tracker.py | 289 ++++++++++++++++++ 18 files changed, 1529 insertions(+), 17 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/clipboard/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/cache.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/fields.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/header.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/order.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/samples.py rename src/sampletones_application/logic/sequencer/{clipboard.py => clipboard/store.py} (100%) create mode 100644 src/sampletones_application/logic/sequencer/clipboard/tracker.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index a28525e0..c53a14e2 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -22,11 +22,18 @@ from sampletones_application.logic.reconstruction.browser_manager import BrowserManager from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic -from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.clipboard import ( + OrderBlockText, + ParsedBlockCache, + ProjectSampleDirectory, + SequencerClipboard, + TrackerBlockText, +) from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) from sampletones_application.logic.sequencer.order import ( + OrderBlock, OrderBlockReader, OrderBlockWriter, SequencerOrderLogic, @@ -41,6 +48,7 @@ from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, + TrackerBlock, TrackerBlockReader, TrackerBlockWriter, TrackerRegionAdjuster, @@ -81,6 +89,10 @@ from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.clipboard import ( + SystemTextClipboard, + TextClipboard, +) from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -196,6 +208,13 @@ def __init__( self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) self._clipboard: SequencerClipboard = SequencerClipboard() + self._system_clipboard: TextClipboard = SystemTextClipboard() + self._tracker_block_text: TrackerBlockText = TrackerBlockText( + samples=ProjectSampleDirectory(project_controller), + ) + self._order_block_text: OrderBlockText = OrderBlockText() + self._tracker_text_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_block_text.parse) + self._order_text_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_block_text.parse) self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) @@ -506,15 +525,45 @@ def _wire_block_callbacks(self) -> None: def _can_paste_tracker_block(self) -> bool: """Whether the tracker has a block to write, which is what its Paste item is offered on.""" - return self._clipboard.tracker_block is not None + return self._tracker_block_in_hand() is not None def _can_paste_order_block(self) -> bool: """Whether the order has a block to write, which is what its Paste item is offered on.""" - return self._clipboard.order_block is not None + return self._order_block_in_hand() is not None + + def _tracker_block_in_hand(self) -> Optional[TrackerBlock]: + """The block a tracker paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + tracker copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._tracker_text_cache.block(self._system_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.tracker_block + + def _order_block_in_hand(self) -> Optional[OrderBlock]: + """The block an order paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + order copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._order_text_cache.block(self._system_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.order_block def _on_tracker_copy_block(self, region: TrackerRegion) -> None: - """Puts the tracker's selected block on the clipboard, for a paste to replay.""" - self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + """Puts the tracker's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._tracker_block_reader.read(region) + self._clipboard.store_tracker_block(block) + self._system_clipboard.write(self._tracker_block_text.state(block, region)) def _cut_tracker_block(self, region: TrackerRegion) -> None: """Takes the block a region covers onto the clipboard, then empties what it covered.""" @@ -522,14 +571,20 @@ def _cut_tracker_block(self, region: TrackerRegion) -> None: self._tracker_block_writer.clear(region) def _paste_tracker_block(self, cell: TrackerCell) -> None: - """Writes the block the tracker last copied at a cell, while a copy has been made.""" - block = self._clipboard.tracker_block + """Writes the block the tracker has in hand at a cell, while a copy has been made.""" + block = self._tracker_block_in_hand() if block is not None: self._tracker_block_writer.write(block, cell) def _on_order_copy_block(self, region: OrderRegion) -> None: - """Puts the order's selected block on the clipboard, for a paste to replay.""" - self._clipboard.store_order_block(self._order_block_reader.read(region)) + """Puts the order's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._order_block_reader.read(region) + self._clipboard.store_order_block(block) + self._system_clipboard.write(self._order_block_text.state(block, region)) def _cut_order_block(self, region: OrderRegion) -> None: """Takes the block a region covers onto the clipboard, then silences what it covered.""" @@ -537,8 +592,8 @@ def _cut_order_block(self, region: OrderRegion) -> None: self._order_block_writer.clear(region) def _paste_order_block(self, cell: OrderCell) -> None: - """Writes the block the order last copied at a cell, while a copy has been made.""" - block = self._clipboard.order_block + """Writes the block the order has in hand at a cell, while a copy has been made.""" + block = self._order_block_in_hand() if block is not None: self._order_block_writer.write(block, cell) diff --git a/src/sampletones_application/logic/sequencer/clipboard/__init__.py b/src/sampletones_application/logic/sequencer/clipboard/__init__.py new file mode 100644 index 00000000..689e06d8 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/__init__.py @@ -0,0 +1,14 @@ +from .cache import ParsedBlockCache +from .order import OrderBlockText +from .samples import ProjectSampleDirectory, SampleDirectory +from .store import SequencerClipboard +from .tracker import TrackerBlockText + +__all__ = [ + "OrderBlockText", + "ParsedBlockCache", + "ProjectSampleDirectory", + "SampleDirectory", + "SequencerClipboard", + "TrackerBlockText", +] diff --git a/src/sampletones_application/logic/sequencer/clipboard/cache.py b/src/sampletones_application/logic/sequencer/clipboard/cache.py new file mode 100644 index 00000000..df915db8 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/cache.py @@ -0,0 +1,25 @@ +from typing import Callable, Generic, Optional, TypeVar + +BlockT = TypeVar("BlockT") + + +class ParsedBlockCache(Generic[BlockT]): + """Holds the block a text last read as, so asking again about it costs one comparison. + + A menu opening asks whether a paste has anything to write and the paste that follows asks + for the block itself, both about the text standing on the system clipboard, so one parse + serves every question put about that text. + """ + + def __init__(self, parse: Callable[[str], Optional[BlockT]]) -> None: + self._parse = parse + self._text: Optional[str] = None + self._block: Optional[BlockT] = None + + def block(self, text: str) -> Optional[BlockT]: + """The block a text reads as, parsed on its first reading and held for the rest.""" + if text != self._text: + self._text = text + self._block = self._parse(text) + + return self._block diff --git a/src/sampletones_application/logic/sequencer/clipboard/fields.py b/src/sampletones_application/logic/sequencer/clipboard/fields.py new file mode 100644 index 00000000..3e17c3aa --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/fields.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Generic, Optional, TypeVar + +from sampletones_shared.constants.symbols import DOT, HEXADECIMAL, MIXED + +KeyT = TypeVar("KeyT") +ValueT = TypeVar("ValueT") + +HEXADECIMAL_BASE: Final[int] = 16 + + +@dataclass(frozen=True) +class FieldReading(Generic[ValueT]): + """What one printed field states about the cell it stands for. + + ``stated`` separates the two readings a value of ``None`` carries: a field printing the dots + an empty cell shows states emptiness, and one printing the marks a mixed cell shows states + nothing at all, so its key stays out of the block and a paste passes that cell by. + """ + + value: Optional[ValueT] + stated: bool + + @classmethod + def of(cls, value: Optional[ValueT]) -> FieldReading[ValueT]: + return cls(value=value, stated=True) + + @classmethod + def mixed(cls) -> FieldReading[ValueT]: + return cls(value=None, stated=False) + + +def state_mixed(width: int) -> str: + """The marks a mixed cell prints, filling its field so every row line reads as a grid.""" + return MIXED * width + + +def read_placeholder(field: str) -> Optional[FieldReading[ValueT]]: + """The reading a field of one repeated mark carries: emptiness, or nothing at all. + + Returns: + The reading, present while the field is dots throughout or marks throughout. A field + carrying anything else is left to the reader of its own kind. + """ + marks = set(field) + if marks == {MIXED}: + return FieldReading.mixed() + + if marks == {DOT}: + return FieldReading.of(None) + + return None + + +def read_hexadecimal(field: str) -> Optional[int]: + """The number a field of hexadecimal digits names, present while every character is one. + + Digits are read in either case, so a field typed by hand reads as the one the grid prints. + """ + digits = field.upper() + if not digits or any(digit not in HEXADECIMAL for digit in digits): + return None + + return int(digits, HEXADECIMAL_BASE) + + +def store_reading( + values: Dict[KeyT, Optional[ValueT]], + key: KeyT, + reading: Optional[FieldReading[ValueT]], +) -> bool: + """Puts the cell a reading states into the map, answering whether the field had a reading. + + A field the form has no reading for answers ``False``, which is what refuses a whole text + rather than letting one unreadable cell reach the grid. + """ + if reading is None: + return False + + if reading.stated: + values[key] = reading.value + + return True diff --git a/src/sampletones_application/logic/sequencer/clipboard/header.py b/src/sampletones_application/logic/sequencer/clipboard/header.py new file mode 100644 index 00000000..9bd24a59 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/header.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +BLOCK_MAGIC: Final[str] = "SampleToNES/1" +ROW_KEY: Final[str] = "rows" +LABEL_SEPARATOR: Final[str] = "=" +SPAN_SEPARATOR: Final[str] = ".." +HEADER_TOKEN_COUNT: Final[int] = 4 + + +@dataclass(frozen=True) +class BlockShape: + """How far a block reaches: the rows it holds, and the span its fields cross. + + The span is stated in the coordinates of the grid the block was read from, so a tracker + block names the slots it began and ended on and a reading of it lands on the same kinds of + subcolumn. + """ + + rows: int + first: int + last: int + + @property + def width(self) -> int: + return self.last - self.first + 1 + + +def state_header(*, grid: str, span_key: str, shape: BlockShape) -> str: + """The line a block opens with, naming the grid it came from and the shape it covers.""" + rows = f"{ROW_KEY}{LABEL_SEPARATOR}{shape.rows}" + span = f"{span_key}{LABEL_SEPARATOR}{shape.first}{SPAN_SEPARATOR}{shape.last}" + return f"{BLOCK_MAGIC} {grid} {rows} {span}" + + +def parse_header( + line: str, + *, + grid: str, + span_key: str, +) -> Optional[BlockShape]: + """The shape a header states, present while it names this grid in the form written here. + + The shape is also the declaration the body is held to, so a text whose lines state a + different count or width is refused by the reader that asked for it. + """ + tokens = line.split() + if len(tokens) != HEADER_TOKEN_COUNT or tokens[0] != BLOCK_MAGIC or tokens[1] != grid: + return None + + rows = _read_count(tokens[2], ROW_KEY) + span = _read_span(tokens[3], span_key) + if rows is None or span is None: + return None + + first, last = span + return BlockShape(rows=rows, first=first, last=last) + + +def _read_label(token: str, label: str) -> Optional[str]: + """What a ``label=value`` token states, present while it carries the label asked for.""" + name, separator, value = token.partition(LABEL_SEPARATOR) + if name != label or not separator: + return None + + return value + + +def _read_count(token: str, label: str) -> Optional[int]: + """The count a ``rows=4`` token names, present while it covers at least one row.""" + value = _read_label(token, label) + if value is None or not value.isdigit() or int(value) < 1: + return None + + return int(value) + + +def _read_span(token: str, label: str) -> Optional[Tuple[int, int]]: + """The bounds a ``slots=3..11`` token names, present while they stand in reading order.""" + value = _read_label(token, label) + if value is None: + return None + + first, separator, last = value.partition(SPAN_SEPARATOR) + if not separator or not first.isdigit() or not last.isdigit() or int(last) < int(first): + return None + + return int(first), int(last) diff --git a/src/sampletones_application/logic/sequencer/clipboard/order.py b/src/sampletones_application/logic/sequencer/clipboard/order.py new file mode 100644 index 00000000..c065e42e --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/order.py @@ -0,0 +1,112 @@ +from typing import Dict, Final, List, Optional + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.sequencer.order.block import BlockKey, OrderBlock +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.utils.display import display_id + +from .fields import ( + FieldReading, + read_hexadecimal, + read_placeholder, + state_mixed, + store_reading, +) +from .header import BlockShape, parse_header, state_header + +ORDER_GRID: Final[str] = "order" +POSITION_KEY: Final[str] = "positions" +ENTRY_WIDTH: Final[int] = len(display_id(None)) + + +class OrderBlockText: + """States an order block as the lines the table prints, and reads the same form back. + + One line per channel row, positions running across it, every field carrying what the table + shows in its cell: a pattern index, the dots of a silent slot, or the marks a master cell + fills its field with where the channels beneath it disagree. + """ + + def state(self, block: OrderBlock, region: OrderRegion) -> str: + """The text a copy puts on the system clipboard, the region supplying the shape. + + The region is what states the positions the block stands on, since a mixed cell leaves + its key out and a block alone therefore names less than the rectangle it came from. + """ + shape = BlockShape( + rows=len(region.rows), + first=region.first_position, + last=region.last_position, + ) + lines = [state_header(grid=ORDER_GRID, span_key=POSITION_KEY, shape=shape)] + lines.extend(self._state_row(block, shape, row_offset) for row_offset in range(shape.rows)) + return "\n".join(lines) + + def parse(self, text: str) -> Optional[OrderBlock]: + """The block a text states, present while it is one this table writes. + + Text naming another grid, declaring a shape its lines do not fill, or carrying a field + the form has no reading for states no block, so the slot the order copied into stands. + """ + lines = text.strip().splitlines() + if not lines: + return None + + shape = parse_header(lines[0], grid=ORDER_GRID, span_key=POSITION_KEY) + if shape is None or shape.rows > len(CHANNEL_AXIS) or len(lines) != shape.rows + 1: + return None + + return self._read_rows(lines[1:], shape) + + def _state_row( + self, + block: OrderBlock, + shape: BlockShape, + row_offset: int, + ) -> str: + """One row of the block, its fields standing in the order the positions run.""" + return " ".join( + self._state_entry( + block, + (row_offset, position_offset), + ) + for position_offset in range(shape.width) + ) + + @staticmethod + def _state_entry(block: OrderBlock, key: BlockKey) -> str: + if key not in block.entries: + return state_mixed(ENTRY_WIDTH) + + return display_id(block.entries[key]) + + def _read_rows( + self, + lines: List[str], + shape: BlockShape, + ) -> Optional[OrderBlock]: + entries: Dict[BlockKey, Optional[int]] = {} + for row_offset, line in enumerate(lines): + fields = line.split() + if len(fields) != shape.width: + return None + + for position_offset, field in enumerate(fields): + key = (row_offset, position_offset) + if not store_reading(entries, key, self._read_entry(field)): + return None + + return OrderBlock(entries=entries) + + @staticmethod + def _read_entry(field: str) -> Optional[FieldReading[int]]: + """The pattern a field names, present while it states an index or one of the two marks.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + pattern_index = read_hexadecimal(field) + if pattern_index is None: + return None + + return FieldReading.of(pattern_index) diff --git a/src/sampletones_application/logic/sequencer/clipboard/samples.py b/src/sampletones_application/logic/sequencer/clipboard/samples.py new file mode 100644 index 00000000..2e409952 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/samples.py @@ -0,0 +1,38 @@ +from typing import Optional, Protocol + +from sampletones_application.logic.project.controller import ProjectController + + +class SampleDirectory(Protocol): + """The samples a note can name, read the way a grid prints them: by list position.""" + + def position_of(self, sample_id: str) -> Optional[int]: ... + + def sample_at(self, position: int) -> Optional[str]: ... + + +class ProjectSampleDirectory: + """The samples the open project holds, in the order the samples panel lists them. + + The project is read on each lookup, because opening a document and every undo put another + one in place, so a block stated as text names whichever sample stands at that position now. + """ + + def __init__(self, project_controller: ProjectController) -> None: + self._controller = project_controller + + def position_of(self, sample_id: str) -> Optional[int]: + """Where a sample stands in the list, present while the project holds it.""" + samples = self._controller.project.samples + if samples.get(sample_id) is None: + return None + + return samples.get_index(sample_id) + + def sample_at(self, position: int) -> Optional[str]: + """The sample a position names, present while the list reaches that far.""" + samples = self._controller.project.samples + if 0 <= position < len(samples): + return samples[position].id + + return None diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard/store.py similarity index 100% rename from src/sampletones_application/logic/sequencer/clipboard.py rename to src/sampletones_application/logic/sequencer/clipboard/store.py diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py new file mode 100644 index 00000000..53b4f2b3 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -0,0 +1,273 @@ +from typing import Callable, Dict, Final, List, Optional + +from sampletones_application.logic.sequencer.tracker.block import ( + BlockKey, + BlockNote, + TrackerBlock, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.general import ( + MAX_TRANSPOSE, + MAX_VOLUME, + MIN_TRANSPOSE, + SILENT_VOLUME, +) +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.utils.display import ( + NOTE_OFF, + display_id, + display_transpose, + display_volume, +) +from sampletones_shared.constants.symbols import PLUS, SIGNS + +from .fields import ( + FieldReading, + read_hexadecimal, + read_placeholder, + state_mixed, + store_reading, +) +from .header import BlockShape, parse_header, state_header +from .samples import SampleDirectory + +TRACKER_GRID: Final[str] = "tracker" +SLOT_KEY: Final[str] = "slots" +COLUMN_SEPARATOR: Final[str] = "|" +NOTE_WIDTH: Final[int] = len(display_id(None)) +TRANSPOSE_WIDTH: Final[int] = len(display_transpose(None)) +VOLUME_WIDTH: Final[int] = len(display_volume(None)) + + +class TrackerBlockText: + """States a tracker block as the lines the grid prints, and reads the same form back. + + Every field carries what the grid shows in its cell, which is what makes the three states a + cell reaches a block in survive a round trip: a value reads as its value, an empty cell as + the dots beneath it, and a mixed one as the marks filling its field. A bar stands between + columns, so a line reads as the row it was taken from. + + A note names its sample by the list position the grid prints, so a block carried to another + project plays whichever sample stands at that position there. + """ + + def __init__(self, *, samples: SampleDirectory) -> None: + self._samples = samples + + def state(self, block: TrackerBlock, region: TrackerRegion) -> str: + """The text a copy puts on the system clipboard, the region supplying the shape. + + The region is what states the slots the block stands on, since a mixed cell leaves its + key out and a block alone therefore names less than the rectangle it was read from. + """ + shape = BlockShape( + rows=len(region.rows), + first=region.first_slot, + last=region.last_slot, + ) + lines = [state_header(grid=TRACKER_GRID, span_key=SLOT_KEY, shape=shape)] + lines.extend( + self._state_row( + block, + region, + row_offset, + ) + for row_offset in range(shape.rows) + ) + return "\n".join(lines) + + def parse(self, text: str) -> Optional[TrackerBlock]: + """The block a text states, present while it is one this grid writes. + + Text naming another grid, declaring a shape its lines do not fill, or carrying a field + the form has no reading for states no block, so the slot the tracker copied into stands. + """ + lines = text.strip().splitlines() + if not lines: + return None + + shape = parse_header(lines[0], grid=TRACKER_GRID, span_key=SLOT_KEY) + if shape is None or shape.last >= SLOT_COUNT or len(lines) != shape.rows + 1: + return None + + return self._read_rows(lines[1:], shape) + + def _state_row( + self, + block: TrackerBlock, + region: TrackerRegion, + row_offset: int, + ) -> str: + """One row of the block, its fields in slot order and its columns held apart by a bar.""" + base = column_slot_base(slot_from_flat(region.first_slot).generator) + fields: List[str] = [] + for position, slot in enumerate(region.slots): + if position > 0 and slot.generator != region.slots[position - 1].generator: + fields.append(COLUMN_SEPARATOR) + + key = (row_offset, region.first_slot + position - base) + fields.append(self._state_slot(block, slot.subcolumn, key)) + + return " ".join(fields) + + def _state_slot( + self, + block: TrackerBlock, + subcolumn: SubColumn, + key: BlockKey, + ) -> str: + match subcolumn: + case SubColumn.INSTRUMENT: + return self._state_note(block.notes, key) + case SubColumn.TRANSPOSE: + return self._state_number( + block.transposes, + key, + display_transpose, + TRANSPOSE_WIDTH, + ) + case SubColumn.VOLUME: + return self._state_number( + block.volumes, + key, + display_volume, + VOLUME_WIDTH, + ) + + def _state_note( + self, + notes: Dict[BlockKey, Optional[BlockNote]], + key: BlockKey, + ) -> str: + """What the note column prints at a cell, a sample naming the position it stands at. + + A note whose sample the project in place lacks prints as mixed, so reading the text back + passes that cell by, the way a paste passes over a sample it has nothing to place. + """ + if key not in notes: + return state_mixed(NOTE_WIDTH) + + match notes[key]: + case NoteOff(): + return NOTE_OFF + case str() as sample_id: + position = self._samples.position_of(sample_id) + return state_mixed(NOTE_WIDTH) if position is None else display_id(position) + case _: + return display_id(None) + + @staticmethod + def _state_number( + values: Dict[BlockKey, Optional[int]], + key: BlockKey, + display: Callable[[Optional[int]], str], + width: int, + ) -> str: + if key not in values: + return state_mixed(width) + + return display(values[key]) + + def _read_rows( + self, + lines: List[str], + shape: BlockShape, + ) -> Optional[TrackerBlock]: + """The block a body states, each kind of subcolumn gathered into a map of its own.""" + base = column_slot_base(slot_from_flat(shape.first).generator) + notes: Dict[BlockKey, Optional[BlockNote]] = {} + transposes: Dict[BlockKey, Optional[int]] = {} + volumes: Dict[BlockKey, Optional[int]] = {} + for row_offset, line in enumerate(lines): + fields = line.replace(COLUMN_SEPARATOR, " ").split() + if len(fields) != shape.width: + return None + + for position, field in enumerate(fields): + slot = slot_from_flat(shape.first + position) + key = (row_offset, shape.first + position - base) + match slot.subcolumn: + case SubColumn.INSTRUMENT: + read = store_reading( + notes, + key, + self._read_note(field), + ) + case SubColumn.TRANSPOSE: + read = store_reading( + transposes, + key, + self._read_transpose(field), + ) + case SubColumn.VOLUME: + read = store_reading( + volumes, + key, + self._read_volume(field), + ) + + if not read: + return None + + return TrackerBlock( + notes=notes, + transposes=transposes, + volumes=volumes, + ) + + def _read_note(self, field: str) -> Optional[FieldReading[BlockNote]]: + """The note a field states: the sample standing at the position it names, a cut, or emptiness. + + A position the project's samples fall short of states nothing, so a paste passes that + cell by rather than silencing it. + """ + placeholder: Optional[FieldReading[BlockNote]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + if field == NOTE_OFF: + return FieldReading.of(NoteOff()) + + position = read_hexadecimal(field) + if position is None: + return None + + sample_id = self._samples.sample_at(position) + return FieldReading.mixed() if sample_id is None else FieldReading.of(sample_id) + + @staticmethod + def _read_transpose(field: str) -> Optional[FieldReading[int]]: + """The transpose a signed field states, present while it lies in the range a row accepts.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + sign = field[:1] + magnitude = read_hexadecimal(field[1:]) + if sign not in SIGNS or magnitude is None: + return None + + transpose = magnitude if sign == PLUS else -magnitude + if not MIN_TRANSPOSE <= transpose <= MAX_TRANSPOSE: + return None + + return FieldReading.of(transpose) + + @staticmethod + def _read_volume(field: str) -> Optional[FieldReading[int]]: + """The volume a field states, present while it lies in the range a row accepts.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + volume = read_hexadecimal(field) + if volume is None or not SILENT_VOLUME <= volume <= MAX_VOLUME: + return None + + return FieldReading.of(volume) diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py index df31e3c6..9b26290c 100644 --- a/src/sampletones_application/utils/gui/clipboard.py +++ b/src/sampletones_application/utils/gui/clipboard.py @@ -1,10 +1,29 @@ import threading +from typing import Protocol, cast import dearpygui.dearpygui as dpg from sampletones_application.utils.gui.dpg import dpg_configure_item +class TextClipboard(Protocol): + """The clipboard the desktop shares between applications, as text going out and coming back.""" + + def read(self) -> str: ... + + def write(self, text: str) -> None: ... + + +class SystemTextClipboard: + """The desktop's clipboard, reached through the one DearPyGui holds for the viewport.""" + + def read(self) -> str: + return cast(str, dpg.get_clipboard_text()) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) + + def copy_to_clipboard( text: str, label: str, @@ -12,7 +31,7 @@ def copy_to_clipboard( *, copied_label: str, ) -> None: - dpg.set_clipboard_text(text) + SystemTextClipboard().write(text) dpg_configure_item(button_tag, label=copied_label) diff --git a/tests/suite/surface.py b/tests/suite/surface.py index e13a4227..052ff2e9 100644 --- a/tests/suite/surface.py +++ b/tests/suite/surface.py @@ -2,9 +2,15 @@ from typing import Callable, Final, List, Optional from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ClipboardItems -from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface -from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( + ClipboardItems, +) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) +from sampletones_application.ui.panels.sequencer.grid.surface.targets import ( + CursorTargets, +) from sampletones_application.ui.panels.sequencer.input.state import GridInputState from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS from tests.suite.shortcuts import shipped_source diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 8c52f87b..b0e7d778 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -20,7 +20,13 @@ ALL_CHANNELS, SequencerChannelsLogic, ) -from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.clipboard import ( + OrderBlockText, + ParsedBlockCache, + ProjectSampleDirectory, + SequencerClipboard, + TrackerBlockText, +) from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail from sampletones_application.logic.sequencer.order import ( OrderBlockReader, @@ -1347,13 +1353,27 @@ def test_player_returns_the_guarded_wrapper( ) +class FakeTextClipboard: + """The desktop's clipboard, held in memory so a test reads what a copy put there.""" + + def __init__(self) -> None: + self.text: str = "" + + def read(self) -> str: + return self.text + + def write(self, text: str) -> None: + self.text = text + + @pytest.fixture def block_coordinator() -> SequencerTabCoordinator: """A coordinator whose block path is real, from the tracker logic through to the clipboard. A real manager observes the same controller production wires it to, so a test reads the entries a gesture actually records, and the hooks are the ones ``_wire_block_callbacks`` - assigns rather than wrappers a test built to look like them. + assigns rather than wrappers a test built to look like them. The system clipboard is the one + boundary standing in, since the desktop's own is reached through a running viewport. """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) @@ -1365,6 +1385,11 @@ def block_coordinator() -> SequencerTabCoordinator: instance._history = history instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() + instance._system_clipboard = FakeTextClipboard() + instance._tracker_block_text = TrackerBlockText(samples=ProjectSampleDirectory(controller)) + instance._order_block_text = OrderBlockText() + instance._tracker_text_cache = ParsedBlockCache(instance._tracker_block_text.parse) + instance._order_text_cache = ParsedBlockCache(instance._order_block_text.parse) instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) instance._sequencer_order_logic = SequencerOrderLogic(controller) @@ -1562,3 +1587,110 @@ def test_a_paste_with_nothing_copied_records_nothing( coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) assert len(coordinator._history.entries) == recorded + + +class TestSystemClipboardCopy: + """A copy writes both clipboards, so the same gesture reaches a paste here and elsewhere.""" + + def test_a_copy_states_the_block_as_text( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + + def test_an_order_copy_states_its_own_grid( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + + coordinator._on_order_copy_block(PULSE1_FRAME) + + assert coordinator._system_clipboard.text == "SampleToNES/1 order rows=1 positions=0..0\n00" + + def test_a_cut_states_the_block_it_took( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + + +class TestSystemClipboardPrecedence: + """Text that reads as a block for this grid stands ahead of the slot it copied into.""" + + def test_a_block_copied_elsewhere_is_the_one_a_paste_writes( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """This is a second instance's copy arriving, which is what carries a block between them.""" + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 9 + + def test_unrelated_text_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("a line from a message") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + + def test_a_truncated_block_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + + def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A tracker copy stands on the clipboard while the order pastes, so each grid keeps its own.""" + coordinator = block_coordinator + coordinator._on_order_copy_block(PULSE1_FRAME) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + + def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """The menu asks the same question the paste does, so it offers what the next press reaches.""" + coordinator = block_coordinator + + assert not coordinator._can_paste_tracker_block() + + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + assert coordinator._can_paste_tracker_block() + assert not coordinator._can_paste_order_block() diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py new file mode 100644 index 00000000..a425d534 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py @@ -0,0 +1,60 @@ +from typing import List, Optional + +from sampletones_application.logic.sequencer.clipboard.cache import ParsedBlockCache + +BLOCK = "SampleToNES/1 tracker rows=1 slots=3..5" +OTHER = "SampleToNES/1 order rows=1 positions=0..0" + + +class FakeParser: + """A parser recording every text it was put to, reading each one as its own length.""" + + def __init__(self) -> None: + self.asked: List[str] = [] + + def parse(self, text: str) -> Optional[int]: + self.asked.append(text) + return len(text) if text.startswith("SampleToNES") else None + + +class TestReadingTheSameTextTwice: + def test_a_text_asked_about_again_is_read_once(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + first = cache.block(BLOCK) + second = cache.block(BLOCK) + + assert first == second == len(BLOCK) + assert parser.asked == [BLOCK] + + def test_a_text_reading_as_no_block_is_held_the_same_way(self) -> None: + """A menu opening over unrelated text costs one comparison, as one over a block does.""" + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + assert cache.block("a message") is None + assert cache.block("a message") is None + assert parser.asked == ["a message"] + + +class TestReadingAnotherText: + def test_text_replaced_on_the_clipboard_is_read_afresh(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + cache.block(BLOCK) + second = cache.block(OTHER) + + assert second == len(OTHER) + assert parser.asked == [BLOCK, OTHER] + + def test_returning_to_an_earlier_text_reads_it_again(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + cache.block(BLOCK) + cache.block(OTHER) + + assert cache.block(BLOCK) == len(BLOCK) + assert parser.asked == [BLOCK, OTHER, BLOCK] diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py new file mode 100644 index 00000000..cd3eec82 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import List, Optional + +import pytest + +from sampletones_application.logic.sequencer.clipboard.header import ( + BLOCK_MAGIC, + BlockShape, + parse_header, + state_header, +) + +GRID = "tracker" +SPAN_KEY = "slots" + + +def _parse(line: str) -> Optional[BlockShape]: + return parse_header(line, grid=GRID, span_key=SPAN_KEY) + + +class TestStating: + def test_a_header_names_the_grid_and_the_shape(self) -> None: + shape = BlockShape(rows=4, first=3, last=11) + + line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape) + + assert line == f"{BLOCK_MAGIC} tracker rows=4 slots=3..11" + + def test_a_span_of_one_slot_names_the_same_bound_twice(self) -> None: + shape = BlockShape(rows=1, first=7, last=7) + + line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape) + + assert line == f"{BLOCK_MAGIC} tracker rows=1 slots=7..7" + + +class TestParsing: + def test_a_stated_header_reads_back_as_the_shape_it_named(self) -> None: + shape = BlockShape(rows=4, first=3, last=11) + + assert _parse(state_header(grid=GRID, span_key=SPAN_KEY, shape=shape)) == shape + + def test_the_width_counts_both_bounds(self) -> None: + assert BlockShape(rows=1, first=3, last=11).width == 9 + + def test_surrounding_spaces_leave_the_shape_as_it_stands(self) -> None: + assert _parse(f" {BLOCK_MAGIC} tracker rows=2 slots=0..2 ") == BlockShape(rows=2, first=0, last=2) + + +@dataclass(frozen=True) +class RefusalCase: + name: str + line: str + + +REFUSALS: List[RefusalCase] = [ + RefusalCase("another application", "Tracker/1 tracker rows=2 slots=0..2"), + RefusalCase("another grid", f"{BLOCK_MAGIC} order rows=2 slots=0..2"), + RefusalCase("another span", f"{BLOCK_MAGIC} tracker rows=2 positions=0..2"), + RefusalCase("a missing span", f"{BLOCK_MAGIC} tracker rows=2"), + RefusalCase("a trailing word", f"{BLOCK_MAGIC} tracker rows=2 slots=0..2 more"), + RefusalCase("no rows at all", f"{BLOCK_MAGIC} tracker rows=0 slots=0..2"), + RefusalCase("a fractional count", f"{BLOCK_MAGIC} tracker rows=2.5 slots=0..2"), + RefusalCase("a negative count", f"{BLOCK_MAGIC} tracker rows=-2 slots=0..2"), + RefusalCase("bounds out of order", f"{BLOCK_MAGIC} tracker rows=2 slots=11..3"), + RefusalCase("one bound", f"{BLOCK_MAGIC} tracker rows=2 slots=3"), + RefusalCase("a wordy bound", f"{BLOCK_MAGIC} tracker rows=2 slots=three..11"), + RefusalCase("a label with no value", f"{BLOCK_MAGIC} tracker rows slots=3..11"), + RefusalCase("a line of prose", "have a look at this pattern"), + RefusalCase("nothing at all", ""), +] + + +class TestRefusals: + """A header states this grid's form, and anything else states no shape at all.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_a_header_this_grid_never_wrote_states_no_shape(self, case: RefusalCase) -> None: + assert _parse(case.line) is None diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py new file mode 100644 index 00000000..be444d6b --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_application.logic.sequencer.clipboard.order import OrderBlockText +from sampletones_application.logic.sequencer.order.block import OrderBlock +from sampletones_application.view_model.sequencer.region import OrderRegion + + +@pytest.fixture +def text() -> OrderBlockText: + return OrderBlockText() + + +def _region( + *, + rows: int = 1, + first_position: int = 0, + positions: int = 1, + first_row: int = 0, +) -> OrderRegion: + return OrderRegion( + first_row=first_row, + last_row=first_row + rows - 1, + first_position=first_position, + last_position=first_position + positions - 1, + ) + + +def _body(text: OrderBlockText, block: OrderBlock, region: OrderRegion) -> List[str]: + return text.state(block, region).splitlines()[1:] + + +class TestTheFormAFieldTakes: + """Every field carries what the table shows in its cell.""" + + def test_a_pattern_prints_the_index_the_table_shows(self, text: OrderBlockText) -> None: + block = OrderBlock(entries={(0, 0): 1, (0, 1): 26}) + + assert _body(text, block, _region(positions=2)) == ["01 1A"] + + def test_a_silent_slot_prints_the_dots_beneath_it(self, text: OrderBlockText) -> None: + assert _body(text, OrderBlock(entries={(0, 0): None}), _region()) == [".."] + + def test_a_mixed_cell_fills_its_field_with_marks(self, text: OrderBlockText) -> None: + assert _body(text, OrderBlock(entries={}), _region()) == ["??"] + + def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: OrderBlockText) -> None: + block = OrderBlock(entries={(0, 0): 0, (1, 0): 1, (2, 0): None}) + + assert _body(text, block, _region(rows=3)) == ["00", "01", ".."] + + +class TestTheShapeAStatementCovers: + def test_a_header_opens_the_text_with_the_grid_and_the_positions(self, text: OrderBlockText) -> None: + region = _region(rows=3, first_position=5, positions=4) + + header = text.state(OrderBlock(entries={}), region).splitlines()[0] + + assert header == "SampleToNES/1 order rows=3 positions=5..8" + + +@dataclass(frozen=True) +class RoundTripCase: + name: str + block: OrderBlock + region: OrderRegion + + +ROUND_TRIPS: List[RoundTripCase] = [ + RoundTripCase( + "the three states across one row", + OrderBlock(entries={(0, 0): 3, (0, 1): None}), + _region(positions=3), + ), + RoundTripCase( + "a block starting past the first frame", + OrderBlock(entries={(0, 0): 1, (0, 1): 2}), + _region(first_position=7, positions=2), + ), + RoundTripCase( + "every channel row", + OrderBlock(entries={(0, 0): 1, (1, 0): 1, (2, 0): 2, (3, 0): None, (4, 0): 0}), + _region(rows=5, positions=1), + ), + RoundTripCase( + "the master row over channels that disagree", + OrderBlock(entries={(1, 0): 1, (1, 1): 2, (2, 0): 1}), + _region(rows=3, positions=2), + ), + RoundTripCase( + "an index past a single digit", + OrderBlock(entries={(0, 0): 255}), + _region(), + ), +] + + +class TestRoundTrip: + """A block stated as text and read back is the block it set out as.""" + + @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name) + def test_a_block_survives_being_stated_and_read( + self, + text: OrderBlockText, + case: RoundTripCase, + ) -> None: + assert text.parse(text.state(case.block, case.region)) == case.block + + def test_a_master_row_the_channels_disagree_over_states_nothing(self, text: OrderBlockText) -> None: + """Its marks reach the reading as an absent key, so a paste passes that cell by.""" + stated = text.state(OrderBlock(entries={(0, 1): 4}), _region(positions=2)) + + assert text.parse(stated) == OrderBlock(entries={(0, 1): 4}) + + +class TestTextTypedByHand: + def test_hexadecimal_reads_in_either_case(self, text: OrderBlockText) -> None: + upper = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0a 1f") + lower = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0A 1F") + + assert upper == lower + assert upper == OrderBlock(entries={(0, 0): 10, (0, 1): 31}) + + def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: OrderBlockText) -> None: + assert text.parse("SampleToNES/1 order rows=1 positions=0..0\n01\n") is not None + + +@dataclass(frozen=True) +class RefusalCase: + name: str + text: str + + +HEADER = "SampleToNES/1 order rows=2 positions=0..1" + +REFUSALS: List[RefusalCase] = [ + RefusalCase("nothing at all", ""), + RefusalCase("unrelated text", "the order goes\nintro then verse"), + RefusalCase("a header alone", HEADER), + RefusalCase("a truncated body", f"{HEADER}\n01 02"), + RefusalCase("a body reaching past the header", f"{HEADER}\n01 02\n01 02\n01 02"), + RefusalCase("a line short of a field", f"{HEADER}\n01\n01 02"), + RefusalCase("a line with a field too many", f"{HEADER}\n01 02 03\n01 02"), + RefusalCase("a tracker's block", "SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F"), + RefusalCase("more rows than the table has", "SampleToNES/1 order rows=6 positions=0..0\n01\n01\n01\n01\n01\n01"), + RefusalCase("a word in a field", f"{HEADER}\nxx 02\n01 02"), + RefusalCase("a signed index", f"{HEADER}\n+1 02\n01 02"), + RefusalCase("dots and marks in one field", f"{HEADER}\n.? 02\n01 02"), +] + + +class TestRefusals: + """Text this table never wrote states no block, so the slot the order copied into stands.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_text_outside_the_form_states_no_block( + self, + text: OrderBlockText, + case: RefusalCase, + ) -> None: + assert text.parse(case.text) is None diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py new file mode 100644 index 00000000..6e374b73 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py @@ -0,0 +1,74 @@ +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.clipboard.samples import ( + ProjectSampleDirectory, +) +from sampletones_core.constants.enums import GeneratorName +from tests.suite.sequencer import sample_reconstruction + + +@pytest.fixture +def controller() -> ProjectController: + controller = ProjectController(ProjectManager()) + controller.new() + return controller + + +@pytest.fixture +def directory(controller: ProjectController) -> ProjectSampleDirectory: + return ProjectSampleDirectory(controller) + + +def _add_sample(controller: ProjectController, name: str) -> str: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1]), + name=name, + ) + return sample.id + + +class TestReadingBothWays: + def test_a_sample_stands_at_the_position_it_is_listed_at( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + first = _add_sample(controller, "kick") + second = _add_sample(controller, "snare") + + assert directory.position_of(first) == 0 + assert directory.position_of(second) == 1 + assert directory.sample_at(0) == first + assert directory.sample_at(1) == second + + def test_a_sample_the_project_lacks_stands_nowhere( + self, + directory: ProjectSampleDirectory, + ) -> None: + assert directory.position_of("absent") is None + + def test_a_position_the_list_falls_short_of_names_no_sample( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + _add_sample(controller, "kick") + + assert directory.sample_at(1) is None + assert directory.sample_at(-1) is None + + +class TestFollowingTheProject: + def test_a_sample_added_later_is_reached( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + """The project is read on each lookup, so an undo putting another one in place is followed.""" + assert directory.sample_at(0) is None + + added = _add_sample(controller, "hat") + + assert directory.sample_at(0) == added diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py new file mode 100644 index 00000000..2a20eb0d --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py @@ -0,0 +1,289 @@ +from dataclasses import dataclass +from typing import List, Optional + +import pytest + +from sampletones_application.logic.sequencer.clipboard.tracker import TrackerBlockText +from sampletones_application.logic.sequencer.tracker.block import TrackerBlock +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff + +SAMPLE_IDS: List[str] = ["kick", "snare", "hat"] + + +class FakeSampleDirectory: + """A list of samples, standing where the project's own list would.""" + + def __init__(self, sample_ids: List[str]) -> None: + self._sample_ids = sample_ids + + def position_of(self, sample_id: str) -> Optional[int]: + if sample_id not in self._sample_ids: + return None + + return self._sample_ids.index(sample_id) + + def sample_at(self, position: int) -> Optional[str]: + if 0 <= position < len(self._sample_ids): + return self._sample_ids[position] + + return None + + +@pytest.fixture +def text() -> TrackerBlockText: + return TrackerBlockText(samples=FakeSampleDirectory(SAMPLE_IDS)) + + +def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: + return TrackerSlot(generator, subcolumn).flat_index + + +def _region( + *, + first_slot: int, + last_slot: int, + rows: int = 1, +) -> TrackerRegion: + return TrackerRegion( + first_row=0, + last_row=rows - 1, + first_slot=first_slot, + last_slot=last_slot, + ) + + +PULSE1_CELL = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), +) + + +def _body(text: TrackerBlockText, block: TrackerBlock, region: TrackerRegion) -> List[str]: + return text.state(block, region).splitlines()[1:] + + +class TestTheFormAFieldTakes: + """Every field carries what the grid shows in its cell, each kind in its own width.""" + + def test_a_cell_of_values_prints_the_three_the_grid_prints(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): "snare"}, transposes={(0, 1): 0}, volumes={(0, 2): 15}) + + assert _body(text, block, PULSE1_CELL) == ["01 +00 F"] + + def test_an_empty_cell_prints_the_dots_beneath_it(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): None}, transposes={(0, 1): None}, volumes={(0, 2): None}) + + assert _body(text, block, PULSE1_CELL) == [".. ... ."] + + def test_a_mixed_cell_fills_its_fields_with_marks(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] + + def test_a_cut_prints_the_mark_the_note_column_shows(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): NoteOff()}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["~~ ??? ?"] + + def test_a_transpose_below_zero_prints_its_sign(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={(0, 1): -10}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? -0A ?"] + + def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: TrackerBlockText) -> None: + """A paste has nothing to place for it, so the text states nothing about that cell.""" + block = TrackerBlock(notes={(0, 0): "cowbell"}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] + + +class TestTheShapeAStatementCovers: + def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None: + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + rows=4, + ) + + header = text.state(TrackerBlock(notes={}, transposes={}, volumes={}), region).splitlines()[0] + + assert header == "SampleToNES/1 tracker rows=4 slots=3..8" + + def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None: + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + ) + + assert _body(text, TrackerBlock(notes={}, transposes={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"] + + def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={}) + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=3, + ) + + assert _body(text, block, region) == ["?? +01 ?", "?? ??? ?", "?? +03 ?"] + + +@dataclass(frozen=True) +class RoundTripCase: + name: str + block: TrackerBlock + region: TrackerRegion + + +ROUND_TRIPS: List[RoundTripCase] = [ + RoundTripCase( + "the three states across one cell", + TrackerBlock(notes={(0, 0): "kick"}, transposes={(0, 1): None}, volumes={}), + PULSE1_CELL, + ), + RoundTripCase( + "a cut and an empty note", + TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "the whole transpose range", + TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=3, + ), + ), + RoundTripCase( + "the whole volume range", + TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "a block anchored at the sample column", + TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}), + _region( + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + ), + ), + RoundTripCase( + "a block starting and ending mid-cell", + TrackerBlock(notes={(0, 3): "snare"}, transposes={(0, 1): 5, (0, 4): None}, volumes={(0, 2): 3}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), + ), + ), + RoundTripCase( + "the whole grid", + TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}), + _region( + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.NOISE, SubColumn.VOLUME), + rows=2, + ), + ), +] + + +class TestRoundTrip: + """A block stated as text and read back is the block it set out as.""" + + @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name) + def test_a_block_survives_being_stated_and_read( + self, + text: TrackerBlockText, + case: RoundTripCase, + ) -> None: + assert text.parse(text.state(case.block, case.region)) == case.block + + def test_a_note_reaches_the_sample_standing_at_its_position(self, text: TrackerBlockText) -> None: + """The position is what crosses, so a block lands on the list the reading project holds.""" + block = TrackerBlock(notes={(0, 0): "snare"}, transposes={}, volumes={}) + stated = text.state(block, PULSE1_CELL) + + elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass", "clap"])) + + assert elsewhere.parse(stated) == TrackerBlock(notes={(0, 0): "clap"}, transposes={}, volumes={}) + + def test_a_position_the_reading_list_falls_short_of_states_nothing(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): "hat"}, transposes={}, volumes={}) + stated = text.state(block, PULSE1_CELL) + + elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass"])) + + assert elsewhere.parse(stated) == TrackerBlock(notes={}, transposes={}, volumes={}) + + +class TestTextTypedByHand: + """The form is readable, so a reader typing it reaches the same block a copy would.""" + + def test_hexadecimal_reads_in_either_case(self, text: TrackerBlockText) -> None: + upper = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0a f") + lower = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0A F") + + assert upper == lower + assert upper == TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 1): -10}, volumes={(0, 2): 15}) + + def test_the_bars_between_columns_are_a_reading_aid(self, text: TrackerBlockText) -> None: + with_bars = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F | .. ... .") + without = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F .. ... .") + + assert with_bars is not None + assert with_bars == without + + def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: TrackerBlockText) -> None: + assert text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F\n") is not None + + +@dataclass(frozen=True) +class RefusalCase: + name: str + text: str + + +HEADER = "SampleToNES/1 tracker rows=2 slots=3..5" + +REFUSALS: List[RefusalCase] = [ + RefusalCase("nothing at all", ""), + RefusalCase("unrelated text", "check out this riff\nit goes hard"), + RefusalCase("a header alone", HEADER), + RefusalCase("a truncated body", f"{HEADER}\n01 +00 F"), + RefusalCase("a body reaching past the header", f"{HEADER}\n01 +00 F\n01 +00 F\n01 +00 F"), + RefusalCase("a line short of a field", f"{HEADER}\n01 +00\n01 +00 F"), + RefusalCase("a line with a field too many", f"{HEADER}\n01 +00 F 2\n01 +00 F"), + RefusalCase("an order's block", "SampleToNES/1 order rows=1 positions=0..1\n01 02"), + RefusalCase("a slot past the grid", "SampleToNES/1 tracker rows=1 slots=13..15\n01 +00 F"), + RefusalCase("a word in a note field", f"{HEADER}\nxx +00 F\n01 +00 F"), + RefusalCase("an unsigned transpose", f"{HEADER}\n01 12 F\n01 +00 F"), + RefusalCase("a transpose past the range", f"{HEADER}\n01 +40 F\n01 +00 F"), + RefusalCase("a transpose below the range", f"{HEADER}\n01 -40 F\n01 +00 F"), + RefusalCase("a volume past the range", f"{HEADER}\n01 +00 FF\n01 +00 F"), + RefusalCase("dots and marks in one field", f"{HEADER}\n.? +00 F\n01 +00 F"), +] + + +class TestRefusals: + """Text this grid never wrote states no block, so the slot the tracker copied into stands.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_text_outside_the_form_states_no_block( + self, + text: TrackerBlockText, + case: RefusalCase, + ) -> None: + assert text.parse(case.text) is None From 79b4465076cec975374e93dbd6fcf9b9cae46d63 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:55:45 +0200 Subject: [PATCH 26/28] Documented: selection shapes, drag travel and the clipboard form --- docs/development/sequencer-blocks.md | 110 +++++++++++++++++++++++++-- docs/guide/interface.md | 4 +- docs/guide/sequencer.md | 32 ++++++-- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 78479db7..142b2652 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -4,8 +4,9 @@ A **block** is a rectangle of one sequencer grid, lifted out of the song so it c written back somewhere else. Copy, cut, paste and delete are the four gestures over it, and both grids — the tracker's pattern rows and the order's frames — carry the same set. -This document states the rules those gestures follow, and how a grid's actions reach the -menus and the keyboard that fire them. The layering they sit in is +This document states the rules those gestures follow, how a block leaves the app as text, +how a selection is drawn, and how a grid's actions reach the menus and the keyboard that +fire them. The layering they sit in is [Architecture](architecture.md); the conventions the code is held to are the [coding guidelines](guidelines.md). @@ -101,6 +102,58 @@ Each cell reaches the grid through the single-cell adjustment that already gover pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make — the transpose and volume ranges included. +## A block states itself as text + +A copy also writes the block to the desktop's clipboard, as the lines the grid prints — a +tracker block: + +``` +SampleToNES/1 tracker rows=2 slots=3..5 +00 +05 3 +.. -02 . +``` + +and an order block: + +``` +SampleToNES/1 order rows=1 positions=0..1 +00 03 +``` + +The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and +strings alone; the desktop's clipboard is reached through +`utils/gui/clipboard.py::TextClipboard`, one more piece of external behaviour standing behind +a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires +the two. + +**A field prints what the grid prints in its cell**, which is what carries the three states +across: a value reads as its value, an empty cell as the dots beneath it, and a mixed one as +the marks filling its field. The marks fill the whole width, so every line measures the same +and a block pasted into a message still reads as a grid; reading takes any run of them. + +**The header is a declaration the body is held to.** It names the grid, the count of rows, and +the span of slots or positions the block stands on, and a body whose lines or fields disagree +with it states no block. The span also carries the alignment a tracker block needs, since the +first slot decides which subcolumn the block opens on. + +**A note names its sample by list position**, the figure the grid prints, so a block carried to +another project plays whichever sample stands at that position there. A position the project's +list falls short of reads as mixed, which is what the writer already makes of a sample it has +nothing to place. + +A field the form has no reading for refuses the whole text, so a parse answers with a block or +with nothing. Digits are read in either case, and transpose and volume are held to the ranges a +row accepts, so text typed by hand lands the values the grid would. + +### Which block a paste writes + +A copy writes both clipboards, and a paste reads the desktop's text first: it stands while it +parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a +block copied in a second instance pastes here, and a copy taken in this one survives whatever +else the desktop picks up afterwards. `can_paste_block` asks the same question through a +`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one +string compare. + ## A grid declares its actions once Where they are shown is decided by whoever asks for them. Each grid builds its whole @@ -146,6 +199,23 @@ covers is its coalescing target, so a streak over one selection leaves a single shift after the cursor moves or the selection is reached out starts the next entry. Transpose and volume count separately, each carrying its own action. +## A shape selects to the grid's own edges + +`Ctrl+A` and its neighbours select a whole shape at once. Each shape is stated on the input +state as a run of bounds along one axis — slots in the tracker, rows in the order — handed to a +single builder that spans the other axis to the grid's full extent and lands the cursor on the +far corner. The whole frame, a column and a subcolumn are therefore three namings of one +rectangle, as the whole order and a channel row are of the other, and a grid laying out nothing +keeps the selection it had. + +The aggregate is an ordinary member of the axis here: selecting the **Sample** column selects a +column the way selecting a channel does, and the **Master** row a row. + +A press names its shape from the cell the cursor stands on, which is the cell the context menu's +items name too, so a key and an item reach the same rectangle. In the tracker a shape ends at +the frame's last row, so standing one carries the grid to where the cursor landed — the same +reveal a `Shift+End` reach makes. + ## Dragging a range out Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what @@ -167,13 +237,41 @@ position lookup is arithmetic in the same way, taking its pitch from the first t columns; its channel lookup walks the rows, because the master row stands apart from the channels beneath it. +### A drag past the edge carries the view + +A pointer held past the cells on screen travels the grid under it, so a selection reaches +further than the viewport holds. `grid/scroll/` states this in three pieces: a `ScrollAxis` +naming the one DearPyGui axis a table scrolls along and the pointer coordinate that runs past +its edges, a `TravelBand` saying where the cells stand along that axis, and the `DragTravel` +that reads the two each frame. The tracker travels vertically and the order horizontally, both +from the same class. + +Three rules make the travel feel like one gesture: + +- **The pointer report drives it.** A held pointer keeps reporting wherever it is carried to, + including past the window, so the travel runs off the same report the drag itself reads. +- **The frame's own duration paces it**, so the same stretch of grid passes under the pointer + however fast the frames arrive. The pace answers how far past the edge the pointer stands, + rising from a floor to a ceiling over a few cells' overshoot: a nudge creeps, a reach covers + the grid. +- **Each step is added to the offset last issued.** A table reports the scroll it was drawn + with rather than the one just set, so a travel reading it back would re-issue an offset it + has already reached. It rests as soon as the pointer stands within the band again, at the + press that opens the next gesture, and on a rebuild — and the travel that follows sets out + from the offset the grid is drawn with. + ## Accepted limitations - **A rebuilt table has no selection.** Both grids reconstruct their input state on rebuild, so following playback and the rebuild after a growing paste leave the cursor and drop the selection. The rows a region named belong to the body that was replaced. - **The selection stays put after a paste** rather than becoming the pasted footprint. -- **Cross-project paste is lossy in the note column and exact in transpose and volume.** - A slot survives a project close, because it must survive `on_project_replaced`, which - fires on every undo; a note naming a sample the project in place lacks is left out of - the write, and the target keeps what it had. +- **A note crosses a project by whichever route it took.** The in-app slot survives a project + close, because it must survive `on_project_replaced`, which fires on every undo, and it names + its sample by id: a note whose sample the project in place lacks is left out of the write, and + the target keeps what it had. The clipboard's text names a list position instead, so the same + note pasted through it plays whichever sample stands at that position. Transpose and volume + are exact by either route. +- **A drag past the edge and the followed playhead both write the scroll.** With **Follow rows** + on during playback, `_reveal_playing_row` carries the sounding row to the head of the band + while a held pointer travels the grid, so the two take turns each frame. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 12bc7805..efdef079 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -79,7 +79,9 @@ Each menu covers one kind of work: **File** for projects, **Edit** for undo, red and what you can do where your cursor stands, **Reconstruction** for the current reconstruction and its exports, **Playback** for playing and for muting the sequencer's channels, **View** for settings and the window, and **Help** for -**About**. +**About**. What **Edit** offers below undo and redo follows your cursor: the block +actions of the sequencer grid you are in, or the actions of the sample you have +picked in the **Samples** list. Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index de22cb8d..d0e24d90 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -18,8 +18,9 @@ the project already has samples, _SampleToNES_ warns with **Different NES frequency**; **Add anyway** adds it regardless. Manage the imported samples in the **Samples** list on the right: right-click one -to **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** -flag. Removing a sample that patterns still use asks **Remove sample** first, +to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its +**Loop** flag. The **Edit** menu carries the same actions for the sample you have +picked. Removing a sample that patterns still use asks **Remove sample** first, because it clears every row that references it. ## Writing a pattern @@ -46,22 +47,30 @@ own so you can change it on its own, and **Insert frame**, **Clear frame**, Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and delete in one go. Hold `Shift` and press the arrow keys to reach out from the cursor, or drag the pointer across the cells; `Shift`+click carries the selection to -the cell you click. Any plain move, and `Escape`, puts it away again. +the cell you click. Dragging past the edge of a grid scrolls it along, so a selection +can run further than the screen shows. Any plain move, and `Escape`, puts the +selection away again. | Key | Action | |-----|--------| | `Shift`+arrows | Reach the selection out a cell at a time | | `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) | +| `Ctrl+A` | Select the whole frame, or the whole order | +| `Ctrl+Shift+A` | Select the column you are in (tracker), or your channel's row (order) | +| `Ctrl+Alt+A` | Select the subcolumn you are in (tracker) | | `Ctrl+C` | Copy | | `Ctrl+X` | Cut — copy, then empty what was selected | | `Ctrl+V` | Paste, starting at the cursor | | `Del` | Empty the selection | -With nothing selected these act on the cell the cursor stands on, so copying one -cell needs no selection first. The same four sit on each grid's right-click menu: -raised inside a selection they act on the whole of it, raised anywhere else on the -cell you clicked. Each grid keeps its own copy, so a tracker block pastes into the -tracker and an order block into the order. +Copy, cut, paste and delete act on the cell the cursor stands on when nothing is +selected, so copying one cell needs no selection first. All four sit on each grid's +right-click menu: raised inside a selection they act on the whole of it, raised +anywhere else on the cell you clicked. Each grid keeps its own copy, so a tracker +block pastes into the tracker and an order block into the order. + +The **Select** keys work from the cell you are on and reach the whole length of the +grid. They sit on the right-click menu too. A paste is anchored: the block starts at the cell you paste onto and lands the rest down and to the right of it. @@ -80,6 +89,13 @@ as it was. Emptying cells keeps the rows and frames they sit in, and every block action is one step in the history, so a single **Undo** takes it all back. +A copy also goes to your desktop's clipboard as plain text, so a block carries between +two open windows of _SampleToNES_ — copy in one, paste in the other — and you can paste +one into a message to show someone what you wrote. Anything else on the clipboard +leaves you with the last block you copied here. Notes travel by their number in the +**Samples** list, so a block pasted into another project plays whichever sample holds +that number there. + ## Transposing and shading In the **Tracker**, transpose and volume move whatever the selection covers, so a From caced3765b7c2a43d6b62ee76f753d1c1b4fa5bb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 16:24:48 +0200 Subject: [PATCH 27/28] Changed: cell menus title --- .../ui/panels/sequencer/display.py | 24 +++++++- .../ui/panels/sequencer/order.py | 60 +++++++++++++++---- .../ui/panels/sequencer/tracker.py | 2 +- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 03d10e47..4b99693a 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -10,6 +10,8 @@ CellKey = Tuple[int, Optional[GeneratorName], SubColumn] CellValues = Dict[CellKey, str] +CELL_TITLE_SEPARATOR: Final[str] = " | " + _DEFAULT_LABELS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: display_id(None), SubColumn.TRANSPOSE: display_transpose(None), @@ -18,10 +20,19 @@ def indexed_label(index: int, label: str) -> str: - """Joins a formatted index and a label into one display string, e.g. ``"03 Pulse 1"``.""" + """Joins a formatted index and a label into one display string, e.g. ``"03 Bass"``.""" return f"{display_id(index)} {label}" +def cell_title(index: int, label: str) -> str: + """Names the cell a menu was raised on, e.g. ``"0C | Pulse 1"``. + + Both grids title their cell menus this way: where along the grid the cell sits, then the + channel it belongs to, so a menu states its target the same wherever it is opened. + """ + return f"{display_id(index)}{CELL_TITLE_SEPARATOR}{label}" + + def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) -> str: """Extract the pre-formatted display string for one subcolumn from a cell view model.""" match subcolumn: @@ -56,8 +67,15 @@ def subcolumn_label( is_active = ( cursor is not None and cursor.row == row and cursor.generator == generator and cursor.subcolumn == subcolumn ) - stored = cell_values.get((row, generator, subcolumn), _DEFAULT_LABELS[subcolumn]) + stored = cell_values.get( + (row, generator, subcolumn), + _DEFAULT_LABELS[subcolumn], + ) if is_active: - return pending_label(pending, stored, len(_DEFAULT_LABELS[subcolumn])) + return pending_label( + pending, + stored, + len(_DEFAULT_LABELS[subcolumn]), + ) return stored diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 9dcfb3a7..0be02c15 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -44,15 +44,20 @@ channel_tooltip, ) from sampletones_application.ui.panels.sequencer.columns import channel_color +from sampletones_application.ui.panels.sequencer.display import cell_title from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.scroll.axis import HorizontalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, +) from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, ) -from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -72,7 +77,10 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_application.utils.gui.keyboard.modifiers import ( + Modifier, + capture_modifiers, +) from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -1050,7 +1058,10 @@ def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None: header = dpg.add_text(self._row_labels[generator]) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() - self._channel_switch.add_menu_items(generator, self._current_channels) + self._channel_switch.add_menu_items( + generator, + self._current_channels, + ) def _show_context_menu( self, @@ -1059,7 +1070,12 @@ def _show_context_menu( ) -> None: target = self._surface.target_at(OrderCursor(generator, position)) with context_menu(): - header = dpg.add_text(display_id(position)) + header = dpg.add_text( + cell_title( + position, + self._row_labels[generator], + ) + ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() add_play_menu_item( @@ -1108,12 +1124,18 @@ def _add_select_items(self, cell: OrderCursor) -> None: dpg.add_menu_item( label=self._lbl_context_select_all, shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), - callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ALL, cell), + callback=lambda: self._select_shape( + ShortcutId.ORDER_SELECT_ALL, + cell, + ), ) dpg.add_menu_item( label=self._lbl_context_select_row, shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), - callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ROW, cell), + callback=lambda: self._select_shape( + ShortcutId.ORDER_SELECT_ROW, + cell, + ), ) def _add_frame_items(self, position: int) -> None: @@ -1178,12 +1200,19 @@ def _add_move_item( The action names both the direction it moves and the accelerator it prints, so the item a reader sees is the one the key press performs. """ - target = MOVE_DIRECTIONS[shortcut_id].target(position, self._position_count) + target = MOVE_DIRECTIONS[shortcut_id].target( + position, + self._position_count, + ) dpg.add_menu_item( label=label, shortcut=self._shortcuts.display(shortcut_id), enabled=target is not None, - callback=lambda: self.call(self.on_move_requested, position, target), + callback=lambda: self.call( + self.on_move_requested, + position, + target, + ), ) def _keys_active(self) -> bool: @@ -1294,10 +1323,19 @@ def _select_shape( return True def _select_all(self) -> None: - self._apply_state(self._committed_state().select_all(self._position_count)) + self._apply_state( + self._committed_state().select_all( + self._position_count, + ) + ) def _select_row(self, cell: OrderCursor) -> None: - self._apply_state(self._committed_state().select_row(cell, self._position_count)) + self._apply_state( + self._committed_state().select_row( + cell, + self._position_count, + ) + ) def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index b1734fe3..8427560d 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1323,7 +1323,7 @@ def _show_context_menu( target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( - tracker_display.indexed_label(row_index, self._column_labels[generator]), + tracker_display.cell_title(row_index, self._column_labels[generator]), ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() From f65badb98b631e7e7ccade6bbc0f28c53db7f06d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 16:35:44 +0200 Subject: [PATCH 28/28] Changed: the stacked graph ceiling --- .../coordinators/tabs/instructions.py | 6 +- .../layout/general/responsive.py | 6 +- .../parameters/instructions.py | 6 +- .../ui/elements/layout/responsive.py | 9 ++- .../layout/general/responsive.yaml | 2 +- .../parameters/test_instructions.py | 2 +- .../ui/elements/layout/test_responsive.py | 65 ++++++------------- 7 files changed, 36 insertions(+), 60 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 3b42f1b6..69742232 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -114,7 +114,7 @@ def __init__( self._side_panel_count: int self._baseline_viewport_height = layout.baseline_viewport_height self._base_graph_height = layout.base_graph_height - self._max_stack_height = layout.max_stack_height + self._max_graph_height = layout.max_graph_height self._details_width = layout.right_column_width self._right_height = layout.right_column_height self._ttl_generation_status = language_manager["instructions.library.title.generation_status_dialog"] @@ -377,13 +377,13 @@ def _sync_library_width(self) -> None: dpg_configure_item(_LEFT_COLUMN_TAG, width=width) def _sync_graph_heights(self) -> None: - """Grows the stacked graphs to share the viewport's vertical surplus equally, filling the centre column.""" + """Grows the stacked graphs to share the viewport's vertical surplus equally, up to their ceiling.""" height = stacked_graph_height( self._base_graph_height, dpg.get_viewport_client_height(), self._baseline_viewport_height, len(self._graph_panels), - self._max_stack_height, + self._max_graph_height, ) for panel in self._graph_panels: panel.set_display_height(height) diff --git a/src/sampletones_application/layout/general/responsive.py b/src/sampletones_application/layout/general/responsive.py index 138f8b83..fc632ed3 100644 --- a/src/sampletones_application/layout/general/responsive.py +++ b/src/sampletones_application/layout/general/responsive.py @@ -8,10 +8,10 @@ class ResponsiveLayout(BaseModel, extra="forbid", frozen=True): dimensions at which the side columns sit at their configured widths and the stacked graphs at their configured heights. Surplus above either baseline is shared out — width widens the side columns (``expanded_side_width``), height grows the graph stack - (``stacked_graph_height``). ``max_stack_height`` caps the combined height that a - vertical graph stack grows to before the surplus is left free. + (``stacked_graph_height``). ``max_graph_height`` is the tallest a single stacked graph + grows to, from where the surplus is left free. """ baseline_viewport_width: int baseline_viewport_height: int - max_stack_height: int + max_graph_height: int diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py index 6c90f217..10c592ea 100644 --- a/src/sampletones_application/parameters/instructions.py +++ b/src/sampletones_application/parameters/instructions.py @@ -18,7 +18,7 @@ class InstructionsTabParameters: """Everything the Instructions tab coordinator needs, shaped for the coordinator. The stacked-graph geometry — the vertical baseline, the per-graph base height, and the - ceiling the stack grows to — is flattened alongside the shared column geometry because it + ceiling each graph grows to — is flattened alongside the shared column geometry because it feeds the ``stacked_graph_height`` pure-int sink; the choice panel's slice of the general layout is narrowed to a ``PitchStepperStyle`` so the whole ``GeneralLayout`` never reaches a panel. @@ -26,7 +26,7 @@ class InstructionsTabParameters: geometry: TabGeometry baseline_viewport_height: int - max_stack_height: int + max_graph_height: int base_graph_height: int right_column_width: int right_column_height: int @@ -44,7 +44,7 @@ def from_config(cls, config: LayoutConfig) -> InstructionsTabParameters: return cls( geometry=TabGeometry.from_config(config), baseline_viewport_height=general.responsive.baseline_viewport_height, - max_stack_height=general.responsive.max_stack_height, + max_graph_height=general.responsive.max_graph_height, base_graph_height=config.graphs.dimensions.height, right_column_width=config.tabs.instructions.right_column.width, right_column_height=config.tabs.instructions.right_column.height, diff --git a/src/sampletones_application/ui/elements/layout/responsive.py b/src/sampletones_application/ui/elements/layout/responsive.py index 0253a207..8caffb6f 100644 --- a/src/sampletones_application/ui/elements/layout/responsive.py +++ b/src/sampletones_application/ui/elements/layout/responsive.py @@ -25,17 +25,16 @@ def stacked_graph_height( viewport_height: int, baseline_viewport_height: int, graph_count: int, - max_stack_height: int, + max_graph_height: int, ) -> int: """Grows each graph of a vertical stack as the viewport grows past the lowest-resolution baseline. At ``baseline_viewport_height`` — the smallest supported window — the stacked graphs sit at ``base_height`` and together fill their column. The extra room a taller viewport offers is shared - equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until the - graphs together reach ``max_stack_height``; from there each graph holds at its - ``max_stack_height // graph_count`` cap and the surplus stays free. + equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until + each graph stands at ``max_graph_height`` and holds there, leaving the remaining surplus free. The + ceiling reads as one graph's height so it stays the same however many graphs the stack holds. """ surplus = viewport_height - baseline_viewport_height expansion = max(0, round(surplus / graph_count)) - max_graph_height = max_stack_height // graph_count return min(base_height + expansion, max_graph_height) diff --git a/src/sampletones_config/layout/general/responsive.yaml b/src/sampletones_config/layout/general/responsive.yaml index c4409429..4cd9d8f4 100644 --- a/src/sampletones_config/layout/general/responsive.yaml +++ b/src/sampletones_config/layout/general/responsive.yaml @@ -1,3 +1,3 @@ baseline_viewport_width: 1280 baseline_viewport_height: 800 -max_stack_height: 1200 +max_graph_height: 350 diff --git a/tests/unit/sampletones_application/parameters/test_instructions.py b/tests/unit/sampletones_application/parameters/test_instructions.py index 47867977..3741ce0e 100644 --- a/tests/unit/sampletones_application/parameters/test_instructions.py +++ b/tests/unit/sampletones_application/parameters/test_instructions.py @@ -11,7 +11,7 @@ def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig params = InstructionsTabParameters.from_config(layout_config) assert params.baseline_viewport_height == layout_config.general.responsive.baseline_viewport_height - assert params.max_stack_height == layout_config.general.responsive.max_stack_height + assert params.max_graph_height == layout_config.general.responsive.max_graph_height assert params.base_graph_height == layout_config.graphs.dimensions.height assert params.right_column_width == layout_config.tabs.instructions.right_column.width assert params.right_column_height == layout_config.tabs.instructions.right_column.height diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py index de1b57e0..9f1f0fb1 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py @@ -10,32 +10,10 @@ from tests.suite.case import BaseRegularTestCase -@dataclass(frozen=True) -class StackedHeightCase: - label: str - base_height: int - viewport_height: int - baseline_viewport_height: int - graph_count: int - max_stack_height: int - expected: int - - -@dataclass(frozen=True) -class SideWidthCase: - label: str - base_width: int - viewport_width: int - baseline_viewport_width: int - side_panel_count: int - center_weight: int - expected: int - - class TestStackedGraphHeight(BaseTestSuite): """``stacked_graph_height`` fills a vertical graph stack at the lowest-resolution baseline, then - shares the taller viewport's surplus equally across the graphs until their combined height reaches - the configured maximum, from where each graph holds at its per-graph cap.""" + shares the taller viewport's surplus equally across the graphs until each one stands at the + configured maximum, where it holds however many graphs the stack carries.""" @dataclass(frozen=True, kw_only=True) class StackedHeightCase(BaseRegularTestCase): @@ -43,7 +21,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height: int baseline_viewport_height: int graph_count: int - max_stack_height: int + max_graph_height: int expected: int test_cases = ( @@ -53,7 +31,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=800, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=292, ), StackedHeightCase( @@ -62,7 +40,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=640, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=292, ), StackedHeightCase( @@ -71,7 +49,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1000, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=392, ), StackedHeightCase( @@ -80,7 +58,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1414, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=599, ), StackedHeightCase( @@ -89,7 +67,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1416, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=600, ), StackedHeightCase( @@ -98,7 +76,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=2200, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=600, ), StackedHeightCase( @@ -107,17 +85,17 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1100, baseline_viewport_height=800, graph_count=3, - max_stack_height=1200, + max_graph_height=600, expected=392, ), StackedHeightCase( - label="three_graphs_lower_cap", + label="three_graphs_take_the_same_cap", base_height=292, - viewport_height=1124, + viewport_height=2200, baseline_viewport_height=800, graph_count=3, - max_stack_height=1200, - expected=400, + max_graph_height=600, + expected=600, ), ) @@ -129,23 +107,22 @@ def test_height_follows_the_surplus_rule(self, case: StackedHeightCase) -> None: case.viewport_height, case.baseline_viewport_height, case.graph_count, - case.max_stack_height, + case.max_graph_height, ) == case.expected ) @pytest.mark.parametrize("viewport_height", range(600, 3000, 37)) - def test_stays_within_base_and_combined_cap( + def test_stays_between_the_base_and_the_cap( self, viewport_height: int, ) -> None: - """Across the whole viewport range each graph sits at or above its base height and the graphs - together stay within the combined maximum.""" - graph_count = 2 - max_stack_height = 1200 - height = stacked_graph_height(292, viewport_height, 800, graph_count, max_stack_height) + """Across the whole viewport range a graph sits at or above its base height and at or below + the configured maximum.""" + max_graph_height = 600 + height = stacked_graph_height(292, viewport_height, 800, 2, max_graph_height) assert height >= 292 - assert height * graph_count <= max_stack_height + assert height <= max_graph_height class TestExpandedSideWidth(BaseTestSuite):