From 32352c7940535596f8ee27e156d3e5384e9dc0e5 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 21:20:54 +0100 Subject: [PATCH 1/7] Add plan --- docs/architecture-improvements.md | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/architecture-improvements.md diff --git a/docs/architecture-improvements.md b/docs/architecture-improvements.md new file mode 100644 index 0000000..4857cb0 --- /dev/null +++ b/docs/architecture-improvements.md @@ -0,0 +1,71 @@ +# Architecture improvements + +Five sequenced phases drawn from the review in [architecture-principles.mdc](../.cursor/rules/architecture-principles.mdc). +Each phase is independently shippable. Phases 1 and 2 fix the two active bugs. +Phases 3-5 harden the foundation against recurrence. + +--- + +## Phase 1 - Cache `build_row_layout` per frame + +**Why.** `build_row_layout` rebuilds the full row list on every call: `row_descriptor`, `row_count`, `find_row`, `find_row_by_kind`, `track_row_count`, `_sub_row_visible`, and `visible_row_indices` all call it independently. A single `draw()` + input dispatch cycle can trigger a dozen rebuilds. This scatters allocation cost across the call stack and, more importantly, creates a correctness hazard: if state mutates mid-frame (unlikely now, but possible during timeline or modal transitions), callers within the same tick see different layouts. + +**What to do.** Introduce a `RowLayout` wrapper (a frozen list of `RowDescriptor` plus the helpers that operate on it). `TuningViewState` holds one `RowLayout` instead of allowing callers to rebuild on demand. `build_row_layout` becomes an internal constructor; the public surface is `state.layout`. All helpers (`row_descriptor`, `find_row`, `navigable_row_indices`, etc.) become methods or free functions on `RowLayout`. `TuningViewStateBuilder.build()` calls `build_row_layout` exactly once. + +**Scope.** `overlay.py` (layout functions), `tuning_view_state.py` (builder), any caller in `controls.py` and `overlay.py` that currently imports raw layout helpers. No behavior change; it is a mechanical refactor. + +--- + +## Phase 2 - Decouple FPS from transport color; route fps through the view builder + +**Why.** FPS is drawn at `y = padding` (top of the panel, physically on the Settings row), but its color is computed as `_row_value_color(state, transport_index)`. When the transport row is focused the FPS text turns the highlight color despite being in a different region -- this is Bug 1. The coupling exists because FPS was originally on the same Y as transport; after layout moved it, the color callback was not updated. A secondary issue: `TuningViewStateBuilder` does not set `fps`; `app.py` patches it in via `dataclasses.replace` after the builder runs, so the builder does not represent the full view state. + +**What to do.** Remove the `_row_value_color(state, transport_index)` call for FPS. Use a fixed theme constant (e.g. `DISABLED` or a dedicated `FPS_COLOR`). Move `fps` population into `TuningViewStateBuilder.build()`, passing the current fps value in at construction or as a build argument (the same way `paused` is passed today). Remove the `dataclasses.replace` patch in `app.py`. + +**Scope.** `overlay.py` (FPS draw path), `tuning_view_state.py`, `app.py` (or wherever `dataclasses.replace` lives). Small and isolated. + +--- + +## Phase 3 - Use `RowDescriptor` as the focus cursor + +**Why.** `TuningControls.focus_index` is an integer. Integer indices are unstable: adding a layer, expanding effects, or toggling settings shifts every index below the insertion point. The codebase has accumulated several repair paths to compensate (`_restore_focus`, `_refocus_track_header_if_sub_row`, arithmetic adjustments after add/delete). These are fragile and drift-prone. + +**What to do.** Replace `focus_index: int` with `focus_descriptor: RowDescriptor`. `RowDescriptor` is already a frozen dataclass with `__eq__` and identity that survives layout changes (kind + slot + effect_id + driver_slug). Navigation computes the new layout, resolves the current descriptor to its new index, applies delta or modulo, and stores the resulting descriptor. The resolved `int` is needed only for scroll math and highlight -- produce it lazily from the layout for those purposes. + +Remove `_restore_focus`, `_refocus_track_header_if_sub_row`, and the index arithmetic in add/delete handlers; they become no-ops because descriptors are stable by construction. + +**Scope.** `controls.py` (focus field + all navigation methods), `tuning_view_state.py` (view state carries `focus_descriptor`; `focus_index` becomes a derived property for callers that still need it temporarily), `overlay.py` (`_row_has_tree_focus` resolves descriptor to index via the layout). The helpers in Phase 1 make this straightforward because `RowLayout` already materializes the index→descriptor map. + +--- + +## Phase 4 - Unified focus model for the timeline bridge + +**Why.** There are two parallel focus systems: `TuningControls.focus_index` (main tree) and `session.timeline.focus_row + submenu_focused` (timeline strip). The bridge in `_move_focus` stitches them with special cases for Up-from-TRANSPORT and Down-from-RENDER_TIMELINE_HEADER. The bridge consumes both endpoints of the modulo ring, stranding `SETTINGS_HEADER` at the top of the navigable list: you can never reach Settings by wrapping from below when the timeline is open -- this is Bug 2. The asymmetric exit logic (Down past last timeline row exits to TRANSPORT, not modulo) also means the two systems have different wrap semantics. + +**What to do.** Model focus as a discriminated union: + +``` +FocusCursor = MainFocus(descriptor: RowDescriptor) | TimelineFocus(row: int) +``` + +Navigation produces a new `FocusCursor` from the old one plus a delta. Flatten the combined navigable sequence -- main navigable rows followed by timeline rows 0..N-1 -- into a single ordered list of `FocusCursor` values and apply uniform modulo wrap. The bridge special-cases disappear; the ring closes naturally. `submenu_focused` becomes a property: `isinstance(cursor, TimelineFocus)`. `session.timeline.focus_row` is written from the cursor when the cursor is a `TimelineFocus`. + +Move `FocusCursor` and the combined navigation function into `focus_context.py` (already exists for typed dependency injection) or a new `focus_nav.py`. `_move_focus` in `controls.py` becomes a one-liner delegating to it. + +**Scope.** `controls.py` (focus field, `_move_focus`, `_move_quick_focus`, timeline bridge branches), `session.py` (`timeline.submenu_focused` becomes derived), `tuning_view_state.py` (view state carries `FocusCursor`), `timeline_controls.py` (reads `TimelineFocus.row`), `overlay.py` highlight check. + +--- + +## Phase 5 - Split `overlay.py` into layout/nav and draw modules + +**Why.** `overlay.py` is ~1 800 lines combining: layout construction (`build_row_layout`), navigability rules (`navigable_row_indices`, `quick_nav_row_indices`), visibility rules (`_sub_row_visible`), label and color computation, scroll metrics, and pygame draw calls. Navigability logic duplicates visibility logic (`_sub_row_visible` and `navigable_row_indices` share the same expand/collapse branches and can drift). `RowBehavior.navigable` in `row_semantics.py` is the intended source of truth for navigability but is not consulted by the actual navigation path. Changing navigation requires reading through draw code and vice versa. + +**What to do.** Extract three modules: + +- `row_layout.py` -- `RowLayout` (from Phase 1), `build_row_layout`, `navigable_row_indices`, `quick_nav_row_indices`, `visible_row_indices`. Navigability derived from `RowBehavior.navigable` and expand/collapsed state, not from hardcoded kind sets. `_sub_row_visible` and `navigable_row_indices` share one visibility predicate. +- `overlay_draw.py` -- pygame draw logic, color computation, scroll, font, glyph calls. Consumes `RowLayout` and `TuningViewState`; imports nothing from `controls.py`. +- `overlay.py` -- thin re-export shim until call sites are updated, then removed. + +The result: adding a new row kind means updating `row_semantics.py` (descriptor) and `row_layout.py` (layout order and visibility predicate). Draw code is untouched unless the row has novel visual treatment. + +**Scope.** Large but mechanical. Phases 1-4 should land first: Phase 1 already introduces `RowLayout` as the natural home for the layout module, and Phase 3 makes navigability use descriptors rather than raw index queries, making the split cleaner. From 4b53119c0c64cb05e20a7000ef0cc36748d43248 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 21:42:13 +0100 Subject: [PATCH 2/7] Complete phase 1 + 2 --- cleave/viz/app.py | 3 +- cleave/viz/controls.py | 84 ++-- cleave/viz/overlay.py | 477 +++++++++++------------ cleave/viz/overlay_draw.py | 4 +- cleave/viz/render_overlay_controls.py | 8 +- cleave/viz/render_post_fx_controls.py | 6 +- cleave/viz/settings_controls.py | 4 +- cleave/viz/tuning_view_state.py | 2 + docs/architecture-improvements.md | 4 + tests/cleave/viz/test_config_dirty.py | 53 ++- tests/cleave/viz/test_controls.py | 334 ++++++++-------- tests/cleave/viz/test_layer.py | 3 +- tests/cleave/viz/test_overlay.py | 148 +++---- tests/cleave/viz/test_overlay_targets.py | 6 +- 14 files changed, 565 insertions(+), 571 deletions(-) diff --git a/cleave/viz/app.py b/cleave/viz/app.py index d0f5e86..a34ed6c 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -328,9 +328,8 @@ def _tick_frame_live_overlay( view_state = runtime.controls.build_view_state( paused=paused, position_sec=t_sec, + fps=display_fps, ) - if display_fps is not None: - view_state = dataclasses.replace(view_state, fps=display_fps) tl = runtime.seed.session.timeline runtime.overlay.update(overlay_dt) overlay_visibility = runtime.overlay.visibility diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 63aaa34..d20f1b7 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -33,16 +33,7 @@ ) from cleave.viz.overlay import ( TuningViewState, - build_row_layout, - find_row, - find_row_by_kind, - navigable_row_indices, - quick_nav_row_indices, - row_count, - row_descriptor, row_effect, - row_kind, - row_slot, ) from cleave.viz.session import TuningSession @@ -136,7 +127,7 @@ def set_focus_index(index: int) -> None: ) view = self.build_view_state(paused=self.playback.paused) - self.focus_index = find_row_by_kind(view, RowKind.TRANSPORT) + self.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) def _move_mode_signature_payload(self) -> dict[str, list[str]] | None: if self.move_mode_slot is not None and self._move_mode_original_z_order is not None: @@ -237,7 +228,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if event.key in (pygame.K_LEFT, pygame.K_RIGHT): view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) self._apply_horizontal(event.key, event.mod, kind) repeat = kind in REPEAT_ROW_KINDS if repeat and kind == RowKind.TRACK_PRESET_DIR and mod_ctrl(event.mod): @@ -249,16 +240,18 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: on_repeat=lambda key, mod: self._apply_horizontal( key, mod, - row_kind(self.build_view_state(paused=self.playback.paused), self.focus_index), + self.build_view_state( + paused=self.playback.paused + ).layout.kind(self.focus_index), ), ) return True if event.key == pygame.K_BACKSPACE: view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) if kind == RowKind.TRACK_PRESET_DIR: - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: if layer_lock_blocks_mutation( kind, locked=self.session.layers[slot].locked @@ -269,35 +262,35 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if delete_key_pressed(event): view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) if row_triggers_layer_delete(kind): - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: self._delete_layer(slot) return True if event.key == pygame.K_RETURN and mod_ctrl(event.mod): view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) if kind == RowKind.TRACK_HEADER: - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: self._toggle_locked(slot) return True if event.key == pygame.K_RETURN: view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) if kind == RowKind.LAYER_MANAGEMENT_ADD: self._add_layer() return True if kind == RowKind.LAYER_MANAGEMENT_DELETE: - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: self._delete_layer(slot) return True if kind == RowKind.TRACK_PRESET_DIR: - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: if layer_lock_blocks_mutation( kind, locked=self.session.layers[slot].locked @@ -309,7 +302,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: toggle_pause(self.playback, self.duration_sec) return True if kind == RowKind.TRACK_HEADER: - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) if slot is not None: if ( self.session.layers[slot].locked @@ -347,8 +340,11 @@ def build_view_state( *, paused: bool, position_sec: float | None = None, + fps: float | None = None, ) -> TuningViewState: - return self._view_state.build(paused=paused, position_sec=position_sec) + return self._view_state.build( + paused=paused, position_sec=position_sec, fps=fps + ) def _timeline_row_count(self) -> int: return len(self.session.layer_z_order) @@ -373,14 +369,14 @@ def _move_focus(self, delta: int) -> None: elif tl.focus_row >= row_count - 1: tl.submenu_focused = False view = self.build_view_state(paused=self.playback.paused) - self.focus_index = find_row_by_kind(view, RowKind.TRANSPORT) + self.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) return else: tl.focus_row += 1 return view = self.build_view_state(paused=self.playback.paused) - navigable = navigable_row_indices(view) + navigable = view.layout.navigable_indices(view) if not navigable: return try: @@ -389,8 +385,8 @@ def _move_focus(self, delta: int) -> None: pos = 0 if self._timeline_submenu_active(): - timeline_header = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - transport = find_row_by_kind(view, RowKind.TRANSPORT) + timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + transport = view.layout.find_by_kind(RowKind.TRANSPORT) if delta > 0 and self.focus_index == timeline_header: tl.submenu_focused = True tl.focus_row = 0 @@ -404,13 +400,13 @@ def _move_focus(self, delta: int) -> None: def _move_quick_focus(self, delta: int) -> None: view = self.build_view_state(paused=self.playback.paused) - quick = quick_nav_row_indices(view) + quick = view.layout.quick_nav_indices() if not quick: return tl = self.session.timeline if tl.submenu_focused: tl.submenu_focused = False - timeline_header = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) if delta < 0: self.focus_index = timeline_header return @@ -447,7 +443,7 @@ def _rebuild_view(self, *, nav_pos: int | None = None) -> None: if self.move_mode_slot is not None: self._confirm_move_mode() view = self.build_view_state(paused=self.playback.paused) - navigable = navigable_row_indices(view) + navigable = view.layout.navigable_indices(view) if not navigable: return if nav_pos is None: @@ -489,7 +485,7 @@ def _confirm_delete_layer(self, slot: str) -> None: if self._layer_manager is None: return view = self.build_view_state(paused=self.playback.paused) - navigable = navigable_row_indices(view) + navigable = view.layout.navigable_indices(view) try: nav_pos = navigable.index(self.focus_index) except ValueError: @@ -511,7 +507,7 @@ def _cancel_move_mode(self) -> None: def _apply_horizontal(self, key: int, mod: int, kind: RowKind) -> None: view = self.build_view_state(paused=self.playback.paused) - slot = row_slot(view, self.focus_index) + slot = view.layout.slot(self.focus_index) ctrl = mod_ctrl(mod) forward = key == pygame.K_RIGHT @@ -779,30 +775,30 @@ def _set_expanded(self, slot: str, expanded: bool) -> None: def _track_header_index(self, slot: str) -> int: view = self.build_view_state(paused=self.playback.paused) - return find_row(view, slot, RowKind.TRACK_HEADER) + return view.layout.find(slot, RowKind.TRACK_HEADER) def _refocus_track_header_if_sub_row(self, slot: str) -> None: view = self.build_view_state(paused=self.playback.paused) - kind = row_kind(view, self.focus_index) + kind = view.layout.kind(self.focus_index) if kind not in REPEAT_ROW_KINDS: return - if row_slot(view, self.focus_index) == slot: + if view.layout.slot(self.focus_index) == slot: self.focus_index = self._track_header_index(slot) def _focused_row_kind(self) -> RowKind | None: view = self.build_view_state(paused=self.playback.paused) try: - return row_kind(view, self.focus_index) + return view.layout.kind(self.focus_index) except IndexError: return None def _render_timeline_header_index(self) -> int: view = self.build_view_state(paused=self.playback.paused) - return find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + return view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) def _focused_row_descriptor(self, view: TuningViewState) -> RowDescriptor | None: try: - return row_descriptor(view, self.focus_index) + return view.layout.descriptor(self.focus_index) except IndexError: return None @@ -810,11 +806,11 @@ def _restore_focus(self, descriptor: RowDescriptor | None) -> None: if descriptor is None: return view = self.build_view_state(paused=self.playback.paused) - for index, row in enumerate(build_row_layout(view)): + for index, row in enumerate(view.layout.rows): if row == descriptor: self.focus_index = index return - self.focus_index = min(self.focus_index, row_count(view) - 1) + self.focus_index = min(self.focus_index, len(view.layout) - 1) def _set_render_timeline_enabled(self, enabled: bool) -> None: tl = self.session.timeline @@ -888,16 +884,16 @@ def _set_effects_expanded(self, slot: str, expanded: bool) -> None: if layer.effects_expanded == expanded: return view = self.build_view_state(paused=self.playback.paused) - effects_header_idx = find_row(view, slot, RowKind.TRACK_EFFECTS_HEADER) + effects_header_idx = view.layout.find(slot, RowKind.TRACK_EFFECTS_HEADER) old_focus = self.focus_index effect_count = effect_row_count(layer.stem) layer.effects_expanded = expanded view_after = self.build_view_state(paused=self.playback.paused) - new_header_idx = find_row(view_after, slot, RowKind.TRACK_EFFECTS_HEADER) + new_header_idx = view_after.layout.find(slot, RowKind.TRACK_EFFECTS_HEADER) if not expanded: if old_focus > effects_header_idx and ( - row_slot(view, old_focus) == slot - and row_kind(view, old_focus) == RowKind.TRACK_EFFECT + view.layout.slot(old_focus) == slot + and view.layout.kind(old_focus) == RowKind.TRACK_EFFECT ): self.focus_index = new_header_idx elif old_focus > effects_header_idx + effect_count: diff --git a/cleave/viz/overlay.py b/cleave/viz/overlay.py index 2853633..6edea36 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/overlay.py @@ -157,6 +157,215 @@ class SettingsBlock: render_mode: str = "balanced" +@dataclass(frozen=True) +class RowLayout: + rows: tuple[RowDescriptor, ...] + + @classmethod + def build(cls, state: TuningViewState) -> RowLayout: + row_list: list[RowDescriptor] = [ + RowDescriptor(RowKind.SETTINGS_HEADER), + ] + if state.settings.expanded: + row_list.append(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) + row_list.extend( + [ + RowDescriptor(RowKind.CONFIG_HEADER), + RowDescriptor(RowKind.TRANSPORT), + ] + ) + for slot in state.layer_z_order: + row_list.append(RowDescriptor(RowKind.TRACK_HEADER, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET_DIR, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_STEM, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_BLEND, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_OPACITY, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_BEAT, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=slot)) + block = state.tracks[slot] + if block.effects_expanded: + for effect_def in effect_roster(block.stem): + row_list.append( + RowDescriptor( + RowKind.TRACK_EFFECT, + slot=slot, + effect_id=effect_def.effect_id, + driver_slug=effect_def.driver_slug, + ) + ) + if block.expanded: + row_list.append( + RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot) + ) + row_list.append(RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)) + if state.render_timeline.enabled: + row_list.append(RowDescriptor(RowKind.TIMELINE_LAYER_HINT)) + row_list.append(RowDescriptor(RowKind.RENDER_SECTION_GAP)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_HEADER)) + if state.render_overlay.expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_POSITION)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BORDER_WIDTH)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_START_DELAY)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_DISPLAY_TIME)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER)) + if state.render_overlay.title_expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE)) + row_list.append( + RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) + ) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER)) + if state.render_overlay.body_expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE)) + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_HEADER)) + if state.render_post_fx.expanded: + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN)) + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT)) + row_list.append(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + return cls(tuple(row_list)) + + def __len__(self) -> int: + return len(self.rows) + + def count(self) -> int: + return len(self.rows) + + def descriptor(self, index: int) -> RowDescriptor: + if index < 0 or index >= len(self.rows): + raise IndexError(index) + return self.rows[index] + + def kind(self, index: int) -> RowKind: + return self.descriptor(index).kind + + def slot(self, index: int) -> str | None: + return self.descriptor(index).slot + + def find( + self, + slot: str, + kind: RowKind, + *, + effect_id: str | None = None, + driver_slug: str | None = None, + ) -> int: + for index, desc in enumerate(self.rows): + if desc.kind != kind or desc.slot != slot: + continue + if kind == RowKind.TRACK_EFFECT: + if desc.effect_id != effect_id or desc.driver_slug != driver_slug: + continue + return index + raise ValueError(f"no row for slot={slot!r} kind={kind!r}") + + def find_by_kind(self, kind: RowKind) -> int: + for index, desc in enumerate(self.rows): + if desc.kind == kind: + return index + raise ValueError(f"no row for kind={kind!r}") + + def header_row_count(self) -> int: + count = 0 + for row in self.rows: + if row_is_pinned(row.kind): + count += 1 + else: + break + return count + + def track_row_count(self) -> int: + """Count of scrollable content rows (all rows except pinned header rows).""" + return sum(1 for row in self.rows if not row_is_pinned(row.kind)) + + def sub_row_visible(self, state: TuningViewState, index: int) -> bool: + desc = self.descriptor(index) + if desc.kind in {RowKind.RENDER_SECTION_GAP, RowKind.TIMELINE_LAYER_HINT}: + return True + if desc.kind in SETTINGS_SUB_ROW_KINDS: + return state.settings.expanded + if desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: + return state.render_overlay.expanded + if desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: + return state.render_overlay.expanded and state.render_overlay.title_expanded + if desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: + return state.render_overlay.expanded and state.render_overlay.body_expanded + if desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: + return state.render_post_fx.expanded + slot = desc.slot + if slot is None or desc.kind not in TRACK_SUB_ROW_KINDS: + return True + block = state.tracks[slot] + if not block.expanded: + return False + if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS: + return block.effects_expanded + return True + + def visible_indices(self, state: TuningViewState) -> list[int]: + """Row indices drawn in the panel (sub-rows hidden when collapsed).""" + return [ + index + for index in range(len(self)) + if self.sub_row_visible(state, index) + ] + + def navigable_indices(self, state: TuningViewState) -> list[int]: + """Row indices reachable via Up/Down (sub-rows skipped when collapsed).""" + indices: list[int] = [] + for index in range(len(self)): + desc = self.descriptor(index) + if desc.kind in { + RowKind.RENDER_SECTION_GAP, + RowKind.TIMELINE_LAYER_HINT, + }: + continue + if desc.kind in SETTINGS_SUB_ROW_KINDS: + if not state.settings.expanded: + continue + elif desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: + if not state.render_overlay.expanded: + continue + elif desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: + if not state.render_overlay.expanded or not state.render_overlay.title_expanded: + continue + elif desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: + if not state.render_overlay.expanded or not state.render_overlay.body_expanded: + continue + elif desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: + if not state.render_post_fx.expanded: + continue + elif desc.kind in TRACK_SUB_ROW_KINDS: + slot = desc.slot + assert slot is not None + block = state.tracks[slot] + if not block.expanded: + continue + if block.locked and not row_navigable_when_layer_locked(desc.kind): + continue + if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS and not block.effects_expanded: + continue + indices.append(index) + return indices + + def quick_nav_indices(self) -> list[int]: + """Row indices for Ctrl+Up/Down: layer headers and transport only.""" + indices: list[int] = [] + for index in range(len(self)): + kind = self.kind(index) + if kind in ( + RowKind.TRACK_HEADER, + RowKind.RENDER_OVERLAY_HEADER, + RowKind.RENDER_POST_FX_HEADER, + RowKind.RENDER_TIMELINE_HEADER, + RowKind.TRANSPORT, + ): + indices.append(index) + return indices + + @dataclass class TuningViewState: layer_z_order: tuple[str, ...] @@ -185,232 +394,20 @@ class TuningViewState: timeline_override_active: bool = False help_visible: bool = False fps: float | None = None + layout: RowLayout = field(init=False, repr=False) - -def header_row_count(state: TuningViewState) -> int: - count = 0 - for row in build_row_layout(state): - if row_is_pinned(row.kind): - count += 1 - else: - break - return count - - -def build_row_layout(state: TuningViewState) -> list[RowDescriptor]: - rows: list[RowDescriptor] = [ - RowDescriptor(RowKind.SETTINGS_HEADER), - ] - if state.settings.expanded: - rows.append(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) - rows.extend( - [ - RowDescriptor(RowKind.CONFIG_HEADER), - RowDescriptor(RowKind.TRANSPORT), - ] - ) - for slot in state.layer_z_order: - rows.append(RowDescriptor(RowKind.TRACK_HEADER, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_PRESET_DIR, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_PRESET, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_STEM, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_BLEND, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_OPACITY, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_BEAT, slot=slot)) - rows.append(RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=slot)) - block = state.tracks[slot] - if block.effects_expanded: - for effect_def in effect_roster(block.stem): - rows.append( - RowDescriptor( - RowKind.TRACK_EFFECT, - slot=slot, - effect_id=effect_def.effect_id, - driver_slug=effect_def.driver_slug, - ) - ) - if block.expanded: - rows.append( - RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot) - ) - rows.append(RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)) - if state.render_timeline.enabled: - rows.append(RowDescriptor(RowKind.TIMELINE_LAYER_HINT)) - rows.append(RowDescriptor(RowKind.RENDER_SECTION_GAP)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_HEADER)) - if state.render_overlay.expanded: - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_POSITION)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_BORDER_WIDTH)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_START_DELAY)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_DISPLAY_TIME)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER)) - if state.render_overlay.title_expanded: - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER)) - if state.render_overlay.body_expanded: - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT)) - rows.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE)) - rows.append(RowDescriptor(RowKind.RENDER_POST_FX_HEADER)) - if state.render_post_fx.expanded: - rows.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN)) - rows.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT)) - rows.append(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) - return rows - - -def track_row_count(state: TuningViewState) -> int: - """Count of scrollable content rows (all rows except pinned header rows).""" - return sum( - 1 - for row in build_row_layout(state) - if not row_is_pinned(row.kind) - ) - - -def row_descriptor(state: TuningViewState, index: int) -> RowDescriptor: - layout = build_row_layout(state) - if index < 0 or index >= len(layout): - raise IndexError(index) - return layout[index] - - -def find_row( - state: TuningViewState, - slot: str, - kind: RowKind, - *, - effect_id: str | None = None, - driver_slug: str | None = None, -) -> int: - for index, desc in enumerate(build_row_layout(state)): - if desc.kind != kind or desc.slot != slot: - continue - if kind == RowKind.TRACK_EFFECT: - if desc.effect_id != effect_id or desc.driver_slug != driver_slug: - continue - return index - raise ValueError(f"no row for slot={slot!r} kind={kind!r}") - - -def find_row_by_kind(state: TuningViewState, kind: RowKind) -> int: - for index, desc in enumerate(build_row_layout(state)): - if desc.kind == kind: - return index - raise ValueError(f"no row for kind={kind!r}") - - -def row_count(state: TuningViewState) -> int: - return len(build_row_layout(state)) + def __post_init__(self) -> None: + object.__setattr__(self, "layout", RowLayout.build(self)) def track_sub_rows_visible(state: TuningViewState, slot: str) -> bool: return state.tracks[slot].expanded -def _sub_row_visible(state: TuningViewState, index: int) -> bool: - desc = row_descriptor(state, index) - if desc.kind in {RowKind.RENDER_SECTION_GAP, RowKind.TIMELINE_LAYER_HINT}: - return True - if desc.kind in SETTINGS_SUB_ROW_KINDS: - return state.settings.expanded - if desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: - return state.render_overlay.expanded - if desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: - return state.render_overlay.expanded and state.render_overlay.title_expanded - if desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: - return state.render_overlay.expanded and state.render_overlay.body_expanded - if desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: - return state.render_post_fx.expanded - slot = desc.slot - if slot is None or desc.kind not in TRACK_SUB_ROW_KINDS: - return True - block = state.tracks[slot] - if not block.expanded: - return False - if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS: - return block.effects_expanded - return True - - -def row_visible(state: TuningViewState, index: int) -> bool: - return _sub_row_visible(state, index) - - -def visible_row_indices(state: TuningViewState) -> list[int]: - """Row indices drawn in the panel (sub-rows hidden when collapsed).""" - return [index for index in range(row_count(state)) if row_visible(state, index)] - - -def navigable_row_indices(state: TuningViewState) -> list[int]: - """Row indices reachable via Up/Down (sub-rows skipped when collapsed).""" - indices: list[int] = [] - for index in range(row_count(state)): - desc = row_descriptor(state, index) - if desc.kind in { - RowKind.RENDER_SECTION_GAP, - RowKind.TIMELINE_LAYER_HINT, - }: - continue - if desc.kind in SETTINGS_SUB_ROW_KINDS: - if not state.settings.expanded: - continue - elif desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: - if not state.render_overlay.expanded: - continue - elif desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: - if not state.render_overlay.expanded or not state.render_overlay.title_expanded: - continue - elif desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: - if not state.render_overlay.expanded or not state.render_overlay.body_expanded: - continue - elif desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: - if not state.render_post_fx.expanded: - continue - elif desc.kind in TRACK_SUB_ROW_KINDS: - slot = desc.slot - assert slot is not None - block = state.tracks[slot] - if not block.expanded: - continue - if block.locked and not row_navigable_when_layer_locked(desc.kind): - continue - if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS and not block.effects_expanded: - continue - indices.append(index) - return indices - - -def quick_nav_row_indices(state: TuningViewState) -> list[int]: - """Row indices for Ctrl+Up/Down: layer headers and transport only.""" - indices: list[int] = [] - for index in range(row_count(state)): - kind = row_kind(state, index) - if kind in ( - RowKind.TRACK_HEADER, - RowKind.RENDER_OVERLAY_HEADER, - RowKind.RENDER_POST_FX_HEADER, - RowKind.RENDER_TIMELINE_HEADER, - RowKind.TRANSPORT, - ): - indices.append(index) - return indices - - -def row_slot(state: TuningViewState, index: int) -> str | None: - return row_descriptor(state, index).slot - - -def row_kind(state: TuningViewState, index: int) -> RowKind: - return row_descriptor(state, index).kind - - def row_effect( state: TuningViewState, index: int ) -> tuple[str, str] | None: - desc = row_descriptor(state, index) + desc = state.layout.descriptor(index) if desc.kind != RowKind.TRACK_EFFECT: return None assert desc.effect_id is not None and desc.driver_slug is not None @@ -422,7 +419,7 @@ def _effect_pct(block: TrackBlock, effect_id: str, driver_slug: str) -> int: def _row_text(state: TuningViewState, index: int) -> str: - kind = row_kind(state, index) + kind = state.layout.kind(index) if kind == RowKind.CONFIG_HEADER: return state.active_config_label if kind == RowKind.TRANSPORT: @@ -491,7 +488,7 @@ def _row_text(state: TuningViewState, index: int) -> str: if kind == RowKind.RENDER_POST_FX_FADE_OUT: return f"└─ fade out: {block_pp.fade_out:.1f}s" - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None block = state.tracks[stem] if kind == RowKind.TRACK_HEADER: @@ -522,7 +519,7 @@ def _row_text(state: TuningViewState, index: int) -> str: def _labeled_sub_row_prefix(state: TuningViewState, index: int) -> str: - kind = row_kind(state, index) + kind = state.layout.kind(index) if kind == RowKind.TRACK_BLEND: return "└─ blend mode: " if kind == RowKind.TRACK_STEM: @@ -565,7 +562,7 @@ def _labeled_sub_row_prefix(state: TuningViewState, index: int) -> str: def _labeled_sub_row_value(state: TuningViewState, index: int) -> str: - kind = row_kind(state, index) + kind = state.layout.kind(index) block_ro = state.render_overlay if kind == RowKind.RENDER_OVERLAY_POSITION: return block_ro.position @@ -594,7 +591,7 @@ def _labeled_sub_row_value(state: TuningViewState, index: int) -> str: return f"{block_pp.fade_in:.1f}s" if kind == RowKind.RENDER_POST_FX_FADE_OUT: return f"{block_pp.fade_out:.1f}s" - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None block = state.tracks[stem] if kind == RowKind.TRACK_BLEND: @@ -619,7 +616,7 @@ def _fit_labeled_sub_row_value( *, max_content_width: int = PANEL_CONTENT_MAX_WIDTH, ) -> str: - kind = row_kind(state, index) + kind = state.layout.kind(index) budget = max_content_width - _row_indent(state, index) budget -= font.size(_labeled_sub_row_prefix(state, index))[0] value = _labeled_sub_row_value(state, index) @@ -660,7 +657,7 @@ def _render_label_value_row( def _track_header_layer_prefix(state: TuningViewState, index: int) -> str: - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None layer_num = state.layer_z_order.index(stem) + 1 return f"Layer {layer_num}: " @@ -743,7 +740,7 @@ def _fit_track_header_stem( *, max_content_width: int = PANEL_CONTENT_MAX_WIDTH, ) -> str: - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None block = state.tracks[stem] locked = block.locked @@ -801,7 +798,7 @@ def fit_row_text( max_content_width: int = PANEL_CONTENT_MAX_WIDTH, ) -> str: """Fit row label to the shared panel content width (pixels).""" - kind = row_kind(state, index) + kind = state.layout.kind(index) indent = _row_indent(state, index) budget = max_content_width - indent text = _row_text(state, index) @@ -813,7 +810,7 @@ def fit_row_text( return fit_path_label_to_width(font, text, budget - icon_w - suffix_w) return fit_counter_label_to_width(font, text, budget - icon_w) if kind == RowKind.TRACK_HEADER: - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None expanded = state.tracks[stem].expanded return ( @@ -861,7 +858,7 @@ def fit_row_text( def _row_indent(state: TuningViewState, index: int) -> int: - kind = row_kind(state, index) + kind = state.layout.kind(index) if kind in { RowKind.TRACK_HEADER, RowKind.RENDER_OVERLAY_HEADER, @@ -899,7 +896,7 @@ def _track_disabled(state: TuningViewState, slot: str) -> bool: def _row_highlight_color(state: TuningViewState, index: int) -> tuple[int, int, int]: - stem = row_slot(state, index) + stem = state.layout.slot(index) if stem is not None and _track_disabled(state, stem): return HIGHLIGHT_MUTED return HIGHLIGHT @@ -913,7 +910,7 @@ def _row_has_tree_focus(state: TuningViewState, index: int) -> bool: def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int]: """Return the VALUE-role color for a row (before label/value split rendering).""" - kind = row_kind(state, index) + kind = state.layout.kind(index) if kind == RowKind.TIMELINE_LAYER_HINT: return DISABLED @@ -928,7 +925,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] return DISABLED return ACTION - stem = row_slot(state, index) + stem = state.layout.slot(index) if kind in {RowKind.RENDER_OVERLAY_HEADER, *RENDER_OVERLAY_ALL_SUB_ROW_KINDS}: if not state.render_overlay.enabled: @@ -972,7 +969,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] def _row_bg_color(state: TuningViewState, index: int) -> tuple[int, int, int] | None: - stem = row_slot(state, index) + stem = state.layout.slot(index) if stem is not None and state.move_mode_slot == stem: return MOVE_MODE if _row_has_tree_focus(state, index): @@ -1051,7 +1048,7 @@ def panel_fps_layout( text_width: int, show_scrollbar: bool, ) -> PanelFpsLayout: - """Top-right FPS readout on the transport row; shifts left for the scrollbar.""" + """Top-right FPS readout in the header region; shifts left for the scrollbar.""" right_reserve = ( SCROLLBAR_WIDTH + SCROLLBAR_CONTENT_GAP if show_scrollbar else 0 ) @@ -1344,18 +1341,18 @@ def draw( timeline_panel_open: bool = False, ) -> None: self._panel_rect = None - if self._visibility <= 0.01 or row_count(state) == 0: + if self._visibility <= 0.01 or len(state.layout) == 0: return font = self._font_get() line_h = font.get_linesize() - visible_indices = visible_row_indices(state) + visible_indices = state.layout.visible_indices(state) visible_count = len(visible_indices) first_scrollable_visible = next( ( index for index in visible_indices - if not row_is_pinned(row_kind(state, index)) + if not row_is_pinned(state.layout.kind(index)) ), None, ) @@ -1389,7 +1386,7 @@ def draw( scrollable_indices=scrollable_indices, show_scrollbar=metrics.show_scrollbar, ) - kind = row_kind(state, index) + kind = state.layout.kind(index) indent = self._padding + _row_indent(state, index) color = _row_value_color(state, index) @@ -1407,7 +1404,7 @@ def draw( indent + icons_surf.get_width() + time_surf.get_width() ) elif kind == RowKind.TRACK_HEADER: - stem = row_slot(state, index) + stem = state.layout.slot(index) block = state.tracks[stem] if stem is not None else None enabled = block.visible if block is not None else True solo = stem is not None and state.solo_slot == stem @@ -1550,7 +1547,7 @@ def draw( indent + icon_surf.get_width() + label_surf.get_width() ) elif kind == RowKind.TRACK_EFFECTS_HEADER: - stem = row_slot(state, index) + stem = state.layout.slot(index) assert stem is not None block = state.tracks[stem] surf = _render_label_value_row( @@ -1754,9 +1751,7 @@ def draw( panel.blit(toast_surf, (self._padding, toast_layout.toast_y)) if state.fps is not None and text_alpha >= 2: - transport_index = find_row_by_kind(state, RowKind.TRANSPORT) - fps_color = _row_value_color(state, transport_index) - fps_surf = font.render(format_fps_display(state.fps), True, fps_color) + fps_surf = font.render(format_fps_display(state.fps), True, DISABLED) fps_surf.set_alpha(text_alpha) fps_layout = panel_fps_layout( panel_w=panel_w, diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index f6bfe06..960c5ea 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -8,7 +8,7 @@ from cleave.viz import modal_overlay from cleave.viz.help_overlay import HelpOverlay from cleave.viz.modal import ModalHost -from cleave.viz.overlay import TuningOverlay, TuningViewState, row_kind +from cleave.viz.overlay import TuningOverlay, TuningViewState from cleave.viz.timeline_overlay import TimelineOverlay, TimelineViewState @@ -46,7 +46,7 @@ def draw_tuning( if help_overlay is not None and view_state.help_visible: help_overlay.draw( overlay_surface, - row_kind(view_state, view_state.focus_index), + view_state.layout.kind(view_state.focus_index), timeline_enabled=view_state.render_timeline.enabled, timeline_submenu_focused=view_state.timeline_submenu_focused, paused=view_state.paused, diff --git a/cleave/viz/render_overlay_controls.py b/cleave/viz/render_overlay_controls.py index 250a8de..ce00eda 100644 --- a/cleave/viz/render_overlay_controls.py +++ b/cleave/viz/render_overlay_controls.py @@ -7,7 +7,7 @@ from cleave.config import RENDER_OVERLAY_POSITIONS from cleave.viz.focus_context import FocusContext from cleave.viz.fonts import cycle_render_overlay_font -from cleave.viz.overlay import find_row_by_kind +from cleave.viz.overlay import TuningViewState from cleave.viz.row_semantics import ( RENDER_OVERLAY_ALL_SUB_ROW_KINDS, RENDER_OVERLAY_BODY_NESTED_KINDS, @@ -33,15 +33,15 @@ def __init__( def _render_overlay_header_index(self) -> int: view = self._focus.build_view_state(paused=self._focus.is_paused()) - return find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) def _render_overlay_title_header_index(self) -> int: view = self._focus.build_view_state(paused=self._focus.is_paused()) - return find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) + return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) def _render_overlay_body_header_index(self) -> int: view = self._focus.build_view_state(paused=self._focus.is_paused()) - return find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_HEADER) + return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_HEADER) def set_expanded(self, expanded: bool) -> None: ro = self.session.render_overlay diff --git a/cleave/viz/render_post_fx_controls.py b/cleave/viz/render_post_fx_controls.py index 723e387..a51b6fb 100644 --- a/cleave/viz/render_post_fx_controls.py +++ b/cleave/viz/render_post_fx_controls.py @@ -3,7 +3,7 @@ from __future__ import annotations from cleave.viz.focus_context import FocusContext -from cleave.viz.overlay import find_row_by_kind, row_kind +from cleave.viz.overlay import TuningViewState from cleave.viz.row_semantics import RENDER_POST_FX_SUB_ROW_KINDS, RowKind from cleave.viz.session import TuningSession @@ -22,11 +22,11 @@ def __init__( def _render_post_fx_header_index(self) -> int: view = self._focus.build_view_state(paused=self._focus.is_paused()) - return find_row_by_kind(view, RowKind.RENDER_POST_FX_HEADER) + return view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) def _refocus_render_post_fx_header_if_sub_row(self) -> None: view = self._focus.build_view_state(paused=self._focus.is_paused()) - if row_kind(view, self._focus.get_focus_index()) in RENDER_POST_FX_SUB_ROW_KINDS: + if view.layout.kind(self._focus.get_focus_index()) in RENDER_POST_FX_SUB_ROW_KINDS: self._focus.set_focus_index(self._render_post_fx_header_index()) def set_expanded(self, expanded: bool) -> None: diff --git a/cleave/viz/settings_controls.py b/cleave/viz/settings_controls.py index 8e636d0..b8d909b 100644 --- a/cleave/viz/settings_controls.py +++ b/cleave/viz/settings_controls.py @@ -8,7 +8,7 @@ from cleave.config import CleaveConfig from cleave.config_schema import VISUALIZER_RENDER_MODES from cleave.viz.focus_context import FocusContext -from cleave.viz.overlay import find_row_by_kind +from cleave.viz.overlay import TuningViewState from cleave.viz.row_semantics import SETTINGS_SUB_ROW_KINDS, RowKind from cleave.viz.session import TuningSession @@ -31,7 +31,7 @@ def __init__( def _settings_header_index(self) -> int: view = self._focus.build_view_state(paused=self._focus.is_paused()) - return find_row_by_kind(view, RowKind.SETTINGS_HEADER) + return view.layout.find_by_kind(RowKind.SETTINGS_HEADER) def set_expanded(self, expanded: bool) -> None: settings = self.session.settings diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index fbec4c0..4841fec 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -50,6 +50,7 @@ def build( *, paused: bool, position_sec: float | None = None, + fps: float | None = None, ) -> TuningViewState: if position_sec is None: position_sec = current_sec(self.playback, self.duration_sec) @@ -144,4 +145,5 @@ def build( timeline_recording=tl.recording, timeline_override_active=bool(tl.override_slots), help_visible=self.session.help_visible, + fps=fps, ) diff --git a/docs/architecture-improvements.md b/docs/architecture-improvements.md index 4857cb0..cc0486a 100644 --- a/docs/architecture-improvements.md +++ b/docs/architecture-improvements.md @@ -8,6 +8,8 @@ Phases 3-5 harden the foundation against recurrence. ## Phase 1 - Cache `build_row_layout` per frame +**Status:** done. `RowLayout` is built once in `TuningViewState.__post_init__`; callers use `state.layout`. + **Why.** `build_row_layout` rebuilds the full row list on every call: `row_descriptor`, `row_count`, `find_row`, `find_row_by_kind`, `track_row_count`, `_sub_row_visible`, and `visible_row_indices` all call it independently. A single `draw()` + input dispatch cycle can trigger a dozen rebuilds. This scatters allocation cost across the call stack and, more importantly, creates a correctness hazard: if state mutates mid-frame (unlikely now, but possible during timeline or modal transitions), callers within the same tick see different layouts. **What to do.** Introduce a `RowLayout` wrapper (a frozen list of `RowDescriptor` plus the helpers that operate on it). `TuningViewState` holds one `RowLayout` instead of allowing callers to rebuild on demand. `build_row_layout` becomes an internal constructor; the public surface is `state.layout`. All helpers (`row_descriptor`, `find_row`, `navigable_row_indices`, etc.) become methods or free functions on `RowLayout`. `TuningViewStateBuilder.build()` calls `build_row_layout` exactly once. @@ -18,6 +20,8 @@ Phases 3-5 harden the foundation against recurrence. ## Phase 2 - Decouple FPS from transport color; route fps through the view builder +**Status:** done. FPS draws with `DISABLED`; `TuningViewStateBuilder.build(fps=...)` sets the field; `app.py` no longer patches view state after build. + **Why.** FPS is drawn at `y = padding` (top of the panel, physically on the Settings row), but its color is computed as `_row_value_color(state, transport_index)`. When the transport row is focused the FPS text turns the highlight color despite being in a different region -- this is Bug 1. The coupling exists because FPS was originally on the same Y as transport; after layout moved it, the color callback was not updated. A secondary issue: `TuningViewStateBuilder` does not set `fps`; `app.py` patches it in via `dataclasses.replace` after the builder runs, so the builder does not represent the full view state. **What to do.** Remove the `_row_value_color(state, transport_index)` call for FPS. Use a fixed theme constant (e.g. `DISABLED` or a dedicated `FPS_COLOR`). Move `fps` population into `TuningViewStateBuilder.build()`, passing the current fps value in at construction or as a build argument (the same way `paused` is passed today). Remove the `dataclasses.replace` patch in `app.py`. diff --git a/tests/cleave/viz/test_config_dirty.py b/tests/cleave/viz/test_config_dirty.py index fc2473b..7e986fa 100644 --- a/tests/cleave/viz/test_config_dirty.py +++ b/tests/cleave/viz/test_config_dirty.py @@ -13,7 +13,6 @@ from cleave.timeline import TimelineCue from cleave.viz.controls import TuningControls from cleave.viz.timeline_controls import TimelineControls -from cleave.viz.overlay import find_row_by_kind from cleave.viz.row_semantics import RowKind from tests.cleave.viz.test_controls import _choose_save_as_new, _config_header_row, _keydown, _make_controls, _row from tests.cleave.viz.test_timeline_controls import _make_timeline_controls @@ -28,13 +27,13 @@ def _expand_layer_1(controls: TuningControls) -> None: def _expand_render_overlay(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _expand_render_post_fx(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_POST_FX_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -101,14 +100,14 @@ def _mutate_preset_path(controls: TuningControls) -> None: def _mutate_render_overlay_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _mutate_render_overlay_position(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_POSITION) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_POSITION) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -116,9 +115,7 @@ def _mutate_render_overlay_title_font_size(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE - ) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -126,7 +123,7 @@ def _mutate_render_overlay_title_font(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -134,8 +131,8 @@ def _mutate_render_overlay_title_margin_bottom(controls: TuningControls) -> None _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM + controls.focus_index = view.layout.find_by_kind( + RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM ) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -144,8 +141,8 @@ def _mutate_render_overlay_body_font_size(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_BODY_FONT_SIZE + controls.focus_index = view.layout.find_by_kind( + RowKind.RENDER_OVERLAY_BODY_FONT_SIZE ) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -154,76 +151,76 @@ def _mutate_render_overlay_body_font(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_FONT) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_opacity(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_OPACITY) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_OPACITY) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_border_width(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BORDER_WIDTH) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BORDER_WIDTH) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_start_delay(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_START_DELAY) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_START_DELAY) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_display_time(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_DISPLAY_TIME + controls.focus_index = view.layout.find_by_kind( + RowKind.RENDER_OVERLAY_DISPLAY_TIME ) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_post_fx_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_POST_FX_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _mutate_render_post_fx_fade_in(controls: TuningControls) -> None: _expand_render_post_fx(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_POST_FX_FADE_IN) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_FADE_IN) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_post_fx_fade_out(controls: TuningControls) -> None: _expand_render_post_fx(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_POST_FX_FADE_OUT) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_FADE_OUT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_timeline_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _expand_settings(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_visualizer_render_mode(controls: TuningControls) -> None: _expand_settings(controls) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -349,7 +346,7 @@ def _mutate_solo_slot(controls: TuningControls) -> None: def _mutate_timeline_panel_open(controls: TuningControls) -> None: controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -359,7 +356,7 @@ def _mutate_render_overlay_expanded(controls: TuningControls) -> None: def _mutate_render_overlay_solo(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) @@ -369,7 +366,7 @@ def _mutate_render_post_fx_expanded(controls: TuningControls) -> None: def _mutate_render_post_fx_solo(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.RENDER_POST_FX_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 5fdfa52..b9c4fb0 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -63,9 +63,6 @@ ) from cleave.viz.row_semantics import RowKind from cleave.viz.overlay import ( - find_row, - find_row_by_kind, - header_row_count, TrackBlock, TuningViewState, TREE_INDENT, @@ -76,13 +73,6 @@ render_visibility_icon, fit_row_text, track_header_prefix_width, - navigable_row_indices, - quick_nav_row_indices, - row_count, - row_kind, - row_slot, - row_visible, - visible_row_indices, ) from tests.support.viz import baseline_tuning_ui_metrics @@ -188,7 +178,7 @@ def _confirm_modal_yes(controls: TuningControls) -> None: def _config_header_row(view: TuningViewState) -> int: return next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.CONFIG_HEADER ) @@ -207,10 +197,16 @@ def _choose_overwrite(controls: TuningControls) -> None: controls.handle_keydown(_keydown(pygame.K_RETURN)) +def test_build_view_state_passes_fps() -> None: + controls = _make_controls() + view = controls.build_view_state(paused=False, fps=42.0) + assert view.fps == 42.0 + + def test_add_layer_at_max_shows_toast() -> None: controls, manager = _make_controls_with_manager(("layer_1",), can_add=False) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) + controls.focus_index = view.layout.find_by_kind(RowKind.LAYER_MANAGEMENT_ADD) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -226,8 +222,8 @@ def test_delete_layer_at_min_shows_toast() -> None: controls, manager = _make_controls_with_manager(("layer_1",), can_remove=False) controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row( - view, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE + controls.focus_index = view.layout.find( + "layer_1", RowKind.LAYER_MANAGEMENT_DELETE ) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -254,14 +250,14 @@ def add_layer() -> None: manager.add_layer.side_effect = add_layer view = controls.build_view_state(paused=False) - before_count = row_count(view) - controls.focus_index = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) + before_count = len(view.layout) + controls.focus_index = view.layout.find_by_kind(RowKind.LAYER_MANAGEMENT_ADD) _confirm_modal_yes(controls) manager.add_layer.assert_called_once() view = controls.build_view_state(paused=False) - assert row_count(view) > before_count + assert len(view.layout) > before_count assert "layer_2" in controls.session.layer_z_order @@ -276,8 +272,8 @@ def remove_layer(slot: str) -> None: manager.remove_layer.side_effect = remove_layer view = controls.build_view_state(paused=False) - controls.focus_index = find_row( - view, "layer_2", RowKind.LAYER_MANAGEMENT_DELETE + controls.focus_index = view.layout.find( + "layer_2", RowKind.LAYER_MANAGEMENT_DELETE ) controls.handle_keydown(_keydown(confirm_key)) @@ -296,7 +292,7 @@ def remove_layer(slot: str) -> None: manager.remove_layer.side_effect = remove_layer view = controls.build_view_state(paused=False) - controls.focus_index = find_row(view, "layer_2", RowKind.TRACK_HEADER) + controls.focus_index = view.layout.find( "layer_2", RowKind.TRACK_HEADER) controls.handle_keydown(_keydown(pygame.K_DELETE)) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -354,7 +350,7 @@ def _row( effect_id: str | None = None, driver_slug: str | None = None, ) -> int: - return find_row(view, stem, kind, effect_id=effect_id, driver_slug=driver_slug) + return view.layout.find(stem, kind, effect_id=effect_id, driver_slug=driver_slug) def test_allow_overwrite_for_path_hides_repo_root_template_only() -> None: @@ -373,8 +369,8 @@ def test_allow_overwrite_for_path_hides_repo_root_template_only() -> None: def test_focus_navigation_wraps() -> None: controls = _make_controls(("layer_1", "layer_2")) view = controls.build_view_state(paused=False) - navigable = navigable_row_indices(view) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) + navigable = view.layout.navigable_indices(view) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) start_pos = navigable.index(transport_row) assert controls.focus_index == transport_row @@ -436,20 +432,20 @@ def test_navigation_skips_sub_rows_when_collapsed() -> None: drums_header = next( i for i in range(12) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_1" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_1" ) bass_header = next( i for i in range(12) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - navigable = navigable_row_indices(view) + navigable = view.layout.navigable_indices(view) assert drums_header in navigable assert bass_header in navigable for i in navigable: - stem = row_slot(view, i) + stem = view.layout.slot( i) if stem == "layer_1": - assert row_kind(view, i) == RowKind.TRACK_HEADER + assert view.layout.kind(i) == RowKind.TRACK_HEADER controls.focus_index = drums_header controls.handle_keydown(_keydown(pygame.K_DOWN)) @@ -462,19 +458,15 @@ def test_re_enable_without_expanding() -> None: controls.session.layers["layer_1"].expanded = False view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - add_layer_row = find_row_by_kind(view, RowKind.LAYER_MANAGEMENT_ADD) - render_overlay_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + add_layer_row = view.layout.find_by_kind(RowKind.LAYER_MANAGEMENT_ADD) + render_overlay_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) transport_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) controls.focus_index = header_row - render_post_fx_row = find_row_by_kind( - view, RowKind.RENDER_POST_FX_HEADER - ) - render_timeline_row = find_row_by_kind( - view, RowKind.RENDER_TIMELINE_HEADER - ) + render_post_fx_row = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + render_timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_index == add_layer_row @@ -508,15 +500,15 @@ def test_header_collapses_and_expands_sub_rows() -> None: controls.focus_index = header_row assert controls.session.layers["layer_1"].expanded is False - assert preset_dir_row not in navigable_row_indices(view) - assert preset_dir_row not in visible_row_indices(view) + assert preset_dir_row not in view.layout.navigable_indices(view) + assert preset_dir_row not in view.layout.visible_indices(view) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].expanded is True view = controls.build_view_state(paused=False) - assert preset_dir_row in navigable_row_indices(view) - assert preset_dir_row in visible_row_indices(view) + assert preset_dir_row in view.layout.navigable_indices(view) + assert preset_dir_row in view.layout.visible_indices(view) def test_disable_auto_collapses_sub_rows() -> None: @@ -536,8 +528,8 @@ def test_disable_auto_collapses_sub_rows() -> None: assert controls.focus_index == header_row view = controls.build_view_state(paused=False) - assert not row_visible(view, preset_dir_row) - assert preset_dir_row not in visible_row_indices(view) + assert not view.layout.sub_row_visible(view, preset_dir_row) + assert preset_dir_row not in view.layout.visible_indices(view) def test_disabled_track_can_expand_sub_rows() -> None: @@ -555,8 +547,8 @@ def test_disabled_track_can_expand_sub_rows() -> None: assert controls.session.layers["layer_1"].expanded is True view = controls.build_view_state(paused=False) - assert preset_dir_row in visible_row_indices(view) - assert preset_dir_row in navigable_row_indices(view) + assert preset_dir_row in view.layout.visible_indices(view) + assert preset_dir_row in view.layout.navigable_indices(view) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_index == preset_dir_row @@ -602,7 +594,7 @@ def test_move_mode_swaps_z_order() -> None: header_row = next( i for i in range(15) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) controls.focus_index = header_row @@ -625,7 +617,7 @@ def test_move_mode_esc_cancels_without_applying() -> None: header_row = next( i for i in range(15) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) controls.focus_index = header_row @@ -646,7 +638,7 @@ def test_move_mode_backspace_cancels_without_applying() -> None: header_row = next( i for i in range(15) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) controls.focus_index = header_row @@ -692,10 +684,10 @@ def test_config_header_shows_active_path() -> None: controls._config_save._active_config_path = launch_path view = controls.build_view_state(paused=False) header_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.CONFIG_HEADER ) assert _row_text(view, header_row) == config_path_display(launch_path) - assert header_row in navigable_row_indices(view) + assert header_row in view.layout.navigable_indices(view) def test_config_header_shows_asterisk_when_dirty() -> None: @@ -705,11 +697,11 @@ def test_config_header_shows_asterisk_when_dirty() -> None: _mutate_dirty(controls) view = controls.build_view_state(paused=False) header_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.CONFIG_HEADER ) assert _row_text(view, header_row) == config_path_display(launch_path) assert view.config_dirty - assert header_row in navigable_row_indices(view) + assert header_row in view.layout.navigable_indices(view) def test_blend_and_opacity_change_sets_dirty_save_clears() -> None: @@ -741,7 +733,7 @@ def test_config_header_truncates_long_paths() -> None: controls._config_save._active_config_path = long_path view = controls.build_view_state(paused=False) header_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.CONFIG_HEADER ) font = _overlay_font() panel_w = baseline_tuning_ui_metrics().panel_content_max_width @@ -812,7 +804,7 @@ def test_fit_row_text_config_and_preset_share_panel_width() -> None: preset_empty=False, ) header_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.CONFIG_HEADER + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.CONFIG_HEADER ) preset_row = _row(view, "layer_1", RowKind.TRACK_PRESET) font = _overlay_font() @@ -837,7 +829,7 @@ def test_save_as_new_updates_active_config_path() -> None: assert controls._config_save._active_config_path == saved_path state = controls.build_view_state(paused=False) header_row = next( - i for i in range(row_count(state)) if row_kind(state, i) == RowKind.CONFIG_HEADER + i for i in range(len(state.layout)) if state.layout.kind( i) == RowKind.CONFIG_HEADER ) assert _row_text(state, header_row) == config_path_display(saved_path) @@ -859,7 +851,7 @@ def test_save_as_new_enables_overwrite_from_root_template() -> None: state = controls.build_view_state(paused=False) assert state.allow_overwrite is True - kinds = {row_kind(state, i) for i in range(row_count(state))} + kinds = {state.layout.kind( i) for i in range(len(state.layout))} assert RowKind.CONFIG_HEADER in kinds @@ -944,16 +936,16 @@ def test_navigable_rows_without_overwrite() -> None: ) view = controls.build_view_state(paused=False) assert view.allow_overwrite is False - assert row_count(view) == 16 + assert len(view.layout) == 16 - kinds = {row_kind(view, i) for i in range(row_count(view))} + kinds = {view.layout.kind(i) for i in range(len(view.layout))} assert RowKind.CONFIG_HEADER in kinds - navigable = navigable_row_indices(view) - assert any(row_kind(view, i) == RowKind.CONFIG_HEADER for i in navigable) + navigable = view.layout.navigable_indices(view) + assert any(view.layout.kind(i) == RowKind.CONFIG_HEADER for i in navigable) transport_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) config_row = _config_header_row(view) controls.focus_index = config_row @@ -965,10 +957,10 @@ def test_navigable_rows_with_overwrite() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) assert view.allow_overwrite is True - assert row_count(view) == 16 + assert len(view.layout) == 16 config_row = _config_header_row(view) - assert config_row in navigable_row_indices(view) + assert config_row in view.layout.navigable_indices(view) def test_overwrite_shows_confirm_before_write() -> None: @@ -1145,7 +1137,7 @@ def test_track_header_expand_arrow() -> None: def test_render_overlay_header_label_spacing() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) assert _row_text(view, header_row) == "Render: OVERLAY ▶" @@ -1153,12 +1145,12 @@ def test_render_overlay_title_header_expand_arrow() -> None: controls = _make_controls() controls.session.render_overlay.expanded = True view = controls.build_view_state(paused=False) - title_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) assert _row_text(view, title_header) == "└─ title ▶" controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - title_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) assert _row_text(view, title_header) == "└─ title ▼" @@ -1166,12 +1158,12 @@ def test_render_overlay_body_header_expand_arrow() -> None: controls = _make_controls() controls.session.render_overlay.expanded = True view = controls.build_view_state(paused=False) - body_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_HEADER) + body_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_HEADER) assert _row_text(view, body_header) == "└─ body ▶" controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - body_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_HEADER) + body_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_HEADER) assert _row_text(view, body_header) == "└─ body ▼" @@ -1188,7 +1180,7 @@ def test_render_overlay_title_font_row(_mock_fonts) -> None: controls.session.render_overlay.title_expanded = True controls.session.render_overlay.title_font = "alpha" view = controls.build_view_state(paused=False) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT) assert _row_text(view, font_row) == "└─ font: alpha (1/3)" controls.focus_index = font_row @@ -1206,7 +1198,7 @@ def test_render_overlay_body_font_row(_mock_fonts) -> None: controls.session.render_overlay.body_expanded = True controls.session.render_overlay.body_font = "bravo" view = controls.build_view_state(paused=False) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_FONT) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT) assert _row_text(view, font_row) == "└─ font: bravo (2/3)" controls.focus_index = font_row @@ -1220,7 +1212,7 @@ def test_render_overlay_title_font_size_row() -> None: controls.session.render_overlay.title_expanded = True controls.session.render_overlay.title_font_size = 12 view = controls.build_view_state(paused=False) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) assert _row_text(view, font_row) == "└─ font size: 12px" controls.focus_index = font_row @@ -1234,7 +1226,7 @@ def test_render_overlay_title_margin_bottom_row() -> None: controls.session.render_overlay.title_expanded = True controls.session.render_overlay.title_margin_bottom = 10 view = controls.build_view_state(paused=False) - margin_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) + margin_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) assert _row_text(view, margin_row) == "└─ margin bottom: 10px" controls.focus_index = margin_row @@ -1248,7 +1240,7 @@ def test_render_overlay_body_font_size_row() -> None: controls.session.render_overlay.body_expanded = True controls.session.render_overlay.body_font_size = 18 view = controls.build_view_state(paused=False) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) assert _row_text(view, font_row) == "└─ font size: 18px" controls.focus_index = font_row @@ -1262,12 +1254,12 @@ def test_render_overlay_font_rows_nested_indent() -> None: controls.session.render_overlay.title_expanded = True controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - title_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) - title_font_size = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) - title_font = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT) - body_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_HEADER) - body_font_size = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) - body_font = find_row_by_kind(view, RowKind.RENDER_OVERLAY_BODY_FONT) + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) + title_font_size = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + title_font = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT) + body_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_HEADER) + body_font_size = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) + body_font = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT) assert _row_indent(view, title_header) == TREE_INDENT assert _row_indent(view, title_font_size) == TREE_INDENT * 2 assert _row_indent(view, title_font) == TREE_INDENT * 2 @@ -1280,7 +1272,7 @@ def test_render_overlay_title_header_toggles_expansion() -> None: controls = _make_controls() controls.session.render_overlay.expanded = True view = controls.build_view_state(paused=False) - title_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) controls.focus_index = title_header controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -1295,8 +1287,8 @@ def test_render_overlay_collapse_refocuses_from_title_font_row() -> None: controls.session.render_overlay.expanded = True controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - overlay_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + overlay_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) controls.focus_index = font_row controls._render_overlay.set_expanded(False) @@ -1308,8 +1300,8 @@ def test_render_overlay_title_collapse_refocuses_from_font_row() -> None: controls.session.render_overlay.expanded = True controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - title_header = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_HEADER) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) controls.focus_index = font_row controls._render_overlay.set_title_expanded(False) @@ -1369,35 +1361,35 @@ def test_transport_icons_play_vs_pause() -> None: def test_render_timeline_header_after_post_fx() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - post_fx_row = find_row_by_kind(view, RowKind.RENDER_POST_FX_HEADER) - timeline_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) + post_fx_row = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) assert transport_row < post_fx_row < timeline_row def test_render_timeline_header_label_spacing() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row) == "Render: TIMELINE ▶" def test_render_timeline_header_expand_arrow() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▶") controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▼") def test_render_timeline_ctrl_right_toggles_enabled() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row assert controls.session.timeline.enabled is True @@ -1416,7 +1408,7 @@ def test_render_timeline_ctrl_right_toggles_enabled() -> None: def test_render_timeline_enable_opens_panel() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row assert controls.session.timeline.enabled is False assert controls.session.timeline.panel_open is False @@ -1426,14 +1418,14 @@ def test_render_timeline_enable_opens_panel() -> None: assert controls.session.timeline.panel_open is True assert controls.session.timeline.submenu_focused is False view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▼") def test_render_timeline_right_opens_panel() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.focus_row = 2 assert controls.session.timeline.panel_open is False @@ -1444,21 +1436,21 @@ def test_render_timeline_right_opens_panel() -> None: assert controls.session.timeline.submenu_focused is False assert controls.session.timeline.focus_row == 2 view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▼") controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.timeline.panel_open is False assert controls.session.timeline.submenu_focused is False view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▶") def test_render_timeline_down_enters_submenu() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.panel_open = True controls.session.timeline.focus_row = 2 @@ -1472,7 +1464,7 @@ def test_render_timeline_down_enters_submenu() -> None: def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.panel_open = True controls.session.timeline.focus_row = 2 @@ -1508,9 +1500,9 @@ def test_vertical_navigation_repeats_on_hold() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - transport = find_row_by_kind(view, RowKind.TRANSPORT) + transport = view.layout.find_by_kind(RowKind.TRANSPORT) controls.focus_index = transport - navigable = navigable_row_indices(view) + navigable = view.layout.navigable_indices(view) start_pos = navigable.index(transport) controls.handle_keydown(_keydown(pygame.K_DOWN)) @@ -1549,7 +1541,7 @@ def test_vertical_navigation_stops_on_keyup() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) - transport = find_row_by_kind(view, RowKind.TRANSPORT) + transport = view.layout.find_by_kind(RowKind.TRANSPORT) controls.focus_index = transport controls.handle_keydown(_keydown(pygame.K_DOWN)) @@ -1597,7 +1589,7 @@ def test_held_key_repeat_keeps_overlay_visible() -> None: def test_render_timeline_submenu_up_returns_to_header() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True @@ -1607,7 +1599,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert controls.session.timeline.submenu_focused is False view = controls.build_view_state(paused=False) - assert controls.focus_index == find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + assert controls.focus_index == view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -1615,7 +1607,7 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False @@ -1635,7 +1627,7 @@ def test_render_timeline_submenu_down_from_last_row_wraps_to_transport() -> None stems = ("layer_1", "layer_2", "layer_3", "layer_4") controls = _make_controls(stems, timeline_enabled=True) view = controls.build_view_state(paused=False) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True controls.session.timeline.focus_row = len(stems) - 1 @@ -1650,7 +1642,7 @@ def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: stems = ("layer_1", "layer_2", "layer_3", "layer_4") controls = _make_controls(stems, timeline_enabled=True) view = controls.build_view_state(paused=False) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) controls.focus_index = transport_row controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False @@ -1665,10 +1657,10 @@ def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: def test_render_timeline_panel_closed_wrap_unchanged() -> None: controls = _make_controls(("layer_1", "layer_2"), timeline_enabled=True) view = controls.build_view_state(paused=False) - navigable = navigable_row_indices(view) - settings_row = find_row_by_kind(view, RowKind.SETTINGS_HEADER) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) - timeline_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + navigable = view.layout.navigable_indices(view) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) + timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = timeline_row controls.session.timeline.panel_open = False @@ -1687,7 +1679,7 @@ def test_render_timeline_disable_closes_panel() -> None: controls.session.timeline.enabled = True controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) @@ -1698,12 +1690,12 @@ def test_render_timeline_disable_closes_panel() -> None: def test_render_timeline_header_eye_color_when_disabled() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_value_color(view, header_row) == VALUE controls.session.timeline.enabled = False view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_value_color(view, header_row) == DISABLED @@ -1737,7 +1729,7 @@ def test_render_timeline_enabled_change_callback() -> None: ) ) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) @@ -1765,10 +1757,10 @@ def test_t_opens_timeline_panel_when_enabled() -> None: def test_t_closes_timeline_panel_and_focuses_header_when_open() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False - controls.focus_index = find_row_by_kind(view, RowKind.TRANSPORT) + controls.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_t)) assert controls.session.timeline.panel_open is False @@ -1779,7 +1771,7 @@ def test_t_closes_timeline_panel_and_focuses_header_when_open() -> None: def test_t_from_submenu_closes_and_focuses_render_timeline_header() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_index = header_row controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True @@ -1813,7 +1805,7 @@ def test_t_ignored_during_move_mode() -> None: controls = _make_controls(("layer_1",)) controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.TRACK_HEADER) + header_row = view.layout.find_by_kind(RowKind.TRACK_HEADER) controls.focus_index = header_row controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_1" @@ -1826,7 +1818,7 @@ def test_transport_enter_toggles_pause() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) transport_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) controls.focus_index = transport_row assert controls.playback.paused is False @@ -1842,7 +1834,7 @@ def test_space_toggles_pause_from_any_focus() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) assert controls.playback.paused is False - assert controls.focus_index == find_row_by_kind(view, RowKind.TRANSPORT) + assert controls.focus_index == view.layout.find_by_kind(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_SPACE)) assert controls.playback.paused is True @@ -1856,10 +1848,10 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: controls.session.layers["layer_1"].enabled = False view = controls.build_view_state(paused=False) - quick = quick_nav_row_indices(view) + quick = view.layout.quick_nav_indices() assert len(quick) == 6 for index in quick: - kind = row_kind(view, index) + kind = view.layout.kind( index) assert kind in ( RowKind.TRACK_HEADER, RowKind.RENDER_OVERLAY_HEADER, @@ -1870,23 +1862,19 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: drums_header = next( i - for i in range(row_count(view)) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_1" + for i in range(len(view.layout)) + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_1" ) bass_header = next( i - for i in range(row_count(view)) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" - ) - render_overlay_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) - render_post_fx_row = find_row_by_kind( - view, RowKind.RENDER_POST_FX_HEADER - ) - render_timeline_row = find_row_by_kind( - view, RowKind.RENDER_TIMELINE_HEADER + for i in range(len(view.layout)) + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) + render_overlay_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) + render_post_fx_row = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + render_timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) transport_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) assert quick == [ transport_row, @@ -1901,7 +1889,7 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: def test_ctrl_quick_nav_cycles_headers_and_transport() -> None: controls = _make_controls(("layer_1", "layer_2")) view = controls.build_view_state(paused=False) - quick = quick_nav_row_indices(view) + quick = view.layout.quick_nav_indices() controls.focus_index = quick[0] controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) @@ -1929,9 +1917,9 @@ def test_ctrl_quick_nav_cycles_headers_and_transport() -> None: def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: controls = _make_controls(("layer_1", "layer_2")) view = controls.build_view_state(paused=False) - quick = quick_nav_row_indices(view) + quick = view.layout.quick_nav_indices() preset_row = next( - i for i in range(12) if row_kind(view, i) == RowKind.TRACK_PRESET + i for i in range(12) if view.layout.kind(i) == RowKind.TRACK_PRESET ) controls.focus_index = preset_row @@ -1946,7 +1934,7 @@ def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: def test_ctrl_quick_nav_from_config_header_row() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - quick = quick_nav_row_indices(view) + quick = view.layout.quick_nav_indices() config_row = _config_header_row(view) controls.focus_index = config_row @@ -1973,14 +1961,14 @@ def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: controls = _make_controls(("layer_1", "layer_2"), timeline_enabled=True) view = controls.build_view_state(paused=False) - quick = quick_nav_row_indices(view) - timeline_header = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - transport_row = find_row_by_kind(view, RowKind.TRANSPORT) + quick = view.layout.quick_nav_indices() + timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True controls.session.timeline.focus_row = 1 - controls.focus_index = find_row_by_kind(view, RowKind.TRANSPORT) + controls.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.submenu_focused is False @@ -2233,8 +2221,8 @@ def test_move_mode_colors_focused_track_header() -> None: view = controls.build_view_state(paused=False) header_row = next( i - for i in range(row_count(view)) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + for i in range(len(view.layout)) + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) controls.focus_index = header_row assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True @@ -2323,8 +2311,8 @@ def _header_row( view = controls.build_view_state(paused=paused) return next( i - for i in range(row_count(view)) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == stem + for i in range(len(view.layout)) + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == stem ) @@ -2341,8 +2329,8 @@ def _sub_rows_for_stem(view: TuningViewState, stem: str) -> list[int]: ) return [ i - for i in range(row_count(view)) - if row_slot(view, i) == stem and row_kind(view, i) in sub_kinds + for i in range(len(view.layout)) + if view.layout.slot( i) == stem and view.layout.kind(i) in sub_kinds ] @@ -2368,8 +2356,8 @@ def test_locked_expanded_skips_sub_rows_in_nav() -> None: sub_rows = _sub_rows_for_stem(view, "layer_1") assert sub_rows - visible = visible_row_indices(view) - navigable = navigable_row_indices(view) + visible = view.layout.visible_indices(view) + navigable = view.layout.navigable_indices(view) effects_header = _row(view, "layer_1", RowKind.TRACK_EFFECTS_HEADER) stem_row = _row(view, "layer_1", RowKind.TRACK_STEM) assert effects_header in navigable @@ -2420,13 +2408,13 @@ def test_locked_header_still_expands() -> None: assert controls.session.layers["layer_1"].expanded is True view = controls.build_view_state(paused=False) - assert preset_dir_row in visible_row_indices(view) + assert preset_dir_row in view.layout.visible_indices(view) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.layers["layer_1"].expanded is False view = controls.build_view_state(paused=False) - assert preset_dir_row not in visible_row_indices(view) + assert preset_dir_row not in view.layout.visible_indices(view) def test_locked_sub_rows_use_locked_color() -> None: @@ -2441,7 +2429,7 @@ def test_locked_sub_rows_use_locked_color() -> None: tracks=view.tracks, paused=view.paused, position_sec=view.position_sec, - focus_index=row_count(view) - 1, + focus_index=len(view.layout) - 1, move_mode_slot=view.move_mode_slot, toast_message=view.toast_message, toast_remaining_sec=view.toast_remaining_sec, @@ -2487,7 +2475,7 @@ def test_ctrl_quick_nav_blocked_during_move_mode() -> None: bass_header = next( i for i in range(15) - if row_kind(view, i) == RowKind.TRACK_HEADER and row_slot(view, i) == "layer_2" + if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) controls.focus_index = bass_header controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -2507,7 +2495,7 @@ def test_transport_seek_constants() -> None: view = controls.build_view_state(paused=False) transport_row = next( - i for i in range(row_count(view)) if row_kind(view, i) == RowKind.TRANSPORT + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) controls.focus_index = transport_row @@ -2869,27 +2857,27 @@ def test_try_quit_overwrite_confirm_esc_clears_quit_after_save() -> None: def test_settings_header_is_first_row() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - assert row_kind(view, 0) == RowKind.SETTINGS_HEADER - assert row_kind(view, 1) == RowKind.CONFIG_HEADER - assert row_kind(view, 2) == RowKind.TRANSPORT + assert view.layout.kind( 0) == RowKind.SETTINGS_HEADER + assert view.layout.kind( 1) == RowKind.CONFIG_HEADER + assert view.layout.kind( 2) == RowKind.TRANSPORT def test_settings_expand_collapse_and_sub_row_visibility() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - settings_row = find_row_by_kind(view, RowKind.SETTINGS_HEADER) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) assert RowKind.SETTINGS_RENDER_MODE not in { - row_kind(view, i) for i in range(row_count(view)) + view.layout.kind(i) for i in range(len(view.layout)) } controls.focus_index = settings_row controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.settings.expanded is True view = controls.build_view_state(paused=False) - render_mode_row = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + render_mode_row = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) assert render_mode_row == 1 - assert render_mode_row in navigable_row_indices(view) - assert header_row_count(view) == 4 + assert render_mode_row in view.layout.navigable_indices(view) + assert view.layout.header_row_count() == 4 controls.focus_index = render_mode_row controls.handle_keydown(_keydown(pygame.K_LEFT)) @@ -2898,31 +2886,31 @@ def test_settings_expand_collapse_and_sub_row_visibility() -> None: assert controls.session.settings.expanded is False view = controls.build_view_state(paused=False) assert RowKind.SETTINGS_RENDER_MODE not in { - row_kind(view, i) for i in range(row_count(view)) + view.layout.kind(i) for i in range(len(view.layout)) } - assert header_row_count(view) == 3 + assert view.layout.header_row_count() == 3 def test_settings_collapse_from_sub_row_refocuses_header() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) controls._settings.set_expanded(False) view = controls.build_view_state(paused=False) - assert controls.focus_index == find_row_by_kind(view, RowKind.SETTINGS_HEADER) + assert controls.focus_index == view.layout.find_by_kind(RowKind.SETTINGS_HEADER) def test_settings_cycle_render_mode() -> None: controls = _make_controls(("layer_1",)) assert controls.cfg.visualizer.render_mode == "balanced" view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.cfg.visualizer.render_mode == "performance" @@ -2937,10 +2925,10 @@ def test_settings_render_mode_change_marks_config_dirty() -> None: controls = _make_controls(("layer_1",)) assert not controls.config_dirty view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.config_dirty @@ -2948,4 +2936,4 @@ def test_settings_render_mode_change_marks_config_dirty() -> None: def test_default_focus_stays_on_transport() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - assert controls.focus_index == find_row_by_kind(view, RowKind.TRANSPORT) \ No newline at end of file + assert controls.focus_index == view.layout.find_by_kind(RowKind.TRANSPORT) \ No newline at end of file diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index b3e9e1c..4d4706c 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -17,7 +17,6 @@ from cleave.timeline import TimelineCue from cleave.viz.session import LayerRuntime, TimelineRuntime, TuningSession from cleave.viz.row_semantics import RowKind -from cleave.viz.overlay import find_row_by_kind from cleave.viz.layer import StemLayer from cleave.viz.layer_pipeline import LayerFramePipeline from cleave.viz.layer_visibility import ( @@ -253,7 +252,7 @@ def test_header_toggle_blocked_when_timeline_enabled() -> None: controls = make_controls(("layer_1",)) controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) - controls.focus_index = find_row_by_kind(view, RowKind.TRACK_HEADER) + controls.focus_index = view.layout.find_by_kind(RowKind.TRACK_HEADER) assert controls.session.layers["layer_1"].enabled is True controls.handle_keydown(keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 05e7573..a164b93 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -20,21 +20,14 @@ _row_bg_color, _row_text, _row_value_color, - build_row_layout, - find_row, - find_row_by_kind, fit_row_text, - navigable_row_indices, panel_content_max_width, panel_fps_layout, panel_help_hint_layout, panel_toast_layout, render_visibility_icon, - row_kind, TREE_INDENT, - row_slot, scroll_metrics, - visible_row_indices, ) from cleave.viz.theme import ( ACTION, @@ -87,7 +80,7 @@ def test_draw_effects_expanded_panel_rect_within_surface() -> None: pygame.init() overlay = TuningOverlay() state = _effects_expanded_view_state() - assert len(visible_row_indices(state)) > 30 + assert len(state.layout.visible_indices(state)) > 30 surface = pygame.Surface((1280, 720), pygame.SRCALPHA) overlay.notify_input() @@ -113,12 +106,12 @@ def _panel_scroll_metrics( ) -> PanelScrollMetrics: font = overlay._font_get() line_h = font.get_linesize() - visible_indices = visible_row_indices(state) + visible_indices = state.layout.visible_indices(state) first_scrollable_visible = next( ( index for index in visible_indices - if row_kind(state, index) not in { + if state.layout.kind( index) not in { RowKind.CONFIG_HEADER, RowKind.TRANSPORT, RowKind.SETTINGS_HEADER, @@ -151,7 +144,7 @@ def test_scrolled_panel_keeps_focus_row_in_viewport() -> None: pygame.init() overlay = TuningOverlay() state = _effects_expanded_view_state() - state.focus_index = find_row_by_kind(state, RowKind.RENDER_TIMELINE_HEADER) - 1 + state.focus_index = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) - 1 surface = pygame.Surface((1280, 720), pygame.SRCALPHA) overlay.notify_input() @@ -178,7 +171,7 @@ def _copy_panel_surface(overlay: TuningOverlay, state: TuningViewState) -> pygam def test_header_rows_pinned_when_scrolled() -> None: pygame.init() state_top = _effects_expanded_view_state() - scroll_focus = find_row_by_kind(state_top, RowKind.RENDER_TIMELINE_HEADER) - 1 + scroll_focus = state_top.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) - 1 state_top.focus_index = scroll_focus state_bottom = _effects_expanded_view_state() state_bottom.focus_index = scroll_focus @@ -263,8 +256,7 @@ def test_draw_fps_counter_when_present() -> None: font = overlay._font_get() fps_text = format_fps_display(28.4) - fps_color = _row_value_color(state, find_row_by_kind(state, RowKind.TRANSPORT)) - fps_surf = font.render(fps_text, True, fps_color) + fps_surf = font.render(fps_text, True, DISABLED) metrics = _panel_scroll_metrics(overlay, state) layout = panel_fps_layout( panel_w=with_fps.get_width(), @@ -273,7 +265,29 @@ def test_draw_fps_counter_when_present() -> None: show_scrollbar=metrics.show_scrollbar, ) sampled = with_fps.get_at((layout.x + 2, layout.y + font.get_linesize() // 2)) - assert sampled[:3] == fps_color + assert sampled[:3] == DISABLED + + +def test_fps_color_ignores_transport_focus() -> None: + pygame.init() + overlay = TuningOverlay() + state = _minimal_view_state(fps=30.0) + transport_index = state.layout.find_by_kind(RowKind.TRANSPORT) + state = _minimal_view_state(fps=30.0, focus_index=transport_index) + + with_fps = _copy_panel_surface(overlay, state) + font = overlay._font_get() + fps_surf = font.render(format_fps_display(30.0), True, DISABLED) + metrics = _panel_scroll_metrics(overlay, state) + layout = panel_fps_layout( + panel_w=with_fps.get_width(), + padding=overlay._padding, + text_width=fps_surf.get_width(), + show_scrollbar=metrics.show_scrollbar, + ) + sampled = with_fps.get_at((layout.x + 2, layout.y + font.get_linesize() // 2)) + assert sampled[:3] == DISABLED + assert sampled[:3] != HIGHLIGHT def test_panel_content_max_width_reserves_scrollbar() -> None: @@ -351,8 +365,8 @@ def test_preset_rows_fit_within_scrollbar_content_width() -> None: scrollable = frozenset(metrics.scrollable_indices) font = overlay._font_get() - drums_dir_idx = find_row_by_kind(state, RowKind.TRACK_PRESET_DIR) - drums_preset_idx = find_row_by_kind(state, RowKind.TRACK_PRESET) + drums_dir_idx = state.layout.find_by_kind( RowKind.TRACK_PRESET_DIR) + drums_preset_idx = state.layout.find_by_kind( RowKind.TRACK_PRESET) for index, expected_counter in ( (drums_dir_idx, "(12/99)"), (drums_preset_idx, "(34/99)"), @@ -445,7 +459,7 @@ def test_track_header_uses_stem_display_not_slot_key() -> None: ) }, ) - header_row = find_row_by_kind(state, RowKind.TRACK_HEADER) + header_row = state.layout.find_by_kind( RowKind.TRACK_HEADER) text = _row_text(state, header_row) assert "DRUMS" in text assert "LAYER_1" not in text.upper() @@ -465,7 +479,7 @@ def test_track_header_full_mix_shows_mix() -> None: ) }, ) - header_row = find_row_by_kind(state, RowKind.TRACK_HEADER) + header_row = state.layout.find_by_kind( RowKind.TRACK_HEADER) text = _row_text(state, header_row) assert "MIX" in text assert "FULL_MIX" not in text.upper() @@ -486,7 +500,7 @@ def test_track_stem_row_text() -> None: ) }, ) - stem_row = find_row(state, "layer_1", RowKind.TRACK_STEM) + stem_row = state.layout.find( "layer_1", RowKind.TRACK_STEM) assert _row_text(state, stem_row) == "└─ driving stem: full-mix" @@ -506,8 +520,8 @@ def test_locked_stem_row_not_navigable_and_uses_locked_color() -> None: ) }, ) - stem_row = find_row(state, "layer_1", RowKind.TRACK_STEM) - assert stem_row not in navigable_row_indices(state) + stem_row = state.layout.find( "layer_1", RowKind.TRACK_STEM) + assert stem_row not in state.layout.navigable_indices(state) assert _row_value_color(state, stem_row) == LOCKED @@ -518,15 +532,15 @@ def test_timeline_layer_hint_when_timeline_enabled() -> None: enabled = _minimal_view_state( render_timeline=RenderTimelineBlock(enabled=True), ) - disabled_kinds = [row.kind for row in build_row_layout(disabled)] - enabled_kinds = [row.kind for row in build_row_layout(enabled)] + disabled_kinds = [row.kind for row in disabled.layout.rows] + enabled_kinds = [row.kind for row in enabled.layout.rows] assert RowKind.TIMELINE_LAYER_HINT not in disabled_kinds assert RowKind.TIMELINE_LAYER_HINT in enabled_kinds - hint_idx = find_row_by_kind(enabled, RowKind.TIMELINE_LAYER_HINT) - gap_idx = find_row_by_kind(enabled, RowKind.RENDER_SECTION_GAP) - overlay_idx = find_row_by_kind(enabled, RowKind.RENDER_OVERLAY_HEADER) + hint_idx = enabled.layout.find_by_kind( RowKind.TIMELINE_LAYER_HINT) + gap_idx = enabled.layout.find_by_kind( RowKind.RENDER_SECTION_GAP) + overlay_idx = enabled.layout.find_by_kind( RowKind.RENDER_OVERLAY_HEADER) assert hint_idx < gap_idx < overlay_idx - assert hint_idx not in navigable_row_indices(enabled) + assert hint_idx not in enabled.layout.navigable_indices(enabled) assert _row_value_color(enabled, hint_idx) == DISABLED @@ -540,15 +554,15 @@ def test_draw_timeline_layer_hint_without_error() -> None: overlay.notify_input() overlay.draw(surface, state) assert overlay.panel_rect is not None - hint_idx = find_row_by_kind(state, RowKind.TIMELINE_LAYER_HINT) - assert hint_idx in visible_row_indices(state) + hint_idx = state.layout.find_by_kind( RowKind.TIMELINE_LAYER_HINT) + assert hint_idx in state.layout.visible_indices(state) def test_build_row_layout_includes_add_before_render_gap() -> None: state = _minimal_view_state() - add_idx = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) - gap_idx = find_row_by_kind(state, RowKind.RENDER_SECTION_GAP) - overlay_idx = find_row_by_kind(state, RowKind.RENDER_OVERLAY_HEADER) + add_idx = state.layout.find_by_kind( RowKind.LAYER_MANAGEMENT_ADD) + gap_idx = state.layout.find_by_kind( RowKind.RENDER_SECTION_GAP) + overlay_idx = state.layout.find_by_kind( RowKind.RENDER_OVERLAY_HEADER) assert add_idx < gap_idx < overlay_idx @@ -568,9 +582,9 @@ def test_delete_row_after_effects_when_expanded() -> None: ) }, ) - layout = build_row_layout(state) - delete_idx = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) - effects_header = find_row(state, "layer_1", RowKind.TRACK_EFFECTS_HEADER) + layout = state.layout.rows + delete_idx = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + effects_header = state.layout.find( "layer_1", RowKind.TRACK_EFFECTS_HEADER) effect_rows = [ index for index, row in enumerate(layout) @@ -596,7 +610,7 @@ def test_delete_row_omitted_when_track_collapsed() -> None: ) }, ) - kinds = [row.kind for row in build_row_layout(state)] + kinds = [row.kind for row in state.layout.rows] assert RowKind.LAYER_MANAGEMENT_DELETE not in kinds @@ -616,8 +630,8 @@ def test_delete_row_navigable_when_locked() -> None: ) }, ) - delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) - assert delete_row in navigable_row_indices(state) + delete_row = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + assert delete_row in state.layout.navigable_indices(state) def test_add_row_always_navigable() -> None: @@ -650,8 +664,8 @@ def test_add_row_always_navigable() -> None: }, ) for state in (collapsed, expanded): - add_row = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) - assert add_row in navigable_row_indices(state) + add_row = state.layout.find_by_kind( RowKind.LAYER_MANAGEMENT_ADD) + assert add_row in state.layout.navigable_indices(state) def test_delete_row_disabled_color_single_layer() -> None: @@ -669,7 +683,7 @@ def test_delete_row_disabled_color_single_layer() -> None: ) }, ) - delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + delete_row = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) assert _row_value_color(state, delete_row) == DISABLED @@ -688,7 +702,7 @@ def test_delete_layer_row_text_has_tree_prefix() -> None: ) }, ) - delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + delete_row = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) assert _row_text(state, delete_row) == "└─ Delete Layer" @@ -718,9 +732,9 @@ def test_action_row_value_color() -> None: }, layer_z_order=["layer_1", "layer_2"], ) - config_row = find_row_by_kind(state, RowKind.CONFIG_HEADER) - add_row = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) - delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + config_row = state.layout.find_by_kind( RowKind.CONFIG_HEADER) + add_row = state.layout.find_by_kind( RowKind.LAYER_MANAGEMENT_ADD) + delete_row = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) assert _row_value_color(state, config_row) == ACTION assert _row_value_color(state, add_row) == ACTION assert _row_value_color(state, delete_row) == ACTION @@ -748,17 +762,17 @@ def test_draw_layer_management_rows_without_error() -> None: overlay.notify_input() overlay.draw(surface, state) assert overlay.panel_rect is not None - add_row = find_row_by_kind(state, RowKind.LAYER_MANAGEMENT_ADD) - delete_row = find_row(state, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) - assert add_row in visible_row_indices(state) - assert delete_row in visible_row_indices(state) + add_row = state.layout.find_by_kind( RowKind.LAYER_MANAGEMENT_ADD) + delete_row = state.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE) + assert add_row in state.layout.visible_indices(state) + assert delete_row in state.layout.visible_indices(state) def test_render_overlay_row_layout_includes_header_and_sub_rows_when_expanded() -> None: state = _minimal_view_state( render_overlay=RenderOverlayBlock(expanded=True), ) - kinds = [row.kind for row in build_row_layout(state)] + kinds = [row.kind for row in state.layout.rows] assert RowKind.RENDER_OVERLAY_HEADER in kinds assert RowKind.RENDER_OVERLAY_POSITION in kinds assert RowKind.RENDER_OVERLAY_TITLE_HEADER in kinds @@ -769,8 +783,8 @@ def test_render_overlay_row_layout_includes_header_and_sub_rows_when_expanded() assert RowKind.RENDER_OVERLAY_DISPLAY_TIME in kinds assert RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE not in kinds assert RowKind.RENDER_OVERLAY_BODY_FONT_SIZE not in kinds - header_idx = find_row_by_kind(state, RowKind.RENDER_OVERLAY_HEADER) - config_idx = find_row_by_kind(state, RowKind.CONFIG_HEADER) + header_idx = state.layout.find_by_kind( RowKind.RENDER_OVERLAY_HEADER) + config_idx = state.layout.find_by_kind( RowKind.CONFIG_HEADER) assert config_idx < header_idx @@ -782,7 +796,7 @@ def test_render_overlay_title_and_body_font_rows_when_expanded() -> None: body_expanded=True, ), ) - kinds = [row.kind for row in build_row_layout(state)] + kinds = [row.kind for row in state.layout.rows] assert RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE in kinds assert RowKind.RENDER_OVERLAY_TITLE_FONT in kinds assert RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM in kinds @@ -797,12 +811,12 @@ def test_render_overlay_collapsed_hides_sub_rows() -> None: expanded = _minimal_view_state( render_overlay=RenderOverlayBlock(expanded=True), ) - collapsed_kinds = {row.kind for row in build_row_layout(collapsed)} - expanded_kinds = {row.kind for row in build_row_layout(expanded)} + collapsed_kinds = {row.kind for row in collapsed.layout.rows} + expanded_kinds = {row.kind for row in expanded.layout.rows} assert RowKind.RENDER_OVERLAY_HEADER in collapsed_kinds assert RowKind.RENDER_OVERLAY_POSITION not in collapsed_kinds assert RowKind.RENDER_OVERLAY_TITLE_HEADER not in collapsed_kinds - assert len(visible_row_indices(collapsed)) + 7 == len(visible_row_indices(expanded)) + assert len(collapsed.layout.visible_indices(collapsed)) + 7 == len(expanded.layout.visible_indices(expanded)) def test_draw_render_overlay_header_without_error() -> None: @@ -819,8 +833,8 @@ def test_draw_render_overlay_header_without_error() -> None: overlay.notify_input() overlay.draw(surface, state) assert overlay.panel_rect is not None - header_row = find_row_by_kind(state, RowKind.RENDER_OVERLAY_HEADER) - assert header_row in visible_row_indices(state) + header_row = state.layout.find_by_kind( RowKind.RENDER_OVERLAY_HEADER) + assert header_row in state.layout.visible_indices(state) def test_draw_track_header_with_solo_eye() -> None: @@ -857,11 +871,11 @@ def test_draw_track_header_with_solo_eye() -> None: header_row = next( i - for i in visible_row_indices(state) - if row_kind(state, i) == RowKind.TRACK_HEADER and row_slot(state, i) == "layer_1" + for i in state.layout.visible_indices(state) + if state.layout.kind( i) == RowKind.TRACK_HEADER and state.layout.slot( i) == "layer_1" ) assert state.solo_slot == "layer_1" - assert header_row == find_row_by_kind(state, RowKind.TRACK_HEADER) + assert header_row == state.layout.find_by_kind( RowKind.TRACK_HEADER) def test_disabled_track_focus_uses_muted_highlight() -> None: @@ -879,7 +893,7 @@ def test_disabled_track_focus_uses_muted_highlight() -> None: ) }, ) - header_row = find_row_by_kind(state, RowKind.TRACK_HEADER) + header_row = state.layout.find_by_kind( RowKind.TRACK_HEADER) state.focus_index = header_row assert _row_value_color(state, header_row) == HIGHLIGHT_MUTED assert _row_bg_color(state, header_row) == HIGHLIGHT_MUTED @@ -892,7 +906,7 @@ def test_main_tree_rows_not_highlighted_when_timeline_submenu_focused() -> None: render_timeline=RenderTimelineBlock(enabled=True, expanded=True), ) for row_kind_target in (RowKind.TRANSPORT, RowKind.TRACK_HEADER): - row = find_row_by_kind(state, row_kind_target) + row = state.layout.find_by_kind(row_kind_target) state.focus_index = row state.timeline_submenu_focused = False assert _row_value_color(state, row) == HIGHLIGHT @@ -902,7 +916,7 @@ def test_main_tree_rows_not_highlighted_when_timeline_submenu_focused() -> None: assert _row_value_color(state, row) != HIGHLIGHT assert _row_bg_color(state, row) is None - timeline_row = find_row_by_kind(state, RowKind.RENDER_TIMELINE_HEADER) + timeline_row = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) state.focus_index = timeline_row state.timeline_submenu_focused = False assert _row_value_color(state, timeline_row) == HIGHLIGHT @@ -926,8 +940,8 @@ def test_draw_render_timeline_header_without_error() -> None: overlay.notify_input() overlay.draw(surface, state) assert overlay.panel_rect is not None - header_row = find_row_by_kind(state, RowKind.RENDER_TIMELINE_HEADER) - assert header_row in visible_row_indices(state) + header_row = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) + assert header_row in state.layout.visible_indices(state) def test_overlay_starts_hidden() -> None: diff --git a/tests/cleave/viz/test_overlay_targets.py b/tests/cleave/viz/test_overlay_targets.py index d082820..86dfba7 100644 --- a/tests/cleave/viz/test_overlay_targets.py +++ b/tests/cleave/viz/test_overlay_targets.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pygame @@ -28,8 +28,7 @@ def test_draw_tuning_overlay_uses_display_target() -> None: compositor.draw_content_overlay.assert_not_called() -@patch("cleave.viz.overlay_draw.row_kind", return_value=RowKind.TRANSPORT) -def test_draw_tuning_overlay_uploads_help_panel(_row_kind: MagicMock) -> None: +def test_draw_tuning_overlay_uploads_help_panel() -> None: pygame.init() compositor = recording_compositor() compositor.upload_overlay_texture.side_effect = [11, 22] @@ -41,6 +40,7 @@ def test_draw_tuning_overlay_uploads_help_panel(_row_kind: MagicMock) -> None: view_state = MagicMock() view_state.help_visible = True view_state.focus_index = 0 + view_state.layout.kind.return_value = RowKind.TRANSPORT OverlayDrawer.draw_tuning( compositor, From 6e71e3f7a7a82e491917da0efa8fff4212424c19 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 22:12:51 +0100 Subject: [PATCH 3/7] Complete phase 3 --- cleave/viz/controls.py | 227 ++++------ cleave/viz/focus_context.py | 5 +- cleave/viz/overlay.py | 31 +- cleave/viz/overlay_draw.py | 2 +- cleave/viz/render_overlay_controls.py | 44 +- cleave/viz/render_post_fx_controls.py | 23 +- cleave/viz/row_semantics.py | 21 + cleave/viz/settings_controls.py | 16 - cleave/viz/tuning_view_state.py | 13 +- docs/architecture-improvements.md | 2 + tests/cleave/viz/test_config_dirty.py | 84 ++-- tests/cleave/viz/test_controls.py | 392 +++++++++--------- tests/cleave/viz/test_layer.py | 4 +- tests/cleave/viz/test_overlay.py | 31 +- tests/cleave/viz/test_overlay_targets.py | 4 +- .../cleave/viz/test_row_layout_resolution.py | 147 +++++++ 16 files changed, 543 insertions(+), 503 deletions(-) create mode 100644 tests/cleave/viz/test_row_layout_resolution.py diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index d20f1b7..d82e05c 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -11,14 +11,12 @@ from cleave.config import CleaveConfig, clamp_beat_sensitivity, clamp_effect_pct from cleave.config_schema import MAX_LAYER_COUNT -from cleave.effects.registry import effect_row_count from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES from cleave.viz.config_save import ConfigSaveController from cleave.viz.key_repeat import KeyRepeatController, delete_key_pressed, mod_ctrl, mod_shift from cleave.viz.modal import ModalHost from cleave.viz.playback import PlaybackState, seek, toggle_pause -from cleave.viz.focus_context import FocusContext from cleave.viz.live_layer_bindings import LiveLayerBindings from cleave.viz.render_overlay_controls import RenderOverlayControls from cleave.viz.render_post_fx_controls import RenderPostFxControls @@ -31,10 +29,7 @@ row_behavior, row_triggers_layer_delete, ) -from cleave.viz.overlay import ( - TuningViewState, - row_effect, -) +from cleave.viz.overlay import TuningViewState from cleave.viz.session import TuningSession if TYPE_CHECKING: @@ -74,7 +69,7 @@ def __init__( self._layer_manager = layer_manager self._modal_host = modal_host if modal_host is not None else ModalHost() - self.focus_index = 0 + self.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) self.move_mode_slot: str | None = None self._move_mode_original_z_order: list[str] | None = None self._toast_message: str | None = None @@ -98,36 +93,15 @@ def __init__( playback, duration_sec, preset_root, - get_focus_index=lambda: self.focus_index, + get_focus_descriptor=lambda: self.focus_descriptor, get_move_mode_slot=lambda: self.move_mode_slot, config_save=self._config_save, get_toast_message=lambda: self._toast_message, get_toast_deadline=lambda: self._toast_deadline, ) - def set_focus_index(index: int) -> None: - self.focus_index = index - - focus_context = FocusContext( - get_focus_index=lambda: self.focus_index, - set_focus_index=set_focus_index, - build_view_state=self.build_view_state, - is_paused=lambda: self.playback.paused, - ) - self._render_overlay = RenderOverlayControls( - session, - focus_context=focus_context, - focused_row_kind=self._focused_row_kind, - ) - self._render_post_fx = RenderPostFxControls(session, focus_context=focus_context) - self._settings = SettingsControls( - session, - cfg, - focus_context=focus_context, - focused_row_kind=self._focused_row_kind, - ) - - view = self.build_view_state(paused=self.playback.paused) - self.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) + self._render_overlay = RenderOverlayControls(session) + self._render_post_fx = RenderPostFxControls(session) + self._settings = SettingsControls(session, cfg) def _move_mode_signature_payload(self) -> dict[str, list[str]] | None: if self.move_mode_slot is not None and self._move_mode_original_z_order is not None: @@ -227,8 +201,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True if event.key in (pygame.K_LEFT, pygame.K_RIGHT): - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) + kind = self.focus_descriptor.kind self._apply_horizontal(event.key, event.mod, kind) repeat = kind in REPEAT_ROW_KINDS if repeat and kind == RowKind.TRACK_PRESET_DIR and mod_ctrl(event.mod): @@ -240,18 +213,15 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: on_repeat=lambda key, mod: self._apply_horizontal( key, mod, - self.build_view_state( - paused=self.playback.paused - ).layout.kind(self.focus_index), + self.focus_descriptor.kind, ), ) return True if event.key == pygame.K_BACKSPACE: - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) + kind = self.focus_descriptor.kind if kind == RowKind.TRACK_PRESET_DIR: - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: if layer_lock_blocks_mutation( kind, locked=self.session.layers[slot].locked @@ -261,36 +231,33 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True if delete_key_pressed(event): - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) + kind = self.focus_descriptor.kind if row_triggers_layer_delete(kind): - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: self._delete_layer(slot) return True if event.key == pygame.K_RETURN and mod_ctrl(event.mod): - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) + kind = self.focus_descriptor.kind if kind == RowKind.TRACK_HEADER: - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: self._toggle_locked(slot) return True if event.key == pygame.K_RETURN: - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) + kind = self.focus_descriptor.kind if kind == RowKind.LAYER_MANAGEMENT_ADD: self._add_layer() return True if kind == RowKind.LAYER_MANAGEMENT_DELETE: - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: self._delete_layer(slot) return True if kind == RowKind.TRACK_PRESET_DIR: - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: if layer_lock_blocks_mutation( kind, locked=self.session.layers[slot].locked @@ -302,7 +269,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: toggle_pause(self.playback, self.duration_sec) return True if kind == RowKind.TRACK_HEADER: - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot if slot is not None: if ( self.session.layers[slot].locked @@ -368,61 +335,77 @@ def _move_focus(self, delta: int) -> None: return elif tl.focus_row >= row_count - 1: tl.submenu_focused = False - view = self.build_view_state(paused=self.playback.paused) - self.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) + self.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) return else: tl.focus_row += 1 return view = self.build_view_state(paused=self.playback.paused) - navigable = view.layout.navigable_indices(view) + navigable = view.layout.navigable_descriptors(view) if not navigable: return + current = view.layout.resolve_navigable(self.focus_descriptor, view) try: - pos = navigable.index(self.focus_index) + pos = navigable.index(current) except ValueError: pos = 0 if self._timeline_submenu_active(): - timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - transport = view.layout.find_by_kind(RowKind.TRANSPORT) - if delta > 0 and self.focus_index == timeline_header: + timeline_header = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + transport = RowDescriptor(RowKind.TRANSPORT) + if delta > 0 and current == timeline_header: tl.submenu_focused = True tl.focus_row = 0 return - if delta < 0 and self.focus_index == transport: + if delta < 0 and current == transport: tl.submenu_focused = True tl.focus_row = row_count - 1 return - self.focus_index = navigable[(pos + delta) % len(navigable)] + self.focus_descriptor = navigable[(pos + delta) % len(navigable)] def _move_quick_focus(self, delta: int) -> None: view = self.build_view_state(paused=self.playback.paused) - quick = view.layout.quick_nav_indices() + quick_indices = view.layout.quick_nav_indices() + quick = [view.layout.descriptor(index) for index in quick_indices] if not quick: return tl = self.session.timeline if tl.submenu_focused: tl.submenu_focused = False - timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + timeline_header = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) if delta < 0: - self.focus_index = timeline_header + self.focus_descriptor = timeline_header return - current = timeline_header + current_index = -1 + elif view.layout.contains_descriptor(self.focus_descriptor): + current_index = view.layout.find_descriptor(self.focus_descriptor) else: - current = self.focus_index - if current in quick: - pos = quick.index(current) - self.focus_index = quick[(pos + delta) % len(quick)] + resolved = view.layout.resolve_navigable(self.focus_descriptor, view) + current_index = ( + view.layout.find_descriptor(resolved) + if view.layout.contains_descriptor(resolved) + else -1 + ) + if self.focus_descriptor in quick: + pos = quick.index(self.focus_descriptor) + self.focus_descriptor = quick[(pos + delta) % len(quick)] return if delta > 0: - after = [index for index in quick if index > current] - self.focus_index = after[0] if after else quick[0] + after = [ + desc + for desc in quick + if view.layout.find_descriptor(desc) > current_index + ] + self.focus_descriptor = after[0] if after else quick[0] else: - before = [index for index in quick if index < current] - self.focus_index = before[-1] if before else quick[-1] + before = [ + desc + for desc in quick + if view.layout.find_descriptor(desc) < current_index + ] + self.focus_descriptor = before[-1] if before else quick[-1] def _swap_stem_in_z_order(self, stem: str, direction: int) -> None: order = self.session.layer_z_order @@ -439,19 +422,9 @@ def _confirm_move_mode(self) -> None: self.move_mode_slot = None self._move_mode_original_z_order = None - def _rebuild_view(self, *, nav_pos: int | None = None) -> None: + def _rebuild_view(self) -> None: if self.move_mode_slot is not None: self._confirm_move_mode() - view = self.build_view_state(paused=self.playback.paused) - navigable = view.layout.navigable_indices(view) - if not navigable: - return - if nav_pos is None: - try: - nav_pos = navigable.index(self.focus_index) - except ValueError: - nav_pos = 0 - self.focus_index = navigable[min(nav_pos, len(navigable) - 1)] def _add_layer(self) -> None: if self._layer_manager is None: @@ -485,13 +458,20 @@ def _confirm_delete_layer(self, slot: str) -> None: if self._layer_manager is None: return view = self.build_view_state(paused=self.playback.paused) - navigable = view.layout.navigable_indices(view) + navigable = view.layout.navigable_descriptors(view) + current = view.layout.resolve_navigable(self.focus_descriptor, view) try: - nav_pos = navigable.index(self.focus_index) + nav_pos = navigable.index(current) except ValueError: nav_pos = 0 self._layer_manager.remove_layer(slot) - self._rebuild_view(nav_pos=nav_pos) + self._rebuild_view() + view_after = self.build_view_state(paused=self.playback.paused) + navigable_after = view_after.layout.navigable_descriptors(view_after) + if navigable_after: + self.focus_descriptor = navigable_after[ + min(nav_pos, len(navigable_after) - 1) + ] tl = self.session.timeline row_count = len(self.session.layer_z_order) if row_count == 0: @@ -507,7 +487,7 @@ def _cancel_move_mode(self) -> None: def _apply_horizontal(self, key: int, mod: int, kind: RowKind) -> None: view = self.build_view_state(paused=self.playback.paused) - slot = view.layout.slot(self.focus_index) + slot = self.focus_descriptor.slot ctrl = mod_ctrl(mod) forward = key == pygame.K_RIGHT @@ -582,10 +562,10 @@ def _apply_horizontal(self, key: int, mod: int, kind: RowKind) -> None: elif kind == RowKind.TRACK_EFFECT: if slot is None: return - effect = row_effect(view, self.focus_index) - if effect is None: + effect_id = self.focus_descriptor.effect_id + driver_slug = self.focus_descriptor.driver_slug + if effect_id is None or driver_slug is None: return - effect_id, driver_slug = effect step = 10 if ctrl else 1 delta = step if forward else -step current = self.session.layers[slot].effects.get(effect_id, {}).get( @@ -770,54 +750,11 @@ def _set_expanded(self, slot: str, expanded: bool) -> None: if layer.expanded == expanded: return layer.expanded = expanded - if not expanded: - self._refocus_track_header_if_sub_row(slot) - - def _track_header_index(self, slot: str) -> int: - view = self.build_view_state(paused=self.playback.paused) - return view.layout.find(slot, RowKind.TRACK_HEADER) - - def _refocus_track_header_if_sub_row(self, slot: str) -> None: - view = self.build_view_state(paused=self.playback.paused) - kind = view.layout.kind(self.focus_index) - if kind not in REPEAT_ROW_KINDS: - return - if view.layout.slot(self.focus_index) == slot: - self.focus_index = self._track_header_index(slot) - - def _focused_row_kind(self) -> RowKind | None: - view = self.build_view_state(paused=self.playback.paused) - try: - return view.layout.kind(self.focus_index) - except IndexError: - return None - - def _render_timeline_header_index(self) -> int: - view = self.build_view_state(paused=self.playback.paused) - return view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - - def _focused_row_descriptor(self, view: TuningViewState) -> RowDescriptor | None: - try: - return view.layout.descriptor(self.focus_index) - except IndexError: - return None - - def _restore_focus(self, descriptor: RowDescriptor | None) -> None: - if descriptor is None: - return - view = self.build_view_state(paused=self.playback.paused) - for index, row in enumerate(view.layout.rows): - if row == descriptor: - self.focus_index = index - return - self.focus_index = min(self.focus_index, len(view.layout) - 1) def _set_render_timeline_enabled(self, enabled: bool) -> None: tl = self.session.timeline if tl.enabled == enabled: return - view = self.build_view_state(paused=self.playback.paused) - focused = self._focused_row_descriptor(view) tl.enabled = enabled if enabled: self._open_timeline_panel() @@ -825,7 +762,6 @@ def _set_render_timeline_enabled(self, enabled: bool) -> None: self.close_timeline_panel() if self._layer_bindings is not None: self._layer_bindings.on_timeline_enabled_change() - self._restore_focus(focused) def _enter_solo(self, slot: str) -> None: if self.session.solo_slot == slot: @@ -853,7 +789,6 @@ def _set_enabled(self, slot: str, enabled: bool) -> None: layer.enabled = enabled if not enabled: layer.expanded = False - self._refocus_track_header_if_sub_row(slot) if self._layer_bindings is not None: self._layer_bindings.on_layer_enabled_change(slot, layer.enabled) @@ -883,25 +818,7 @@ def _set_effects_expanded(self, slot: str, expanded: bool) -> None: layer = self.session.layers[slot] if layer.effects_expanded == expanded: return - view = self.build_view_state(paused=self.playback.paused) - effects_header_idx = view.layout.find(slot, RowKind.TRACK_EFFECTS_HEADER) - old_focus = self.focus_index - effect_count = effect_row_count(layer.stem) layer.effects_expanded = expanded - view_after = self.build_view_state(paused=self.playback.paused) - new_header_idx = view_after.layout.find(slot, RowKind.TRACK_EFFECTS_HEADER) - if not expanded: - if old_focus > effects_header_idx and ( - view.layout.slot(old_focus) == slot - and view.layout.kind(old_focus) == RowKind.TRACK_EFFECT - ): - self.focus_index = new_header_idx - elif old_focus > effects_header_idx + effect_count: - self.focus_index = old_focus - effect_count - elif effects_header_idx < old_focus <= effects_header_idx + effect_count: - self.focus_index = new_header_idx - elif old_focus > effects_header_idx: - self.focus_index = old_focus + effect_count def _set_beat(self, slot: str, value: float) -> None: layer = self.session.layers[slot] @@ -938,9 +855,9 @@ def close_timeline_panel(self) -> None: return tl.panel_open = False tl.submenu_focused = False - self.focus_index = self._render_timeline_header_index() + self.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) def exit_timeline_submenu(self) -> None: tl = self.session.timeline tl.submenu_focused = False - self.focus_index = self._render_timeline_header_index() + self.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) diff --git a/cleave/viz/focus_context.py b/cleave/viz/focus_context.py index bcf3e9b..770d9be 100644 --- a/cleave/viz/focus_context.py +++ b/cleave/viz/focus_context.py @@ -6,11 +6,12 @@ from dataclasses import dataclass from cleave.viz.overlay import TuningViewState +from cleave.viz.row_semantics import RowDescriptor @dataclass(frozen=True) class FocusContext: - get_focus_index: Callable[[], int] - set_focus_index: Callable[[int], None] + get_focus_descriptor: Callable[[], RowDescriptor] + set_focus_descriptor: Callable[[RowDescriptor], None] build_view_state: Callable[..., TuningViewState] is_paused: Callable[[], bool] diff --git a/cleave/viz/overlay.py b/cleave/viz/overlay.py index 6edea36..3c9b5b0 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/overlay.py @@ -33,6 +33,7 @@ row_blocked_by_layer_lock, row_is_pinned, row_navigable_when_layer_locked, + section_header_descriptor, ) from cleave.viz.fonts import render_overlay_font_display from cleave.viz.text_fit import ( @@ -267,6 +268,29 @@ def find_by_kind(self, kind: RowKind) -> int: return index raise ValueError(f"no row for kind={kind!r}") + def find_descriptor(self, desc: RowDescriptor) -> int: + for index, row in enumerate(self.rows): + if row == desc: + return index + raise ValueError(f"descriptor not in layout: {desc!r}") + + def contains_descriptor(self, desc: RowDescriptor) -> bool: + return desc in self.rows + + def navigable_descriptors(self, state: TuningViewState) -> list[RowDescriptor]: + return [self.descriptor(index) for index in self.navigable_indices(state)] + + def resolve_navigable( + self, desc: RowDescriptor, state: TuningViewState + ) -> RowDescriptor: + navigable = self.navigable_descriptors(state) + if desc in navigable: + return desc + header = section_header_descriptor(desc) + if header in navigable: + return header + return RowDescriptor(RowKind.TRANSPORT) + def header_row_count(self) -> int: count = 0 for row in self.rows: @@ -372,7 +396,7 @@ class TuningViewState: tracks: dict[str, TrackBlock] paused: bool position_sec: float - focus_index: int + focus_descriptor: RowDescriptor move_mode_slot: str | None toast_message: str | None toast_remaining_sec: float @@ -399,6 +423,11 @@ class TuningViewState: def __post_init__(self) -> None: object.__setattr__(self, "layout", RowLayout.build(self)) + @property + def focus_index(self) -> int: + resolved = self.layout.resolve_navigable(self.focus_descriptor, self) + return self.layout.find_descriptor(resolved) + def track_sub_rows_visible(state: TuningViewState, slot: str) -> bool: return state.tracks[slot].expanded diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index 960c5ea..ebb55ab 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -46,7 +46,7 @@ def draw_tuning( if help_overlay is not None and view_state.help_visible: help_overlay.draw( overlay_surface, - view_state.layout.kind(view_state.focus_index), + view_state.focus_descriptor.kind, timeline_enabled=view_state.render_timeline.enabled, timeline_submenu_focused=view_state.timeline_submenu_focused, paused=view_state.paused, diff --git a/cleave/viz/render_overlay_controls.py b/cleave/viz/render_overlay_controls.py index ce00eda..146c380 100644 --- a/cleave/viz/render_overlay_controls.py +++ b/cleave/viz/render_overlay_controls.py @@ -2,67 +2,31 @@ from __future__ import annotations -from collections.abc import Callable - from cleave.config import RENDER_OVERLAY_POSITIONS -from cleave.viz.focus_context import FocusContext from cleave.viz.fonts import cycle_render_overlay_font -from cleave.viz.overlay import TuningViewState -from cleave.viz.row_semantics import ( - RENDER_OVERLAY_ALL_SUB_ROW_KINDS, - RENDER_OVERLAY_BODY_NESTED_KINDS, - RENDER_OVERLAY_TITLE_NESTED_KINDS, - RowKind, -) from cleave.viz.session import TuningSession class RenderOverlayControls: """Mutations for render overlay rows.""" - def __init__( - self, - session: TuningSession, - *, - focus_context: FocusContext, - focused_row_kind: Callable[[], RowKind | None], - ) -> None: + def __init__(self, session: TuningSession) -> None: self.session = session - self._focus = focus_context - self._focused_row_kind = focused_row_kind - - def _render_overlay_header_index(self) -> int: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) - - def _render_overlay_title_header_index(self) -> int: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) - - def _render_overlay_body_header_index(self) -> int: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - return view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_HEADER) def set_expanded(self, expanded: bool) -> None: ro = self.session.render_overlay if ro.expanded == expanded: return - focus_kind = self._focused_row_kind() ro.expanded = expanded - if not expanded and focus_kind in RENDER_OVERLAY_ALL_SUB_ROW_KINDS: - self._focus.set_focus_index(self._render_overlay_header_index()) def set_enabled(self, enabled: bool) -> None: ro = self.session.render_overlay if ro.enabled == enabled: return - focus_kind = self._focused_row_kind() ro.enabled = enabled if not enabled: self.session.render_overlay_solo = False ro.expanded = False - if focus_kind in RENDER_OVERLAY_ALL_SUB_ROW_KINDS: - self._focus.set_focus_index(self._render_overlay_header_index()) def enter_solo(self) -> None: if self.session.render_overlay_solo: @@ -90,19 +54,13 @@ def set_title_expanded(self, expanded: bool) -> None: ro = self.session.render_overlay if ro.title_expanded == expanded: return - focus_kind = self._focused_row_kind() ro.title_expanded = expanded - if not expanded and focus_kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: - self._focus.set_focus_index(self._render_overlay_title_header_index()) def set_body_expanded(self, expanded: bool) -> None: ro = self.session.render_overlay if ro.body_expanded == expanded: return - focus_kind = self._focused_row_kind() ro.body_expanded = expanded - if not expanded and focus_kind in RENDER_OVERLAY_BODY_NESTED_KINDS: - self._focus.set_focus_index(self._render_overlay_body_header_index()) def set_title_font_size(self, size: int) -> None: self.session.render_overlay.title_font_size = max(1, size) diff --git a/cleave/viz/render_post_fx_controls.py b/cleave/viz/render_post_fx_controls.py index a51b6fb..6a50a71 100644 --- a/cleave/viz/render_post_fx_controls.py +++ b/cleave/viz/render_post_fx_controls.py @@ -2,40 +2,20 @@ from __future__ import annotations -from cleave.viz.focus_context import FocusContext -from cleave.viz.overlay import TuningViewState -from cleave.viz.row_semantics import RENDER_POST_FX_SUB_ROW_KINDS, RowKind from cleave.viz.session import TuningSession class RenderPostFxControls: """Mutations for render post-FX rows.""" - def __init__( - self, - session: TuningSession, - *, - focus_context: FocusContext, - ) -> None: + def __init__(self, session: TuningSession) -> None: self.session = session - self._focus = focus_context - - def _render_post_fx_header_index(self) -> int: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - return view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) - - def _refocus_render_post_fx_header_if_sub_row(self) -> None: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - if view.layout.kind(self._focus.get_focus_index()) in RENDER_POST_FX_SUB_ROW_KINDS: - self._focus.set_focus_index(self._render_post_fx_header_index()) def set_expanded(self, expanded: bool) -> None: pp = self.session.render_post_fx if pp.expanded == expanded: return pp.expanded = expanded - if not expanded: - self._refocus_render_post_fx_header_if_sub_row() def set_enabled(self, enabled: bool) -> None: pp = self.session.render_post_fx @@ -45,7 +25,6 @@ def set_enabled(self, enabled: bool) -> None: if not enabled: self.session.render_post_fx_solo = False pp.expanded = False - self._refocus_render_post_fx_header_if_sub_row() def enter_solo(self) -> None: if self.session.render_post_fx_solo: diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index f0f7157..2878b60 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -364,3 +364,24 @@ def row_triggers_layer_delete(kind: RowKind) -> bool: if kind == RowKind.TRACK_HEADER: return True return row_behavior(kind).parent_group == "track" + + +def section_header_descriptor(desc: RowDescriptor) -> RowDescriptor: + """Map a sub-row descriptor to its section header for focus fallback.""" + kind = desc.kind + if kind == RowKind.SETTINGS_RENDER_MODE: + return RowDescriptor(RowKind.SETTINGS_HEADER) + if kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: + return RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER) + if kind in RENDER_OVERLAY_BODY_NESTED_KINDS: + return RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER) + if kind in RENDER_OVERLAY_ALL_SUB_ROW_KINDS: + return RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) + if kind in RENDER_POST_FX_SUB_ROW_KINDS: + return RowDescriptor(RowKind.RENDER_POST_FX_HEADER) + behavior = row_behavior(kind) + if behavior.parent_group == "track": + if kind in TRACK_EFFECT_SUB_ROW_KINDS: + return RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=desc.slot) + return RowDescriptor(RowKind.TRACK_HEADER, slot=desc.slot) + return desc diff --git a/cleave/viz/settings_controls.py b/cleave/viz/settings_controls.py index b8d909b..8916c3a 100644 --- a/cleave/viz/settings_controls.py +++ b/cleave/viz/settings_controls.py @@ -2,14 +2,10 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import replace from cleave.config import CleaveConfig from cleave.config_schema import VISUALIZER_RENDER_MODES -from cleave.viz.focus_context import FocusContext -from cleave.viz.overlay import TuningViewState -from cleave.viz.row_semantics import SETTINGS_SUB_ROW_KINDS, RowKind from cleave.viz.session import TuningSession @@ -20,27 +16,15 @@ def __init__( self, session: TuningSession, cfg: CleaveConfig, - *, - focus_context: FocusContext, - focused_row_kind: Callable[[], RowKind | None], ) -> None: self.session = session self.cfg = cfg - self._focus = focus_context - self._focused_row_kind = focused_row_kind - - def _settings_header_index(self) -> int: - view = self._focus.build_view_state(paused=self._focus.is_paused()) - return view.layout.find_by_kind(RowKind.SETTINGS_HEADER) def set_expanded(self, expanded: bool) -> None: settings = self.session.settings if settings.expanded == expanded: return - focus_kind = self._focused_row_kind() settings.expanded = expanded - if not expanded and focus_kind in SETTINGS_SUB_ROW_KINDS: - self._focus.set_focus_index(self._settings_header_index()) def cycle_render_mode(self, *, forward: bool) -> None: modes = VISUALIZER_RENDER_MODES diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 4841fec..7b9aae7 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -4,6 +4,7 @@ import time from collections.abc import Callable +from dataclasses import replace from cleave.preset_playlist import directory_display, preset_filename_display from cleave.viz.config_save import ConfigSaveController @@ -16,6 +17,7 @@ TuningViewState, ) from cleave.viz.playback import PlaybackState, current_sec +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import TuningSession, config_path_display @@ -29,7 +31,7 @@ def __init__( duration_sec: float, preset_root, *, - get_focus_index: Callable[[], int], + get_focus_descriptor: Callable[[], RowDescriptor], get_move_mode_slot: Callable[[], str | None], config_save: ConfigSaveController, get_toast_message: Callable[[], str | None], @@ -39,7 +41,7 @@ def __init__( self.playback = playback self.duration_sec = duration_sec self.preset_root = preset_root - self._get_focus_index = get_focus_index + self._get_focus_descriptor = get_focus_descriptor self._get_move_mode_slot = get_move_mode_slot self._config_save = config_save self._get_toast_message = get_toast_message @@ -95,12 +97,13 @@ def build( ro = self.session.render_overlay pp = self.session.render_post_fx tl = self.session.timeline - return TuningViewState( + resolved = self._get_focus_descriptor() + state = TuningViewState( layer_z_order=tuple(self.session.layer_z_order), tracks=tracks, paused=paused, position_sec=position_sec, - focus_index=self._get_focus_index(), + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), move_mode_slot=self._get_move_mode_slot(), toast_message=toast_message, toast_remaining_sec=toast_remaining, @@ -147,3 +150,5 @@ def build( help_visible=self.session.help_visible, fps=fps, ) + resolved = state.layout.resolve_navigable(resolved, state) + return replace(state, focus_descriptor=resolved) diff --git a/docs/architecture-improvements.md b/docs/architecture-improvements.md index cc0486a..25801ef 100644 --- a/docs/architecture-improvements.md +++ b/docs/architecture-improvements.md @@ -32,6 +32,8 @@ Phases 3-5 harden the foundation against recurrence. ## Phase 3 - Use `RowDescriptor` as the focus cursor +**Status:** done. `focus_descriptor` replaces `focus_index` in `TuningControls` and `TuningViewState`; `RowLayout` gains `resolve_navigable` / `navigable_descriptors`; repair paths removed (`_restore_focus`, `_refocus_*`, effects index arithmetic, collapse refocus in sub-controllers); `focus_index` is a derived property on `TuningViewState`. + **Why.** `TuningControls.focus_index` is an integer. Integer indices are unstable: adding a layer, expanding effects, or toggling settings shifts every index below the insertion point. The codebase has accumulated several repair paths to compensate (`_restore_focus`, `_refocus_track_header_if_sub_row`, arithmetic adjustments after add/delete). These are fragile and drift-prone. **What to do.** Replace `focus_index: int` with `focus_descriptor: RowDescriptor`. `RowDescriptor` is already a frozen dataclass with `__eq__` and identity that survives layout changes (kind + slot + effect_id + driver_slug). Navigation computes the new layout, resolves the current descriptor to its new index, applies delta or modulo, and stores the resulting descriptor. The resolved `int` is needed only for scroll math and highlight -- produce it lazily from the layout for those purposes. diff --git a/tests/cleave/viz/test_config_dirty.py b/tests/cleave/viz/test_config_dirty.py index 7e986fa..e437a5e 100644 --- a/tests/cleave/viz/test_config_dirty.py +++ b/tests/cleave/viz/test_config_dirty.py @@ -13,34 +13,34 @@ from cleave.timeline import TimelineCue from cleave.viz.controls import TuningControls from cleave.viz.timeline_controls import TimelineControls -from cleave.viz.row_semantics import RowKind -from tests.cleave.viz.test_controls import _choose_save_as_new, _config_header_row, _keydown, _make_controls, _row +from cleave.viz.row_semantics import RowDescriptor, RowKind +from tests.cleave.viz.test_controls import _choose_save_as_new, _config_header_row, _desc, _keydown, _make_controls, _row from tests.cleave.viz.test_timeline_controls import _make_timeline_controls from tests.support.viz import keydown, stub_playback_state def _expand_layer_1(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _expand_render_overlay(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _expand_render_post_fx(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_layer_z_order(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_2", RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) controls.handle_keydown(_keydown(pygame.K_UP)) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -48,66 +48,66 @@ def _mutate_layer_z_order(controls: TuningControls) -> None: def _mutate_stem_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _mutate_stem_opacity(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_OPACITY) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_OPACITY)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_stem_blend_mode(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_BLEND) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_BLEND)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_stem_locked(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_RETURN, mod=pygame.KMOD_CTRL)) def _mutate_stem_beat_sensitivity(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_BEAT) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_BEAT)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_stem_effects(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_EFFECTS_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_EFFECTS_HEADER)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = _row( + controls.focus_descriptor = view.layout.descriptor(_row( view, "layer_1", RowKind.TRACK_EFFECT, effect_id="pulse", driver_slug="onset" - ) + )) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_preset_path(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_PRESET) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_PRESET)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _mutate_render_overlay_position(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_POSITION) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_POSITION) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -115,7 +115,7 @@ def _mutate_render_overlay_title_font_size(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -123,7 +123,7 @@ def _mutate_render_overlay_title_font(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -131,9 +131,7 @@ def _mutate_render_overlay_title_margin_bottom(controls: TuningControls) -> None _expand_render_overlay(controls) controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind( - RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM - ) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -141,9 +139,7 @@ def _mutate_render_overlay_body_font_size(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind( - RowKind.RENDER_OVERLAY_BODY_FONT_SIZE - ) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -151,76 +147,74 @@ def _mutate_render_overlay_body_font(controls: TuningControls) -> None: _expand_render_overlay(controls) controls.session.render_overlay.body_expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_opacity(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_OPACITY) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_border_width(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BORDER_WIDTH) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_BORDER_WIDTH) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_start_delay(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_START_DELAY) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_START_DELAY) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_overlay_display_time(controls: TuningControls) -> None: _expand_render_overlay(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind( - RowKind.RENDER_OVERLAY_DISPLAY_TIME - ) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_DISPLAY_TIME) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_post_fx_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _mutate_render_post_fx_fade_in(controls: TuningControls) -> None: _expand_render_post_fx(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_FADE_IN) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_render_post_fx_fade_out(controls: TuningControls) -> None: _expand_render_post_fx(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_FADE_OUT) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_timeline_enabled(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) def _expand_settings(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_visualizer_render_mode(controls: TuningControls) -> None: _expand_settings(controls) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -321,7 +315,7 @@ def test_display_time_mutation_clears_dirty_after_save() -> None: assert controls.config_dirty view = controls.build_view_state(paused=False) - controls.focus_index = _config_header_row(view) + controls.focus_descriptor = _desc(view, _config_header_row(view)) _choose_save_as_new(controls) assert not controls.config_dirty @@ -333,20 +327,20 @@ def _mutate_track_expanded(controls: TuningControls) -> None: def _mutate_effects_expanded(controls: TuningControls) -> None: _expand_layer_1(controls) view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_EFFECTS_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_EFFECTS_HEADER)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) def _mutate_solo_slot(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) def _mutate_timeline_panel_open(controls: TuningControls) -> None: controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -356,7 +350,7 @@ def _mutate_render_overlay_expanded(controls: TuningControls) -> None: def _mutate_render_overlay_solo(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) @@ -366,13 +360,13 @@ def _mutate_render_post_fx_expanded(controls: TuningControls) -> None: def _mutate_render_post_fx_solo(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_POST_FX_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) def _mutate_move_mode_without_confirm(controls: TuningControls) -> None: view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_2", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_2", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_RETURN)) controls.handle_keydown(_keydown(pygame.K_UP)) diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index b9c4fb0..a256d23 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -61,7 +61,7 @@ track_header_lock_suffix_width, visibility_icon_prefix_width, ) -from cleave.viz.row_semantics import RowKind +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.overlay import ( TrackBlock, TuningViewState, @@ -206,7 +206,7 @@ def test_build_view_state_passes_fps() -> None: def test_add_layer_at_max_shows_toast() -> None: controls, manager = _make_controls_with_manager(("layer_1",), can_add=False) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.LAYER_MANAGEMENT_ADD) + controls.focus_descriptor = RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -222,9 +222,9 @@ def test_delete_layer_at_min_shows_toast() -> None: controls, manager = _make_controls_with_manager(("layer_1",), can_remove=False) controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find( + controls.focus_descriptor = view.layout.descriptor(view.layout.find( "layer_1", RowKind.LAYER_MANAGEMENT_DELETE - ) + )) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -251,7 +251,7 @@ def add_layer() -> None: manager.add_layer.side_effect = add_layer view = controls.build_view_state(paused=False) before_count = len(view.layout) - controls.focus_index = view.layout.find_by_kind(RowKind.LAYER_MANAGEMENT_ADD) + controls.focus_descriptor = RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD) _confirm_modal_yes(controls) @@ -272,9 +272,9 @@ def remove_layer(slot: str) -> None: manager.remove_layer.side_effect = remove_layer view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find( + controls.focus_descriptor = view.layout.descriptor(view.layout.find( "layer_2", RowKind.LAYER_MANAGEMENT_DELETE - ) + )) controls.handle_keydown(_keydown(confirm_key)) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -292,7 +292,7 @@ def remove_layer(slot: str) -> None: manager.remove_layer.side_effect = remove_layer view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find( "layer_2", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(view.layout.find( "layer_2", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_DELETE)) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -331,7 +331,7 @@ def remove_layer(slot: str) -> None: manager.remove_layer.side_effect = remove_layer view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_HEADER) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_HEADER)) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_1" @@ -353,6 +353,15 @@ def _row( return view.layout.find(stem, kind, effect_id=effect_id, driver_slug=driver_slug) +def _desc(view: TuningViewState, index: int) -> RowDescriptor: + return view.layout.descriptor(index) + + +def _focus_index(controls: TuningControls, *, paused: bool = False) -> int: + view = controls.build_view_state(paused=paused) + return view.layout.find_descriptor(controls.focus_descriptor) + + def test_allow_overwrite_for_path_hides_repo_root_template_only() -> None: root = Path("/repo/cleave-viz.yaml") assert allow_overwrite_for_path(root, repo_root_example=root) is False @@ -372,25 +381,24 @@ def test_focus_navigation_wraps() -> None: navigable = view.layout.navigable_indices(view) transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) start_pos = navigable.index(transport_row) - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) for step in range(1, len(navigable)): assert controls.handle_keydown(_keydown(pygame.K_DOWN)) is True - assert controls.focus_index == navigable[(start_pos + step) % len(navigable)] + assert controls.focus_descriptor == _desc(view, navigable[(start_pos + step) % len(navigable)]) assert controls.handle_keydown(_keydown(pygame.K_DOWN)) is True - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) assert controls.handle_keydown(_keydown(pygame.K_UP)) is True - assert controls.focus_index == navigable[start_pos - 1] + assert controls.focus_descriptor == _desc(view, navigable[start_pos - 1]) def test_opacity_clamps() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) opacity_row = _row(view, "layer_1", RowKind.TRACK_OPACITY) - controls.focus_index = opacity_row - + controls.focus_descriptor = _desc(view, opacity_row) for _ in range(60): controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].opacity_pct == 100 @@ -409,7 +417,7 @@ def test_header_toggles_enabled() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.layers["layer_1"].enabled is True controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) @@ -447,9 +455,9 @@ def test_navigation_skips_sub_rows_when_collapsed() -> None: if stem == "layer_1": assert view.layout.kind(i) == RowKind.TRACK_HEADER - controls.focus_index = drums_header + controls.focus_descriptor = _desc(view, drums_header) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == bass_header + assert controls.focus_descriptor == _desc(view, bass_header) def test_re_enable_without_expanding() -> None: @@ -463,33 +471,32 @@ def test_re_enable_without_expanding() -> None: transport_row = next( i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) render_post_fx_row = view.layout.find_by_kind(RowKind.RENDER_POST_FX_HEADER) render_timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == add_layer_row + assert controls.focus_descriptor == _desc(view, add_layer_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == render_overlay_row + assert controls.focus_descriptor == _desc(view, render_overlay_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == render_post_fx_row + assert controls.focus_descriptor == _desc(view, render_post_fx_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == render_timeline_row + assert controls.focus_descriptor == _desc(view, render_timeline_row) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) assert controls.session.layers["layer_1"].enabled is True assert controls.session.layers["layer_1"].expanded is False controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == add_layer_row + assert controls.focus_descriptor == _desc(view, add_layer_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == render_overlay_row + assert controls.focus_descriptor == _desc(view, render_overlay_row) def test_header_collapses_and_expands_sub_rows() -> None: @@ -497,8 +504,7 @@ def test_header_collapses_and_expands_sub_rows() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) preset_dir_row = _row(view, "layer_1", RowKind.TRACK_PRESET_DIR) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) assert controls.session.layers["layer_1"].expanded is False assert preset_dir_row not in view.layout.navigable_indices(view) assert preset_dir_row not in view.layout.visible_indices(view) @@ -516,16 +522,16 @@ def test_disable_auto_collapses_sub_rows() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) preset_dir_row = _row(view, "layer_1", RowKind.TRACK_PRESET_DIR) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == preset_dir_row + assert controls.focus_descriptor == _desc(view, preset_dir_row) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) assert controls.session.layers["layer_1"].enabled is False assert controls.session.layers["layer_1"].expanded is False - assert controls.focus_index == header_row + assert controls.focus_descriptor == _desc(view, header_row) view = controls.build_view_state(paused=False) assert not view.layout.sub_row_visible(view, preset_dir_row) @@ -537,7 +543,7 @@ def test_disabled_track_can_expand_sub_rows() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) preset_dir_row = _row(view, "layer_1", RowKind.TRACK_PRESET_DIR) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) assert controls.session.layers["layer_1"].enabled is False assert controls.session.layers["layer_1"].expanded is False @@ -551,15 +557,14 @@ def test_disabled_track_can_expand_sub_rows() -> None: assert preset_dir_row in view.layout.navigable_indices(view) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == preset_dir_row + assert controls.focus_descriptor == _desc(view, preset_dir_row) def test_beat_sensitivity_clamps() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) beat_row = _row(view, "layer_1", RowKind.TRACK_BEAT) - controls.focus_index = beat_row - + controls.focus_descriptor = _desc(view, beat_row) for _ in range(400): controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].beat_sensitivity == pytest.approx(5.0) @@ -573,7 +578,7 @@ def test_opacity_ctrl_step_is_ten_percent() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) opacity_row = _row(view, "layer_1", RowKind.TRACK_OPACITY) - controls.focus_index = opacity_row + controls.focus_descriptor = _desc(view, opacity_row) controls.session.layers["layer_1"].opacity_pct = 50 controls.handle_keydown( @@ -596,8 +601,7 @@ def test_move_mode_swaps_z_order() -> None: for i in range(15) if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True assert controls.move_mode_slot == "layer_2" @@ -619,8 +623,7 @@ def test_move_mode_esc_cancels_without_applying() -> None: for i in range(15) if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) controls.handle_keydown(_keydown(pygame.K_UP)) assert controls.session.layer_z_order == ["layer_2", "layer_1", "layer_3"] @@ -640,8 +643,7 @@ def test_move_mode_backspace_cancels_without_applying() -> None: for i in range(15) if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.session.layer_z_order == ["layer_1", "layer_3", "layer_2"] @@ -656,8 +658,7 @@ def test_save_as_new_triggers_toast_without_blocking_input() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) config_row = _config_header_row(view) - controls.focus_index = config_row - + controls.focus_descriptor = _desc(view, config_row) stderr = io.StringIO() with patch.object(time, "monotonic", return_value=1000.0): with patch("sys.stderr", stderr): @@ -673,9 +674,9 @@ def test_save_as_new_triggers_toast_without_blocking_input() -> None: assert state.toast_message == "Config saved to unnamed-1.yaml" assert state.toast_remaining_sec == TOAST_DURATION_SEC - before = controls.focus_index + before = controls.focus_descriptor assert controls.handle_keydown(_keydown(pygame.K_DOWN)) is True - assert controls.focus_index != before + assert controls.focus_descriptor != before def test_config_header_shows_active_path() -> None: @@ -711,16 +712,16 @@ def test_blend_and_opacity_change_sets_dirty_save_clears() -> None: assert not controls.config_dirty view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_BLEND) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_BLEND)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.config_dirty - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_OPACITY) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_OPACITY)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.config_dirty save_row = _config_header_row(view) - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) _choose_save_as_new(controls) assert not controls.config_dirty @@ -766,7 +767,7 @@ def test_preset_row_truncates_long_filenames() -> None: }, paused=False, position_sec=0.0, - focus_index=0, + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -823,7 +824,7 @@ def test_save_as_new_updates_active_config_path() -> None: view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) _choose_save_as_new(controls) assert controls._config_save._active_config_path == saved_path @@ -846,7 +847,7 @@ def test_save_as_new_enables_overwrite_from_root_template() -> None: controls._config_save._on_save_new_config = lambda: saved_path view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) _choose_save_as_new(controls) state = controls.build_view_state(paused=False) @@ -862,7 +863,7 @@ def test_repo_root_save_shows_save_as_new_only_modal() -> None: repo_root_example=_REPO_ROOT_EXAMPLE, ) view = controls.build_view_state(paused=False) - controls.focus_index = _config_header_row(view) + controls.focus_descriptor = _desc(view, _config_header_row(view)) controls.handle_keydown(_keydown(pygame.K_RETURN)) modal_view = controls.modal_host.view_state() @@ -890,7 +891,7 @@ def test_repo_root_save_as_new_requires_confirmation() -> None: ) controls._config_save._on_save_new_config = lambda: saved_path view = controls.build_view_state(paused=False) - controls.focus_index = _config_header_row(view) + controls.focus_descriptor = _desc(view, _config_header_row(view)) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls._config_save._active_config_path == _REPO_ROOT_EXAMPLE @@ -914,14 +915,14 @@ def test_overwrite_after_save_uses_new_active_path() -> None: save_row = _config_header_row(view) with patch.object(time, "monotonic", return_value=3000.0): - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) controls.handle_keydown(_keydown(pygame.K_RETURN)) with patch.object(time, "monotonic", return_value=3000.0 + TOAST_DURATION_SEC + 1): state = controls.build_view_state(paused=False) save_row = _config_header_row(state) - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) _choose_overwrite(controls) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -948,9 +949,9 @@ def test_navigable_rows_without_overwrite() -> None: i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) config_row = _config_header_row(view) - controls.focus_index = config_row + controls.focus_descriptor = _desc(view, config_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) def test_navigable_rows_with_overwrite() -> None: @@ -973,8 +974,7 @@ def test_overwrite_shows_confirm_before_write() -> None: view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row - + controls.focus_descriptor = _desc(view, save_row) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True modal_view = controls.modal_host.view_state() assert modal_view is not None @@ -1012,8 +1012,7 @@ def test_overwrite_confirm_yes_writes_launch_path() -> None: view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row - + controls.focus_descriptor = _desc(view, save_row) stderr = io.StringIO() with patch.object(time, "monotonic", return_value=2000.0): with patch("sys.stderr", stderr): @@ -1037,8 +1036,7 @@ def test_overwrite_confirm_esc_dismisses() -> None: view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row - + controls.focus_descriptor = _desc(view, save_row) _choose_overwrite(controls) assert controls.handle_keydown(_keydown(pygame.K_ESCAPE)) is True assert not controls.modal_host.active @@ -1049,7 +1047,7 @@ def test_esc_during_confirm_does_not_quit() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) save_row = _config_header_row(view) - controls.focus_index = save_row + controls.focus_descriptor = _desc(view, save_row) _choose_overwrite(controls) assert controls.handle_keydown(_keydown(pygame.K_ESCAPE)) is True assert controls.consume_hide_overlay() is False @@ -1066,7 +1064,7 @@ def test_esc_in_move_mode_does_not_request_overlay_hide() -> None: controls = _make_controls(("layer_1", "layer_2")) view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_1" assert controls.handle_keydown(_keydown(pygame.K_ESCAPE)) is True @@ -1183,7 +1181,7 @@ def test_render_overlay_title_font_row(_mock_fonts) -> None: font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT) assert _row_text(view, font_row) == "└─ font: alpha (1/3)" - controls.focus_index = font_row + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_font == "bravo" @@ -1201,7 +1199,7 @@ def test_render_overlay_body_font_row(_mock_fonts) -> None: font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT) assert _row_text(view, font_row) == "└─ font: bravo (2/3)" - controls.focus_index = font_row + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.render_overlay.body_font == "alpha" @@ -1215,7 +1213,7 @@ def test_render_overlay_title_font_size_row() -> None: font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) assert _row_text(view, font_row) == "└─ font size: 12px" - controls.focus_index = font_row + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_font_size == 13 @@ -1229,7 +1227,7 @@ def test_render_overlay_title_margin_bottom_row() -> None: margin_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) assert _row_text(view, margin_row) == "└─ margin bottom: 10px" - controls.focus_index = margin_row + controls.focus_descriptor = _desc(view, margin_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_margin_bottom == 11 @@ -1243,7 +1241,7 @@ def test_render_overlay_body_font_size_row() -> None: font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) assert _row_text(view, font_row) == "└─ font size: 18px" - controls.focus_index = font_row + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.render_overlay.body_font_size == 17 @@ -1273,8 +1271,7 @@ def test_render_overlay_title_header_toggles_expansion() -> None: controls.session.render_overlay.expanded = True view = controls.build_view_state(paused=False) title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) - controls.focus_index = title_header - + controls.focus_descriptor = _desc(view, title_header) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_expanded is True @@ -1287,12 +1284,15 @@ def test_render_overlay_collapse_refocuses_from_title_font_row() -> None: controls.session.render_overlay.expanded = True controls.session.render_overlay.title_expanded = True view = controls.build_view_state(paused=False) - overlay_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_HEADER) font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) - controls.focus_index = font_row - + font_desc = _desc(view, font_row) + controls.focus_descriptor = font_desc controls._render_overlay.set_expanded(False) - assert controls.focus_index == overlay_header + assert controls.focus_descriptor == font_desc + view = controls.build_view_state(paused=False) + assert view.layout.resolve_navigable( + controls.focus_descriptor, view + ) == RowDescriptor(RowKind.TRANSPORT) def test_render_overlay_title_collapse_refocuses_from_font_row() -> None: @@ -1302,10 +1302,12 @@ def test_render_overlay_title_collapse_refocuses_from_font_row() -> None: view = controls.build_view_state(paused=False) title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_HEADER) font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) - controls.focus_index = font_row - + controls.focus_descriptor = _desc(view, font_row) controls._render_overlay.set_title_expanded(False) - assert controls.focus_index == title_header + view = controls.build_view_state(paused=False) + assert view.layout.resolve_navigable( + controls.focus_descriptor, view + ) == RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER) def test_track_header_visibility_icon_color() -> None: @@ -1390,7 +1392,7 @@ def test_render_timeline_ctrl_right_toggles_enabled() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.timeline.enabled is True controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) @@ -1409,7 +1411,7 @@ def test_render_timeline_enable_opens_panel() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.timeline.enabled is False assert controls.session.timeline.panel_open is False @@ -1426,7 +1428,7 @@ def test_render_timeline_right_opens_panel() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 assert controls.session.timeline.panel_open is False assert controls.session.timeline.submenu_focused is False @@ -1451,21 +1453,21 @@ def test_render_timeline_down_enters_submenu() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True controls.session.timeline.focus_row = 2 controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.session.timeline.submenu_focused is True assert controls.session.timeline.focus_row == 0 - assert controls.focus_index == header_row + assert controls.focus_descriptor == _desc(view, header_row) def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True controls.session.timeline.focus_row = 2 @@ -1501,18 +1503,18 @@ def test_vertical_navigation_repeats_on_hold() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) transport = view.layout.find_by_kind(RowKind.TRANSPORT) - controls.focus_index = transport + controls.focus_descriptor = _desc(view, transport) navigable = view.layout.navigable_indices(view) start_pos = navigable.index(transport) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert navigable.index(controls.focus_index) == (start_pos + 1) % len(navigable) + assert navigable.index(_focus_index(controls)) == (start_pos + 1) % len(navigable) controls.tick(INITIAL_DELAY_SEC) - assert navigable.index(controls.focus_index) == (start_pos + 2) % len(navigable) + assert navigable.index(_focus_index(controls)) == (start_pos + 2) % len(navigable) controls.tick(SLOW_INTERVAL_SEC) - assert navigable.index(controls.focus_index) == (start_pos + 3) % len(navigable) + assert navigable.index(_focus_index(controls)) == (start_pos + 3) % len(navigable) def test_timeline_submenu_vertical_navigation_repeats() -> None: @@ -1542,14 +1544,13 @@ def test_vertical_navigation_stops_on_keyup() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) transport = view.layout.find_by_kind(RowKind.TRANSPORT) - controls.focus_index = transport - + controls.focus_descriptor = _desc(view, transport) controls.handle_keydown(_keydown(pygame.K_DOWN)) - focus_after_keydown = controls.focus_index + focus_after_keydown = controls.focus_descriptor controls.handle_keyup(pygame.event.Event(pygame.KEYUP, key=pygame.K_DOWN)) controls.tick(INITIAL_DELAY_SEC + 1.0) - assert controls.focus_index == focus_after_keydown + assert controls.focus_descriptor == focus_after_keydown def test_key_repeat_armed_while_navigation_key_held() -> None: @@ -1590,7 +1591,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True controls.session.timeline.focus_row = 0 @@ -1599,7 +1600,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert controls.session.timeline.submenu_focused is False view = controls.build_view_state(paused=False) - assert controls.focus_index == view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + assert controls.focus_descriptor == RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -1608,7 +1609,7 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False @@ -1635,7 +1636,7 @@ def test_render_timeline_submenu_down_from_last_row_wraps_to_transport() -> None controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: @@ -1643,7 +1644,7 @@ def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: controls = _make_controls(stems, timeline_enabled=True) view = controls.build_view_state(paused=False) transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) - controls.focus_index = transport_row + controls.focus_descriptor = _desc(view, transport_row) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False @@ -1651,7 +1652,7 @@ def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: assert controls.session.timeline.submenu_focused is True assert controls.session.timeline.focus_row == len(stems) - 1 - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) def test_render_timeline_panel_closed_wrap_unchanged() -> None: @@ -1661,17 +1662,17 @@ def test_render_timeline_panel_closed_wrap_unchanged() -> None: settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) timeline_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = timeline_row + controls.focus_descriptor = _desc(view, timeline_row) controls.session.timeline.panel_open = False controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == settings_row + assert controls.focus_descriptor == _desc(view, settings_row) - controls.focus_index = transport_row + controls.focus_descriptor = _desc(view, transport_row) controls.handle_keydown(_keydown(pygame.K_UP)) assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == navigable[navigable.index(transport_row) - 1] + assert controls.focus_descriptor == _desc(view, navigable[navigable.index(transport_row) - 1]) def test_render_timeline_disable_closes_panel() -> None: @@ -1680,8 +1681,7 @@ def test_render_timeline_disable_closes_panel() -> None: controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.enabled is False assert controls.session.timeline.panel_open is False @@ -1730,8 +1730,7 @@ def test_render_timeline_enabled_change_callback() -> None: ) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) assert events == [] @@ -1760,19 +1759,19 @@ def test_t_closes_timeline_panel_and_focuses_header_when_open() -> None: header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = False - controls.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) + controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_t)) assert controls.session.timeline.panel_open is False assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == header_row + assert controls.focus_descriptor == _desc(view, header_row) def test_t_from_submenu_closes_and_focuses_render_timeline_header() -> None: controls = _make_controls(timeline_enabled=True) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True @@ -1788,7 +1787,7 @@ def test_t_from_submenu_closes_and_focuses_render_timeline_header() -> None: assert controls.session.timeline.panel_open is False assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == header_row + assert controls.focus_descriptor == _desc(view, header_row) def test_t_toast_when_timeline_disabled() -> None: @@ -1806,7 +1805,7 @@ def test_t_ignored_during_move_mode() -> None: controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_1" @@ -1820,7 +1819,7 @@ def test_transport_enter_toggles_pause() -> None: transport_row = next( i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) - controls.focus_index = transport_row + controls.focus_descriptor = _desc(view, transport_row) assert controls.playback.paused is False controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -1834,7 +1833,7 @@ def test_space_toggles_pause_from_any_focus() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) assert controls.playback.paused is False - assert controls.focus_index == view.layout.find_by_kind(RowKind.TRANSPORT) + assert controls.focus_descriptor == RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_SPACE)) assert controls.playback.paused is True @@ -1891,27 +1890,27 @@ def test_ctrl_quick_nav_cycles_headers_and_transport() -> None: view = controls.build_view_state(paused=False) quick = view.layout.quick_nav_indices() - controls.focus_index = quick[0] + controls.focus_descriptor = _desc(view, quick[0]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[1] + assert controls.focus_descriptor == _desc(view, quick[1]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[2] + assert controls.focus_descriptor == _desc(view, quick[2]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[3] + assert controls.focus_descriptor == _desc(view, quick[3]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[4] + assert controls.focus_descriptor == _desc(view, quick[4]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[5] + assert controls.focus_descriptor == _desc(view, quick[5]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[0] + assert controls.focus_descriptor == _desc(view, quick[0]) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[5] + assert controls.focus_descriptor == _desc(view, quick[5]) def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: @@ -1922,13 +1921,13 @@ def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: i for i in range(12) if view.layout.kind(i) == RowKind.TRACK_PRESET ) - controls.focus_index = preset_row + controls.focus_descriptor = _desc(view, preset_row) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[2] + assert controls.focus_descriptor == _desc(view, quick[2]) - controls.focus_index = preset_row + controls.focus_descriptor = _desc(view, preset_row) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[1] + assert controls.focus_descriptor == _desc(view, quick[1]) def test_ctrl_quick_nav_from_config_header_row() -> None: @@ -1937,13 +1936,13 @@ def test_ctrl_quick_nav_from_config_header_row() -> None: quick = view.layout.quick_nav_indices() config_row = _config_header_row(view) - controls.focus_index = config_row + controls.focus_descriptor = _desc(view, config_row) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[-1] + assert controls.focus_descriptor == _desc(view, quick[-1]) - controls.focus_index = config_row + controls.focus_descriptor = _desc(view, config_row) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[0] + assert controls.focus_descriptor == _desc(view, quick[0]) def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: @@ -1951,11 +1950,11 @@ def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) preset_dir_row = _row(view, "layer_1", RowKind.TRACK_PRESET_DIR) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_index == preset_dir_row + assert controls.focus_descriptor == _desc(view, preset_dir_row) def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: @@ -1968,17 +1967,17 @@ def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: controls.session.timeline.panel_open = True controls.session.timeline.submenu_focused = True controls.session.timeline.focus_row = 1 - controls.focus_index = view.layout.find_by_kind(RowKind.TRANSPORT) + controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == timeline_header + assert controls.focus_descriptor == _desc(view, timeline_header) controls.session.timeline.submenu_focused = True controls.session.timeline.focus_row = 2 controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.submenu_focused is False - assert controls.focus_index == transport_row + assert controls.focus_descriptor == _desc(view, transport_row) assert timeline_header in quick @@ -2054,7 +2053,7 @@ def _preset_row(controls: TuningControls) -> int: def test_directory_row_lr_changes_current_dir() -> None: root, siblings = _make_sibling_dir_tree(3) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -2067,7 +2066,7 @@ def test_directory_row_lr_changes_current_dir() -> None: def test_directory_enter_descends_backspace_goes_parent() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist child = siblings[0] / "child" @@ -2081,7 +2080,7 @@ def test_directory_enter_descends_backspace_goes_parent() -> None: def test_directory_ctrl_arrows_descend_and_ascend() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist child = siblings[0] / "child" @@ -2095,7 +2094,7 @@ def test_directory_ctrl_arrows_descend_and_ascend() -> None: def test_ctrl_left_at_preset_root_is_noop() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) @@ -2108,7 +2107,7 @@ def test_ctrl_left_at_preset_root_is_noop() -> None: def test_directory_ctrl_arrows_do_not_repeat_parent_climb() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist child = siblings[0] / "child" @@ -2126,7 +2125,7 @@ def test_directory_ctrl_arrows_do_not_repeat_parent_climb() -> None: def test_backspace_at_preset_root_is_noop() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist controls.handle_keydown(_keydown(pygame.K_BACKSPACE)) @@ -2140,7 +2139,7 @@ def test_directory_parent_round_trip_reaches_preset_root() -> None: root, siblings = _make_sibling_dir_tree(2) child = siblings[0] / "child" controls = _controls_with_playlist(root, child) - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist for _ in range(3): @@ -2163,7 +2162,7 @@ def test_preset_lr_noop_when_paths_empty() -> None: controls._layer_bindings = noop_layer_bindings( on_preset_change=lambda stem, pl: changed.append((stem, pl.index)) ) - controls.focus_index = _preset_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_row(controls)) playlist = controls.session.layers["layer_1"].playlist assert playlist.paths == () @@ -2192,7 +2191,7 @@ def test_ctrl_preset_steps_by_ten_wrapping() -> None: view = controls.build_view_state(paused=False) preset_row = _row(view, "layer_1", RowKind.TRACK_PRESET) - controls.focus_index = preset_row + controls.focus_descriptor = _desc(view, preset_row) playlist = controls.session.layers["layer_1"].playlist controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) @@ -2224,7 +2223,7 @@ def test_move_mode_colors_focused_track_header() -> None: for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True view = controls.build_view_state(paused=False) @@ -2248,11 +2247,12 @@ def test_row_value_color_dim_for_focused_empty_preset() -> None: beat_sensitivity=1.0, effects={}, preset_empty=True, + expanded=True, ) }, paused=False, position_sec=0.0, - focus_index=0, + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -2263,7 +2263,7 @@ def test_row_value_color_dim_for_focused_empty_preset() -> None: tracks=state.tracks, paused=state.paused, position_sec=state.position_sec, - focus_index=preset_row, + focus_descriptor=state.layout.descriptor(preset_row), move_mode_slot=state.move_mode_slot, toast_message=state.toast_message, toast_remaining_sec=state.toast_remaining_sec, @@ -2338,7 +2338,7 @@ def test_ctrl_enter_toggles_lock() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.layers["layer_1"].locked is False controls.handle_keydown(_keydown(pygame.K_RETURN, mod=pygame.KMOD_CTRL)) @@ -2374,7 +2374,7 @@ def test_locked_blocks_enable_disable() -> None: controls.session.layers["layer_1"].locked = True view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.layers["layer_1"].enabled is True controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) @@ -2389,8 +2389,7 @@ def test_locked_blocks_move_mode() -> None: controls.session.layers["layer_1"].locked = True view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot is None @@ -2401,7 +2400,7 @@ def test_locked_header_still_expands() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) preset_dir_row = _row(view, "layer_1", RowKind.TRACK_PRESET_DIR) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) assert controls.session.layers["layer_1"].expanded is False controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -2429,7 +2428,7 @@ def test_locked_sub_rows_use_locked_color() -> None: tracks=view.tracks, paused=view.paused, position_sec=view.position_sec, - focus_index=len(view.layout) - 1, + focus_descriptor=view.layout.descriptor(len(view.layout) - 1), move_mode_slot=view.move_mode_slot, toast_message=view.toast_message, toast_remaining_sec=view.toast_remaining_sec, @@ -2444,7 +2443,7 @@ def test_locked_sub_rows_use_locked_color() -> None: tracks=view.tracks, paused=view.paused, position_sec=view.position_sec, - focus_index=header_row, + focus_descriptor=view.layout.descriptor(header_row), move_mode_slot=view.move_mode_slot, toast_message=view.toast_message, toast_remaining_sec=view.toast_remaining_sec, @@ -2459,7 +2458,7 @@ def test_locked_not_toggleable_during_move_mode() -> None: controls = _make_controls(("layer_1", "layer_2")) view = controls.build_view_state(paused=False) bass_header = _header_row(controls, "layer_2") - controls.focus_index = bass_header + controls.focus_descriptor = _desc(view, bass_header) assert controls.session.layers["layer_2"].locked is False controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -2477,13 +2476,13 @@ def test_ctrl_quick_nav_blocked_during_move_mode() -> None: for i in range(15) if view.layout.kind(i) == RowKind.TRACK_HEADER and view.layout.slot( i) == "layer_2" ) - controls.focus_index = bass_header + controls.focus_descriptor = _desc(view, bass_header) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_2" controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) assert controls.session.layer_z_order == ["layer_2", "layer_1", "layer_3"] - assert controls.focus_index == bass_header + assert controls.focus_descriptor == _desc(view, bass_header) def test_transport_seek_constants() -> None: @@ -2497,8 +2496,7 @@ def test_transport_seek_constants() -> None: transport_row = next( i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) - controls.focus_index = transport_row - + controls.focus_descriptor = _desc(view, transport_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) controls.handle_keydown(_keydown(pygame.K_LEFT)) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) @@ -2518,8 +2516,7 @@ def test_effect_pulse_clamps() -> None: bass_pulse_row = _row( view, "layer_2", RowKind.TRACK_EFFECT, effect_id="pulse", driver_slug="sub_bass" ) - controls.focus_index = pulse_row - + controls.focus_descriptor = _desc(view, pulse_row) for _ in range(120): controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].effects["pulse"]["onset"] == 100 @@ -2528,7 +2525,7 @@ def test_effect_pulse_clamps() -> None: controls.handle_keydown(_keydown(pygame.K_LEFT)) assert "pulse" not in controls.session.layers["layer_1"].effects - controls.focus_index = bass_pulse_row + controls.focus_descriptor = _desc(view, bass_pulse_row) for _ in range(20): controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_2"].effects["pulse"]["sub_bass"] == 20 @@ -2602,8 +2599,7 @@ def test_shift_right_enters_solo() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) - controls.focus_index = header_row - + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot == "layer_1" assert solo_calls == ["layer_1"] @@ -2614,32 +2610,34 @@ def test_shift_right_enters_solo() -> None: def test_shift_right_switches_solo_target() -> None: controls = _make_controls(("layer_1", "layer_2")) - drums_header = _row(controls.build_view_state(paused=False), "layer_1", RowKind.TRACK_HEADER) - bass_header = _row(controls.build_view_state(paused=False), "layer_2", RowKind.TRACK_HEADER) + view = controls.build_view_state(paused=False) + drums_header = _row(view, "layer_1", RowKind.TRACK_HEADER) + bass_header = _row(view, "layer_2", RowKind.TRACK_HEADER) - controls.focus_index = drums_header + controls.focus_descriptor = _desc(view, drums_header) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot == "layer_1" - controls.focus_index = bass_header + controls.focus_descriptor = _desc(view, bass_header) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot == "layer_2" def test_shift_left_exits_solo_only_for_active_target() -> None: controls = _make_controls(("layer_1", "layer_2")) - drums_header = _row(controls.build_view_state(paused=False), "layer_1", RowKind.TRACK_HEADER) - bass_header = _row(controls.build_view_state(paused=False), "layer_2", RowKind.TRACK_HEADER) + view = controls.build_view_state(paused=False) + drums_header = _row(view, "layer_1", RowKind.TRACK_HEADER) + bass_header = _row(view, "layer_2", RowKind.TRACK_HEADER) - controls.focus_index = drums_header + controls.focus_descriptor = _desc(view, drums_header) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot == "layer_1" - controls.focus_index = bass_header + controls.focus_descriptor = _desc(view, bass_header) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot == "layer_1" - controls.focus_index = drums_header + controls.focus_descriptor = _desc(view, drums_header) controls.handle_keydown(_keydown(pygame.K_LEFT, mod=pygame.KMOD_SHIFT)) assert controls.session.solo_slot is None @@ -2649,10 +2647,10 @@ def test_save_blocked_while_solo_active() -> None: view = controls.build_view_state(paused=False) header_row = _row(view, "layer_1", RowKind.TRACK_HEADER) config_row = _config_header_row(view) - controls.focus_index = header_row + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) - controls.focus_index = config_row + controls.focus_descriptor = _desc(view, config_row) stderr = io.StringIO() with patch("sys.stderr", stderr): controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -2767,7 +2765,7 @@ def test_stem_row_cycles_sources() -> None: controls.session.layers["layer_1"].expanded = True view = controls.build_view_state(paused=False) stem_row = _row(view, "layer_1", RowKind.TRACK_STEM) - controls.focus_index = stem_row + controls.focus_descriptor = _desc(view, stem_row) assert controls.session.layers["layer_1"].stem == "drums" controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -2782,7 +2780,7 @@ def test_stem_change_clears_effects() -> None: controls.session.layers["layer_1"].expanded = True controls.session.layers["layer_1"].effects = {"pulse": {"onset": 50}} view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_STEM) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_STEM)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].effects == {} @@ -2793,7 +2791,7 @@ def test_locked_blocks_stem_change() -> None: controls.session.layers["layer_1"].expanded = True controls.session.layers["layer_1"].locked = True view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_STEM) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_STEM)) assert controls.session.layers["layer_1"].stem == "drums" controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -2804,7 +2802,7 @@ def test_locked_blocks_preset_dir_enter_and_backspace() -> None: root, siblings = _make_sibling_dir_tree(2) controls = _controls_with_playlist(root, siblings[0]) controls.session.layers["layer_1"].expanded = True - controls.focus_index = _preset_dir_row(controls) + controls.focus_descriptor = _desc(controls.build_view_state(paused=False), _preset_dir_row(controls)) playlist = controls.session.layers["layer_1"].playlist child = siblings[0] / "child" @@ -2830,13 +2828,13 @@ def test_cycle_stem_to_full_mix() -> None: controls.session.layers["layer_1"].expanded = True controls.session.layers["layer_1"].stem = "other" view = controls.build_view_state(paused=False) - controls.focus_index = _row(view, "layer_1", RowKind.TRACK_STEM) + controls.focus_descriptor = view.layout.descriptor(_row(view, "layer_1", RowKind.TRACK_STEM)) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.layers["layer_1"].stem == "full_mix" view = controls.build_view_state(paused=False) - assert _row_text(view, controls.focus_index) == "└─ driving stem: full-mix" + assert _row_text(view, _focus_index(controls)) == "└─ driving stem: full-mix" def test_try_quit_overwrite_confirm_esc_clears_quit_after_save() -> None: @@ -2870,7 +2868,7 @@ def test_settings_expand_collapse_and_sub_row_visibility() -> None: view.layout.kind(i) for i in range(len(view.layout)) } - controls.focus_index = settings_row + controls.focus_descriptor = _desc(view, settings_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.settings.expanded is True view = controls.build_view_state(paused=False) @@ -2879,9 +2877,9 @@ def test_settings_expand_collapse_and_sub_row_visibility() -> None: assert render_mode_row in view.layout.navigable_indices(view) assert view.layout.header_row_count() == 4 - controls.focus_index = render_mode_row + controls.focus_descriptor = _desc(view, render_mode_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) - controls.focus_index = settings_row + controls.focus_descriptor = _desc(view, settings_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.settings.expanded is False view = controls.build_view_state(paused=False) @@ -2894,28 +2892,30 @@ def test_settings_expand_collapse_and_sub_row_visibility() -> None: def test_settings_collapse_from_sub_row_refocuses_header() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls._settings.set_expanded(False) view = controls.build_view_state(paused=False) - assert controls.focus_index == view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + assert view.layout.resolve_navigable( + controls.focus_descriptor, view + ) == RowDescriptor(RowKind.SETTINGS_HEADER) def test_settings_cycle_render_mode() -> None: controls = _make_controls(("layer_1",)) assert controls.cfg.visualizer.render_mode == "balanced" view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.cfg.visualizer.render_mode == "performance" view = controls.build_view_state(paused=False) - assert _row_text(view, controls.focus_index) == "└─ render mode: performance" + assert _row_text(view, _focus_index(controls)) == "└─ render mode: performance" controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.cfg.visualizer.render_mode == "balanced" @@ -2925,10 +2925,10 @@ def test_settings_render_mode_change_marks_config_dirty() -> None: controls = _make_controls(("layer_1",)) assert not controls.config_dirty view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.SETTINGS_RENDER_MODE) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.config_dirty @@ -2936,4 +2936,4 @@ def test_settings_render_mode_change_marks_config_dirty() -> None: def test_default_focus_stays_on_transport() -> None: controls = _make_controls(("layer_1",)) view = controls.build_view_state(paused=False) - assert controls.focus_index == view.layout.find_by_kind(RowKind.TRANSPORT) \ No newline at end of file + assert controls.focus_descriptor == RowDescriptor(RowKind.TRANSPORT) \ No newline at end of file diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index 4d4706c..8424cd2 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -16,7 +16,7 @@ from cleave.stem_pcm import StemPcmBank from cleave.timeline import TimelineCue from cleave.viz.session import LayerRuntime, TimelineRuntime, TuningSession -from cleave.viz.row_semantics import RowKind +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.layer import StemLayer from cleave.viz.layer_pipeline import LayerFramePipeline from cleave.viz.layer_visibility import ( @@ -252,7 +252,7 @@ def test_header_toggle_blocked_when_timeline_enabled() -> None: controls = make_controls(("layer_1",)) controls.session.timeline.enabled = True view = controls.build_view_state(paused=False) - controls.focus_index = view.layout.find_by_kind(RowKind.TRACK_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.TRACK_HEADER, slot="layer_1") assert controls.session.layers["layer_1"].enabled is True controls.handle_keydown(keydown(pygame.K_LEFT, mod=pygame.KMOD_CTRL)) diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index a164b93..5a2a17b 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -9,7 +9,7 @@ from cleave.extract import STEM_NAMES from cleave.viz.frame_rate import format_fps_display from cleave.viz.material_icons import row_icon_prefix_width -from cleave.viz.row_semantics import RowKind +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.overlay import ( PanelScrollMetrics, RenderOverlayBlock, @@ -68,7 +68,7 @@ def _effects_expanded_view_state() -> TuningViewState: tracks=tracks, paused=False, position_sec=0.0, - focus_index=0, + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -144,7 +144,9 @@ def test_scrolled_panel_keeps_focus_row_in_viewport() -> None: pygame.init() overlay = TuningOverlay() state = _effects_expanded_view_state() - state.focus_index = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) - 1 + state.focus_descriptor = state.layout.descriptor( + state.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - 1 + ) surface = pygame.Surface((1280, 720), pygame.SRCALPHA) overlay.notify_input() @@ -171,10 +173,10 @@ def _copy_panel_surface(overlay: TuningOverlay, state: TuningViewState) -> pygam def test_header_rows_pinned_when_scrolled() -> None: pygame.init() state_top = _effects_expanded_view_state() - scroll_focus = state_top.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) - 1 - state_top.focus_index = scroll_focus + scroll_focus = state_top.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - 1 + state_top.focus_descriptor = state_top.layout.descriptor(scroll_focus) state_bottom = _effects_expanded_view_state() - state_bottom.focus_index = scroll_focus + state_bottom.focus_descriptor = state_bottom.layout.descriptor(scroll_focus) panel_top = _copy_panel_surface(TuningOverlay(), state_top) panel_bottom = _copy_panel_surface(TuningOverlay(), state_bottom) @@ -271,9 +273,10 @@ def test_draw_fps_counter_when_present() -> None: def test_fps_color_ignores_transport_focus() -> None: pygame.init() overlay = TuningOverlay() - state = _minimal_view_state(fps=30.0) - transport_index = state.layout.find_by_kind(RowKind.TRANSPORT) - state = _minimal_view_state(fps=30.0, focus_index=transport_index) + state = _minimal_view_state( + fps=30.0, + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + ) with_fps = _copy_panel_surface(overlay, state) font = overlay._font_get() @@ -436,7 +439,7 @@ def _minimal_view_state(**kwargs: object) -> TuningViewState: }, "paused": False, "position_sec": 0.0, - "focus_index": 0, + "focus_descriptor": RowDescriptor(RowKind.TRANSPORT), "move_mode_slot": None, "toast_message": None, "toast_remaining_sec": 0.0, @@ -857,7 +860,7 @@ def test_draw_track_header_with_solo_eye() -> None: }, paused=False, position_sec=0.0, - focus_index=0, + focus_descriptor=RowDescriptor(RowKind.TRANSPORT), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -894,7 +897,7 @@ def test_disabled_track_focus_uses_muted_highlight() -> None: }, ) header_row = state.layout.find_by_kind( RowKind.TRACK_HEADER) - state.focus_index = header_row + state.focus_descriptor = state.layout.descriptor(header_row) assert _row_value_color(state, header_row) == HIGHLIGHT_MUTED assert _row_bg_color(state, header_row) == HIGHLIGHT_MUTED assert _row_value_color(state, header_row) != HIGHLIGHT @@ -907,7 +910,7 @@ def test_main_tree_rows_not_highlighted_when_timeline_submenu_focused() -> None: ) for row_kind_target in (RowKind.TRANSPORT, RowKind.TRACK_HEADER): row = state.layout.find_by_kind(row_kind_target) - state.focus_index = row + state.focus_descriptor = state.layout.descriptor(row) state.timeline_submenu_focused = False assert _row_value_color(state, row) == HIGHLIGHT assert _row_bg_color(state, row) == HIGHLIGHT @@ -917,7 +920,7 @@ def test_main_tree_rows_not_highlighted_when_timeline_submenu_focused() -> None: assert _row_bg_color(state, row) is None timeline_row = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) - state.focus_index = timeline_row + state.focus_descriptor = state.layout.descriptor(timeline_row) state.timeline_submenu_focused = False assert _row_value_color(state, timeline_row) == HIGHLIGHT assert _row_bg_color(state, timeline_row) == HIGHLIGHT diff --git a/tests/cleave/viz/test_overlay_targets.py b/tests/cleave/viz/test_overlay_targets.py index 86dfba7..72971bb 100644 --- a/tests/cleave/viz/test_overlay_targets.py +++ b/tests/cleave/viz/test_overlay_targets.py @@ -8,7 +8,7 @@ from cleave.viz.help_overlay import HelpOverlay from cleave.viz.overlay_draw import OverlayDrawer -from cleave.viz.row_semantics import RowKind +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.timeline_overlay import TimelineViewState from tests.support.compositor_mock import recording_compositor @@ -39,7 +39,7 @@ def test_draw_tuning_overlay_uploads_help_panel() -> None: overlay_surface = pygame.Surface((1280, 720), pygame.SRCALPHA) view_state = MagicMock() view_state.help_visible = True - view_state.focus_index = 0 + view_state.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) view_state.layout.kind.return_value = RowKind.TRANSPORT OverlayDrawer.draw_tuning( diff --git a/tests/cleave/viz/test_row_layout_resolution.py b/tests/cleave/viz/test_row_layout_resolution.py new file mode 100644 index 0000000..e837773 --- /dev/null +++ b/tests/cleave/viz/test_row_layout_resolution.py @@ -0,0 +1,147 @@ +"""Tests for RowLayout descriptor resolution helpers.""" + +from __future__ import annotations + +import pytest + +from cleave.viz.overlay import RenderOverlayBlock, RenderPostFxBlock, SettingsBlock, TrackBlock, TuningViewState +from cleave.viz.row_semantics import RowDescriptor, RowKind, section_header_descriptor +from tests.cleave.viz.test_overlay import _minimal_view_state + + +def test_find_descriptor_and_contains_descriptor() -> None: + state = _minimal_view_state() + desc = RowDescriptor(RowKind.TRACK_HEADER, slot="layer_1") + assert state.layout.contains_descriptor(desc) + assert state.layout.find_descriptor(desc) == state.layout.find("layer_1", RowKind.TRACK_HEADER) + + +def test_find_descriptor_raises_when_missing() -> None: + state = _minimal_view_state() + missing = RowDescriptor(RowKind.TRACK_EFFECT, slot="layer_1", effect_id="pulse", driver_slug="onset") + assert not state.layout.contains_descriptor(missing) + with pytest.raises(ValueError, match="descriptor not in layout"): + state.layout.find_descriptor(missing) + + +def test_navigable_descriptors_matches_indices() -> None: + state = _minimal_view_state( + tracks={ + "layer_1": TrackBlock( + stem="drums", + preset_dir_label="dir", + preset_label="preset.milk", + blend_mode="black-key", + opacity_pct=50, + beat_sensitivity=1.0, + effects={}, + expanded=True, + ) + }, + ) + indices = state.layout.navigable_indices(state) + descriptors = state.layout.navigable_descriptors(state) + assert descriptors == [state.layout.descriptor(index) for index in indices] + + +def test_resolve_navigable_returns_descriptor_when_navigable() -> None: + state = _minimal_view_state() + transport = RowDescriptor(RowKind.TRANSPORT) + assert state.layout.resolve_navigable(transport, state) == transport + + +def test_resolve_navigable_settings_render_mode_collapsed() -> None: + state = _minimal_view_state(settings=SettingsBlock(expanded=False)) + render_mode = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) + assert render_mode not in state.layout.navigable_descriptors(state) + assert state.layout.resolve_navigable(render_mode, state) == RowDescriptor( + RowKind.SETTINGS_HEADER + ) + + +def test_resolve_navigable_track_sub_row_collapsed_block() -> None: + state = _minimal_view_state() + stem = RowDescriptor(RowKind.TRACK_STEM, slot="layer_1") + assert stem not in state.layout.navigable_descriptors(state) + assert state.layout.resolve_navigable(stem, state) == RowDescriptor( + RowKind.TRACK_HEADER, slot="layer_1" + ) + + +def test_resolve_navigable_track_effect_collapsed_effects() -> None: + state = _minimal_view_state( + tracks={ + "layer_1": TrackBlock( + stem="drums", + preset_dir_label="dir", + preset_label="preset.milk", + blend_mode="black-key", + opacity_pct=50, + beat_sensitivity=1.0, + effects={}, + expanded=True, + effects_expanded=False, + ) + }, + ) + effect = RowDescriptor( + RowKind.TRACK_EFFECT, slot="layer_1", effect_id="pulse", driver_slug="onset" + ) + assert effect not in state.layout.rows + assert state.layout.resolve_navigable(effect, state) == RowDescriptor( + RowKind.TRACK_EFFECTS_HEADER, slot="layer_1" + ) + + +def test_resolve_navigable_render_overlay_sub_row_collapsed() -> None: + state = _minimal_view_state(render_overlay=RenderOverlayBlock(expanded=False)) + opacity = RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY) + assert opacity not in state.layout.rows + assert state.layout.resolve_navigable(opacity, state) == RowDescriptor( + RowKind.RENDER_OVERLAY_HEADER + ) + + +def test_resolve_navigable_render_overlay_title_nested_collapsed() -> None: + state = _minimal_view_state( + render_overlay=RenderOverlayBlock(expanded=True, title_expanded=False), + ) + font = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT) + assert font not in state.layout.rows + title_header = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER) + assert state.layout.resolve_navigable(font, state) == title_header + + +def test_resolve_navigable_render_post_fx_sub_row_collapsed() -> None: + state = _minimal_view_state(render_post_fx=RenderPostFxBlock(expanded=False)) + fade_in = RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN) + assert fade_in not in state.layout.rows + assert state.layout.resolve_navigable(fade_in, state) == RowDescriptor( + RowKind.RENDER_POST_FX_HEADER + ) + + +def test_section_header_descriptor_mappings() -> None: + assert section_header_descriptor(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) == RowDescriptor( + RowKind.SETTINGS_HEADER + ) + assert section_header_descriptor( + RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY) + ) == RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) + assert section_header_descriptor( + RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT) + ) == RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER) + assert section_header_descriptor( + RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT) + ) == RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER) + assert section_header_descriptor(RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT)) == RowDescriptor( + RowKind.RENDER_POST_FX_HEADER + ) + assert section_header_descriptor( + RowDescriptor(RowKind.TRACK_STEM, slot="layer_1") + ) == RowDescriptor(RowKind.TRACK_HEADER, slot="layer_1") + assert section_header_descriptor( + RowDescriptor( + RowKind.TRACK_EFFECT, slot="layer_1", effect_id="pulse", driver_slug="onset" + ) + ) == RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot="layer_1") From ba43c1f6e0475c16102c33ca701b60e09b11ff13 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 22:43:08 +0100 Subject: [PATCH 4/7] Complete phase 4 --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/viz/app.py | 34 +++-- cleave/viz/controls.py | 143 +++++++++--------- cleave/viz/focus_context.py | 6 +- cleave/viz/focus_nav.py | 102 +++++++++++++ cleave/viz/input_dispatch.py | 9 +- cleave/viz/layer_visibility.py | 20 ++- cleave/viz/overlay.py | 50 ++++++- cleave/viz/session.py | 1 - cleave/viz/tuning_view_state.py | 14 +- cleave/viz/wiring.py | 3 - docs/architecture-improvements.md | 4 +- tests/cleave/viz/test_app.py | 40 +++--- tests/cleave/viz/test_controls.py | 86 ++++++----- tests/cleave/viz/test_focus_nav.py | 160 +++++++++++++++++++++ tests/cleave/viz/test_input_dispatch.py | 19 +-- tests/cleave/viz/test_overlay.py | 9 +- tests/cleave/viz/test_timeline_controls.py | 4 - 18 files changed, 522 insertions(+), 184 deletions(-) create mode 100644 cleave/viz/focus_nav.py create mode 100644 tests/cleave/viz/test_focus_nav.py diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 92b6c67..4a694a7 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -37,7 +37,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** when `timeline.enabled` (strip only; **Down** enters submenu). **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open and enabled, **Up**/**Down** treat timeline rows as part of the main focus ring: order is main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, wrapping **Down** from the last timeline row to **TRANSPORT** and **Up** from **TRANSPORT** to the last timeline row; **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md). +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** when `timeline.enabled` (strip only; **Down** enters submenu). **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open and enabled, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md). ## Header rows section diff --git a/cleave/viz/app.py b/cleave/viz/app.py index a34ed6c..238277e 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -35,6 +35,7 @@ from cleave.viz.playback import PlaybackState, current_sec, init_playback from cleave.viz.frame_finish import RenderOverlayPanelCache, finish_content_frame from cleave.viz.frame_rate import FrameRateMeter +from cleave.viz.focus_nav import FocusCursor, TimelineFocus from cleave.viz.input_dispatch import ( dispatch_keydown, dispatch_keyup, @@ -263,15 +264,24 @@ def init_gl_resources_render( ) -def _timeline_strip_visible(tl: TimelineRuntime, *, overlay_visibility: float) -> bool: +def _timeline_strip_visible( + tl: TimelineRuntime, + *, + overlay_visibility: float, + focus_cursor: FocusCursor, +) -> bool: """Show the bottom timeline strip while the main panel is visible or a row is focused.""" return tl.enabled and tl.panel_open and ( - tl.submenu_focused or overlay_visibility > 0.01 + isinstance(focus_cursor, TimelineFocus) or overlay_visibility > 0.01 ) -def _timeline_strip_fade(tl: TimelineRuntime, *, overlay_visibility: float) -> float: - if tl.submenu_focused: +def _timeline_strip_fade( + *, + focus_cursor: FocusCursor, + overlay_visibility: float, +) -> float: + if isinstance(focus_cursor, TimelineFocus): return 1.0 return overlay_visibility @@ -334,7 +344,9 @@ def _tick_frame_live_overlay( runtime.overlay.update(overlay_dt) overlay_visibility = runtime.overlay.visibility timeline_strip_visible = _timeline_strip_visible( - tl, overlay_visibility=overlay_visibility + tl, + overlay_visibility=overlay_visibility, + focus_cursor=runtime.controls.focus_cursor, ) timeline_panel_open = tl.enabled and tl.panel_open and overlay_visibility > 0.01 OverlayDrawer.draw_tuning( @@ -349,14 +361,20 @@ def _tick_frame_live_overlay( if timeline_strip_visible: timeline_state = build_timeline_view_state( - runtime.seed.session, t_sec, runtime.seed.duration_sec + runtime.seed.session, + t_sec, + runtime.seed.duration_sec, + focus_cursor=runtime.controls.focus_cursor, ) OverlayDrawer.draw_timeline( runtime.compositor, runtime.timeline_overlay, runtime.overlay_surface, timeline_state, - visibility=_timeline_strip_fade(tl, overlay_visibility=overlay_visibility), + visibility=_timeline_strip_fade( + focus_cursor=runtime.controls.focus_cursor, + overlay_visibility=overlay_visibility, + ), ) @@ -488,7 +506,7 @@ def on_progress(message: str) -> None: rt.timeline_controls.focused_cue_index = None if rt.controls.consume_hide_overlay(): rt.overlay.hide_immediately() - tl.submenu_focused = False + rt.controls.exit_timeline_submenu() elif dispatch_should_notify_overlay(event, rt): rt.overlay.notify_input() elif event.type == pygame.KEYUP: diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index d82e05c..91b97f7 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -21,6 +21,14 @@ from cleave.viz.render_overlay_controls import RenderOverlayControls from cleave.viz.render_post_fx_controls import RenderPostFxControls from cleave.viz.settings_controls import SettingsControls +from cleave.viz.focus_nav import ( + FocusCursor, + MainFocus, + TimelineFocus, + cursor_main_descriptor, + move_focus, + timeline_strip_in_ring, +) from cleave.viz.row_semantics import ( REPEAT_ROW_KINDS, RowDescriptor, @@ -69,7 +77,9 @@ def __init__( self._layer_manager = layer_manager self._modal_host = modal_host if modal_host is not None else ModalHost() - self.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) + self._focus_cursor: FocusCursor = MainFocus( + RowDescriptor(RowKind.TRANSPORT) + ) self.move_mode_slot: str | None = None self._move_mode_original_z_order: list[str] | None = None self._toast_message: str | None = None @@ -93,7 +103,7 @@ def __init__( playback, duration_sec, preset_root, - get_focus_descriptor=lambda: self.focus_descriptor, + get_focus_cursor=lambda: self.focus_cursor, get_move_mode_slot=lambda: self.move_mode_slot, config_save=self._config_save, get_toast_message=lambda: self._toast_message, @@ -313,73 +323,65 @@ def build_view_state( paused=paused, position_sec=position_sec, fps=fps ) - def _timeline_row_count(self) -> int: - return len(self.session.layer_z_order) + @property + def focus_descriptor(self) -> RowDescriptor: + return cursor_main_descriptor(self.focus_cursor) - def _timeline_submenu_active(self) -> bool: - tl = self.session.timeline - return tl.panel_open and tl.enabled and self._timeline_row_count() > 0 + @focus_descriptor.setter + def focus_descriptor(self, descriptor: RowDescriptor) -> None: + self._apply_focus_cursor(MainFocus(descriptor)) - def _move_focus(self, delta: int) -> None: - tl = self.session.timeline - row_count = self._timeline_row_count() + @property + def focus_cursor(self) -> FocusCursor: + return self._focus_cursor - if tl.submenu_focused: - if row_count == 0: - tl.submenu_focused = False - elif delta < 0: - if tl.focus_row == 0: - self.exit_timeline_submenu() - return - tl.focus_row -= 1 - return - elif tl.focus_row >= row_count - 1: - tl.submenu_focused = False - self.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) - return - else: - tl.focus_row += 1 - return + @focus_cursor.setter + def focus_cursor(self, cursor: FocusCursor) -> None: + self._apply_focus_cursor(cursor) + + def _apply_focus_cursor(self, cursor: FocusCursor) -> None: + self._focus_cursor = cursor + if isinstance(cursor, TimelineFocus): + self.session.timeline.focus_row = cursor.row + def _normalize_focus_cursor(self) -> None: view = self.build_view_state(paused=self.playback.paused) - navigable = view.layout.navigable_descriptors(view) - if not navigable: - return - current = view.layout.resolve_navigable(self.focus_descriptor, view) - try: - pos = navigable.index(current) - except ValueError: - pos = 0 - - if self._timeline_submenu_active(): - timeline_header = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) - transport = RowDescriptor(RowKind.TRANSPORT) - if delta > 0 and current == timeline_header: - tl.submenu_focused = True - tl.focus_row = 0 - return - if delta < 0 and current == transport: - tl.submenu_focused = True - tl.focus_row = row_count - 1 + tl = self.session.timeline + row_count = len(self.session.layer_z_order) + if isinstance(self.focus_cursor, TimelineFocus): + if not timeline_strip_in_ring(view): + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + ) return + if row_count == 0: + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + ) + elif self.focus_cursor.row >= row_count: + self._apply_focus_cursor(TimelineFocus(row_count - 1)) + return + tl = self.session.timeline + if tl.focus_row >= row_count: + tl.focus_row = row_count - 1 - self.focus_descriptor = navigable[(pos + delta) % len(navigable)] + def _move_focus(self, delta: int) -> None: + view = self.build_view_state(paused=self.playback.paused) + self._apply_focus_cursor(move_focus(self.focus_cursor, delta, view)) def _move_quick_focus(self, delta: int) -> None: + if isinstance(self.focus_cursor, TimelineFocus): + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + ) + if delta < 0: + return view = self.build_view_state(paused=self.playback.paused) quick_indices = view.layout.quick_nav_indices() quick = [view.layout.descriptor(index) for index in quick_indices] if not quick: return - tl = self.session.timeline - if tl.submenu_focused: - tl.submenu_focused = False - timeline_header = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) - if delta < 0: - self.focus_descriptor = timeline_header - return - current_index = -1 - elif view.layout.contains_descriptor(self.focus_descriptor): + if view.layout.contains_descriptor(self.focus_descriptor): current_index = view.layout.find_descriptor(self.focus_descriptor) else: resolved = view.layout.resolve_navigable(self.focus_descriptor, view) @@ -469,15 +471,12 @@ def _confirm_delete_layer(self, slot: str) -> None: view_after = self.build_view_state(paused=self.playback.paused) navigable_after = view_after.layout.navigable_descriptors(view_after) if navigable_after: - self.focus_descriptor = navigable_after[ - min(nav_pos, len(navigable_after) - 1) - ] - tl = self.session.timeline - row_count = len(self.session.layer_z_order) - if row_count == 0: - tl.submenu_focused = False - elif tl.focus_row >= row_count: - tl.focus_row = row_count - 1 + self._apply_focus_cursor( + MainFocus( + navigable_after[min(nav_pos, len(navigable_after) - 1)] + ) + ) + self._normalize_focus_cursor() def _cancel_move_mode(self) -> None: if self._move_mode_original_z_order is not None: @@ -844,20 +843,18 @@ def _open_timeline_panel(self, *, enter_submenu: bool = False) -> None: return tl.panel_open = True if enter_submenu: - tl.submenu_focused = True - tl.focus_row = 0 - else: - tl.submenu_focused = False + self._apply_focus_cursor(TimelineFocus(0)) def close_timeline_panel(self) -> None: tl = self.session.timeline if not tl.panel_open: return tl.panel_open = False - tl.submenu_focused = False - self.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + ) def exit_timeline_submenu(self) -> None: - tl = self.session.timeline - tl.submenu_focused = False - self.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + ) diff --git a/cleave/viz/focus_context.py b/cleave/viz/focus_context.py index 770d9be..c0aefed 100644 --- a/cleave/viz/focus_context.py +++ b/cleave/viz/focus_context.py @@ -5,13 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass +from cleave.viz.focus_nav import FocusCursor from cleave.viz.overlay import TuningViewState -from cleave.viz.row_semantics import RowDescriptor @dataclass(frozen=True) class FocusContext: - get_focus_descriptor: Callable[[], RowDescriptor] - set_focus_descriptor: Callable[[RowDescriptor], None] + get_focus_cursor: Callable[[], FocusCursor] + set_focus_cursor: Callable[[FocusCursor], None] build_view_state: Callable[..., TuningViewState] is_paused: Callable[[], bool] diff --git a/cleave/viz/focus_nav.py b/cleave/viz/focus_nav.py new file mode 100644 index 0000000..009c981 --- /dev/null +++ b/cleave/viz/focus_nav.py @@ -0,0 +1,102 @@ +"""Unified focus cursor and navigation for the main tree and timeline strip.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from cleave.viz.overlay import TuningViewState +from cleave.viz.row_semantics import RowDescriptor, RowKind + + +@dataclass(frozen=True) +class MainFocus: + descriptor: RowDescriptor + + +@dataclass(frozen=True) +class TimelineFocus: + row: int # 0..N-1 into layer_z_order + + +FocusCursor = MainFocus | TimelineFocus + + +def timeline_strip_in_ring(state: TuningViewState) -> bool: + tl = state.render_timeline + return tl.expanded and tl.enabled and len(state.layer_z_order) > 0 + + +def build_focus_ring(state: TuningViewState) -> list[FocusCursor]: + ring: list[FocusCursor] = [ + MainFocus(descriptor) + for descriptor in state.layout.navigable_descriptors(state) + ] + if timeline_strip_in_ring(state): + ring.extend(TimelineFocus(row) for row in range(len(state.layer_z_order))) + return ring + + +def resolve_cursor( + cursor: FocusCursor, + ring: list[FocusCursor], + state: TuningViewState, +) -> FocusCursor: + if not ring: + if isinstance(cursor, MainFocus): + return MainFocus(state.layout.resolve_navigable(cursor.descriptor, state)) + row_count = len(state.layer_z_order) + row = 0 if row_count == 0 else max(0, min(cursor.row, row_count - 1)) + return TimelineFocus(row) + + if isinstance(cursor, MainFocus): + resolved = MainFocus(state.layout.resolve_navigable(cursor.descriptor, state)) + for item in ring: + if item == resolved: + return item + for item in ring: + if isinstance(item, MainFocus): + return item + return ring[0] + + row_count = len(state.layer_z_order) + row = 0 if row_count == 0 else max(0, min(cursor.row, row_count - 1)) + resolved = TimelineFocus(row) + for item in ring: + if item == resolved: + return item + timeline_header = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + for item in ring: + if isinstance(item, MainFocus) and item.descriptor == timeline_header: + return item + for item in ring: + if isinstance(item, MainFocus): + return item + return ring[0] + + +def move_focus(cursor: FocusCursor, delta: int, state: TuningViewState) -> FocusCursor: + ring = build_focus_ring(state) + if not ring: + return cursor + resolved = resolve_cursor(cursor, ring, state) + try: + pos = ring.index(resolved) + except ValueError: + pos = 0 + return ring[(pos + delta) % len(ring)] + + +def cursor_main_descriptor(cursor: FocusCursor) -> RowDescriptor: + if isinstance(cursor, MainFocus): + return cursor.descriptor + return RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + + +def cursor_timeline_submenu_focused(cursor: FocusCursor) -> bool: + return isinstance(cursor, TimelineFocus) + + +def cursor_timeline_row(cursor: FocusCursor) -> int: + if isinstance(cursor, TimelineFocus): + return cursor.row + return 0 diff --git a/cleave/viz/input_dispatch.py b/cleave/viz/input_dispatch.py index c7ee654..f4bf4d7 100644 --- a/cleave/viz/input_dispatch.py +++ b/cleave/viz/input_dispatch.py @@ -7,6 +7,7 @@ import pygame from cleave.viz.controls import TuningControls +from cleave.viz.focus_nav import FocusCursor, TimelineFocus from cleave.viz.key_repeat import mod_ctrl from cleave.viz.timeline_controls import TimelineControls @@ -19,12 +20,13 @@ def timeline_submenu_routes_to_timeline( *, timeline_controls: TimelineControls | None, key: int, + focus_cursor: FocusCursor, ) -> bool: """True when the key should route to timeline controls (submenu focused).""" return ( tl.panel_open and tl.enabled - and tl.submenu_focused + and isinstance(focus_cursor, TimelineFocus) and timeline_controls is not None and key not in (pygame.K_UP, pygame.K_DOWN) ) @@ -39,6 +41,7 @@ def key_handler_for_runtime( tl, timeline_controls=runtime.timeline_controls, key=key, + focus_cursor=runtime.controls.focus_cursor, ): return runtime.timeline_controls return runtime.controls @@ -99,4 +102,6 @@ def dispatch_should_notify_overlay( key_handler = key_handler_for_runtime(runtime, event.key) if key_handler is not runtime.controls: return False - return event.key != pygame.K_t and not tl.submenu_focused + return event.key != pygame.K_t and not isinstance( + runtime.controls.focus_cursor, TimelineFocus + ) diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 633de49..768fbd4 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -5,6 +5,12 @@ from typing import TYPE_CHECKING from cleave.timeline import TimelineCue, layer_visible_at +from cleave.viz.focus_nav import ( + FocusCursor, + TimelineFocus, + cursor_timeline_row, + cursor_timeline_submenu_focused, +) from cleave.viz.session import TuningSession from cleave.viz.timeline_overlay import TimelineViewState, prune_expired_arm_flashes @@ -215,9 +221,19 @@ def build_timeline_view_state( session: TuningSession, position_sec: float, duration_sec: float, + *, + focus_cursor: FocusCursor | None = None, ) -> TimelineViewState: tl = session.timeline prune_expired_arm_flashes(tl.arm_flash_start_ms) + submenu_focused = ( + focus_cursor is not None and cursor_timeline_submenu_focused(focus_cursor) + ) + focus_row = ( + cursor_timeline_row(focus_cursor) + if submenu_focused + else tl.focus_row + ) monitor_visible = { slot: effective_layer_enabled(session, slot, position_sec) for slot in session.layer_z_order @@ -232,7 +248,7 @@ def build_timeline_view_state( defaults=timeline_defaults(session), position_sec=position_sec, duration_sec=duration_sec, - focus_row=tl.focus_row, + focus_row=focus_row, monitor_visible=monitor_visible, timeline_visible=timeline_visible, slot_stems={slot: session.layers[slot].stem for slot in session.layer_z_order}, @@ -243,6 +259,6 @@ def build_timeline_view_state( record_baseline=dict(tl.record_baseline), record_buffer=list(tl.record_buffer), enabled=tl.enabled, - submenu_focused=tl.submenu_focused, + submenu_focused=submenu_focused, arm_flash_start_ms=dict(tl.arm_flash_start_ms), ) diff --git a/cleave/viz/overlay.py b/cleave/viz/overlay.py index 3c9b5b0..c864c3b 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/overlay.py @@ -7,7 +7,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Literal +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from cleave.viz.focus_nav import FocusCursor import pygame @@ -396,7 +399,7 @@ class TuningViewState: tracks: dict[str, TrackBlock] paused: bool position_sec: float - focus_descriptor: RowDescriptor + focus_cursor: FocusCursor move_mode_slot: str | None toast_message: str | None toast_remaining_sec: float @@ -413,7 +416,6 @@ class TuningViewState: default_factory=RenderTimelineBlock ) settings: SettingsBlock = field(default_factory=SettingsBlock) - timeline_submenu_focused: bool = False timeline_recording: bool = False timeline_override_active: bool = False help_visible: bool = False @@ -423,6 +425,48 @@ class TuningViewState: def __post_init__(self) -> None: object.__setattr__(self, "layout", RowLayout.build(self)) + @property + def focus_descriptor(self) -> RowDescriptor: + from cleave.viz.focus_nav import cursor_main_descriptor + + return self.layout.resolve_navigable( + cursor_main_descriptor(self.focus_cursor), self + ) + + @focus_descriptor.setter + def focus_descriptor(self, descriptor: RowDescriptor) -> None: + from cleave.viz.focus_nav import MainFocus + + object.__setattr__(self, "focus_cursor", MainFocus(descriptor)) + + @property + def timeline_submenu_focused(self) -> bool: + from cleave.viz.focus_nav import cursor_timeline_submenu_focused + + return cursor_timeline_submenu_focused(self.focus_cursor) + + @timeline_submenu_focused.setter + def timeline_submenu_focused(self, value: bool) -> None: + from cleave.viz.focus_nav import ( + MainFocus, + TimelineFocus, + cursor_timeline_row, + ) + + if value: + row = ( + cursor_timeline_row(self.focus_cursor) + if isinstance(self.focus_cursor, TimelineFocus) + else 0 + ) + object.__setattr__(self, "focus_cursor", TimelineFocus(row)) + elif isinstance(self.focus_cursor, TimelineFocus): + object.__setattr__( + self, + "focus_cursor", + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)), + ) + @property def focus_index(self) -> int: resolved = self.layout.resolve_navigable(self.focus_descriptor, self) diff --git a/cleave/viz/session.py b/cleave/viz/session.py index de2e910..2b878ce 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -76,7 +76,6 @@ class TimelineRuntime: enabled: bool = True cues: list[TimelineCue] = field(default_factory=list) panel_open: bool = False - submenu_focused: bool = False focus_row: int = 0 armed_slots: set[str] = field(default_factory=set) recording: bool = False diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 7b9aae7..8996515 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -4,10 +4,10 @@ import time from collections.abc import Callable -from dataclasses import replace from cleave.preset_playlist import directory_display, preset_filename_display from cleave.viz.config_save import ConfigSaveController +from cleave.viz.focus_nav import FocusCursor from cleave.viz.overlay import ( RenderOverlayBlock, RenderPostFxBlock, @@ -17,7 +17,6 @@ TuningViewState, ) from cleave.viz.playback import PlaybackState, current_sec -from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import TuningSession, config_path_display @@ -31,7 +30,7 @@ def __init__( duration_sec: float, preset_root, *, - get_focus_descriptor: Callable[[], RowDescriptor], + get_focus_cursor: Callable[[], FocusCursor], get_move_mode_slot: Callable[[], str | None], config_save: ConfigSaveController, get_toast_message: Callable[[], str | None], @@ -41,7 +40,7 @@ def __init__( self.playback = playback self.duration_sec = duration_sec self.preset_root = preset_root - self._get_focus_descriptor = get_focus_descriptor + self._get_focus_cursor = get_focus_cursor self._get_move_mode_slot = get_move_mode_slot self._config_save = config_save self._get_toast_message = get_toast_message @@ -97,13 +96,12 @@ def build( ro = self.session.render_overlay pp = self.session.render_post_fx tl = self.session.timeline - resolved = self._get_focus_descriptor() state = TuningViewState( layer_z_order=tuple(self.session.layer_z_order), tracks=tracks, paused=paused, position_sec=position_sec, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=self._get_focus_cursor(), move_mode_slot=self._get_move_mode_slot(), toast_message=toast_message, toast_remaining_sec=toast_remaining, @@ -144,11 +142,9 @@ def build( expanded=self.session.settings.expanded, render_mode=self._config_save.cfg.visualizer.render_mode, ), - timeline_submenu_focused=tl.submenu_focused, timeline_recording=tl.recording, timeline_override_active=bool(tl.override_slots), help_visible=self.session.help_visible, fps=fps, ) - resolved = state.layout.resolve_navigable(resolved, state) - return replace(state, focus_descriptor=resolved) + return state diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index 72ee391..569079c 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -319,13 +319,10 @@ def on_close() -> None: tuning_controls.close_timeline_panel() else: session.timeline.panel_open = False - session.timeline.submenu_focused = False def on_exit_submenu() -> None: if tuning_controls is not None: tuning_controls.exit_timeline_submenu() - else: - session.timeline.submenu_focused = False def on_seek(delta_sec: float) -> None: seek(playback, delta_sec, duration_sec) diff --git a/docs/architecture-improvements.md b/docs/architecture-improvements.md index 25801ef..6a08ead 100644 --- a/docs/architecture-improvements.md +++ b/docs/architecture-improvements.md @@ -46,7 +46,9 @@ Remove `_restore_focus`, `_refocus_track_header_if_sub_row`, and the index arith ## Phase 4 - Unified focus model for the timeline bridge -**Why.** There are two parallel focus systems: `TuningControls.focus_index` (main tree) and `session.timeline.focus_row + submenu_focused` (timeline strip). The bridge in `_move_focus` stitches them with special cases for Up-from-TRANSPORT and Down-from-RENDER_TIMELINE_HEADER. The bridge consumes both endpoints of the modulo ring, stranding `SETTINGS_HEADER` at the top of the navigable list: you can never reach Settings by wrapping from below when the timeline is open -- this is Bug 2. The asymmetric exit logic (Down past last timeline row exits to TRANSPORT, not modulo) also means the two systems have different wrap semantics. +**Status:** done. `FocusCursor` (`MainFocus` / `TimelineFocus`), ring construction, and `move_focus` live in [cleave/viz/focus_nav.py](cleave/viz/focus_nav.py); `_move_focus` delegates there; view state and controls carry `focus_cursor`; `submenu_focused` and `focus_descriptor` derive from the cursor. + +**Why.** Main-tree `focus_descriptor` and `session.timeline.focus_row` were separate; `_move_focus` bridged them with special cases for Up-from-TRANSPORT and Down-from-RENDER_TIMELINE_HEADER. That bridge stranded `SETTINGS_HEADER`: Settings was unreachable by wrapping from below when the timeline strip was open (Bug 2). Down past the last timeline row exited to TRANSPORT instead of closing the ring. **What to do.** Model focus as a discriminated union: diff --git a/tests/cleave/viz/test_app.py b/tests/cleave/viz/test_app.py index 8d430d4..974b066 100644 --- a/tests/cleave/viz/test_app.py +++ b/tests/cleave/viz/test_app.py @@ -17,7 +17,9 @@ _timeline_strip_fade, _timeline_strip_visible, ) +from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.input_dispatch import key_handler_for_runtime +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import LayerRuntime, RenderPostFxRuntime, TuningSession from cleave.viz.modal import ModalHost from cleave.viz.overlay import TuningOverlay @@ -562,7 +564,7 @@ def test_key_routing_main_when_strip_open_not_in_submenu() -> None: runtime.timeline_controls = timeline runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True - runtime.seed.session.timeline.submenu_focused = False + runtime.controls.focus_cursor = MainFocus(RowDescriptor(RowKind.TRANSPORT)) assert _key_handler_for_session(runtime) is main @@ -578,7 +580,7 @@ def test_key_routing_timeline_when_submenu_focused() -> None: runtime.overlay.notify_input() runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True - runtime.seed.session.timeline.submenu_focused = True + runtime.controls.focus_cursor = TimelineFocus(0) assert _key_handler_for_session(runtime, pygame.K_RETURN) is timeline assert _key_handler_for_session(runtime, pygame.K_UP) is main @@ -595,7 +597,7 @@ def test_key_routing_timeline_when_overlay_hidden_and_submenu_focused() -> None: runtime.overlay = TuningOverlay() runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True - runtime.seed.session.timeline.submenu_focused = True + runtime.controls.focus_cursor = TimelineFocus(0) assert _key_handler_for_session(runtime, pygame.K_RETURN) is timeline assert _key_handler_for_session(runtime, pygame.K_UP) is main @@ -610,7 +612,7 @@ def test_keyup_routing_main_for_vertical_nav_when_submenu_focused() -> None: runtime.timeline_controls = timeline runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True - runtime.seed.session.timeline.submenu_focused = True + runtime.controls.focus_cursor = TimelineFocus(0) assert _keyup_handler_for_session(runtime, pygame.K_UP) is main assert _keyup_handler_for_session(runtime, pygame.K_DOWN) is main @@ -636,7 +638,7 @@ def test_tick_frame_skips_timeline_when_overlay_hidden_and_not_in_submenu( pygame.init() compositor = recording_compositor() runtime = _timeline_open_runtime(compositor) - runtime.seed.session.timeline.submenu_focused = False + runtime.controls.focus_cursor = MainFocus(RowDescriptor(RowKind.TRANSPORT)) app = VisualizerApp(runtime) app.tick_frame(1.0, paused=True, draw_overlay=True, n_pcm=735) @@ -662,7 +664,7 @@ def test_tick_frame_draws_timeline_when_overlay_hidden_but_submenu_focused( pygame.init() compositor = recording_compositor() runtime = _timeline_open_runtime(compositor) - runtime.seed.session.timeline.submenu_focused = True + runtime.controls.focus_cursor = TimelineFocus(0) app = VisualizerApp(runtime) app.tick_frame(1.0, paused=True, draw_overlay=True, n_pcm=735) @@ -673,15 +675,15 @@ def test_timeline_strip_visible_while_submenu_focused_despite_hidden_overlay() - tl = TuningSession(layer_z_order=[], layers={}).timeline tl.enabled = True tl.panel_open = True - tl.submenu_focused = True + focus = TimelineFocus(0) - assert _timeline_strip_visible(tl, overlay_visibility=0.0) is True - assert _timeline_strip_visible(tl, overlay_visibility=1.0) is True - assert _timeline_strip_fade(tl, overlay_visibility=0.0) == 1.0 + assert _timeline_strip_visible(tl, overlay_visibility=0.0, focus_cursor=focus) is True + assert _timeline_strip_visible(tl, overlay_visibility=1.0, focus_cursor=focus) is True + assert _timeline_strip_fade(focus_cursor=focus, overlay_visibility=0.0) == 1.0 - tl.submenu_focused = False - assert _timeline_strip_visible(tl, overlay_visibility=0.0) is False - assert _timeline_strip_fade(tl, overlay_visibility=0.5) == 0.5 + focus = MainFocus(RowDescriptor(RowKind.TRANSPORT)) + assert _timeline_strip_visible(tl, overlay_visibility=0.0, focus_cursor=focus) is False + assert _timeline_strip_fade(focus_cursor=focus, overlay_visibility=0.5) == 0.5 @patch("cleave.viz.app.OverlayDrawer.draw_timeline") @@ -718,15 +720,21 @@ def test_esc_hide_clears_submenu_focus_preserves_panel_open() -> None: runtime.overlay = overlay runtime.seed.session.timeline.enabled = True runtime.seed.session.timeline.panel_open = True - runtime.seed.session.timeline.submenu_focused = True + runtime.controls.focus_cursor = TimelineFocus(0) + + def _exit_submenu() -> None: + runtime.controls.focus_cursor = MainFocus( + RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) + ) runtime.controls.consume_hide_overlay.return_value = True + runtime.controls.exit_timeline_submenu = _exit_submenu if runtime.controls.consume_hide_overlay(): overlay.hide_immediately() - runtime.seed.session.timeline.submenu_focused = False + runtime.controls.exit_timeline_submenu() assert runtime.seed.session.timeline.panel_open is True - assert runtime.seed.session.timeline.submenu_focused is False + assert not isinstance(runtime.controls.focus_cursor, TimelineFocus) assert overlay.is_visible() is False diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index a256d23..b11de82 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -23,6 +23,7 @@ scan_preset_playlist, ) from cleave.timeline import TimelineCue +from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.key_repeat import mod_shift from cleave.viz.playback import format_mmss from tests.support.viz import make_test_cfg, noop_layer_bindings, stub_playback_state @@ -306,8 +307,7 @@ def test_delete_layer_clamps_timeline_focus_row() -> None: controls, manager = _make_controls_with_manager(slots) controls.session.timeline.enabled = True controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = 3 + controls.focus_cursor = TimelineFocus(3) def remove_layer(slot: str) -> None: controls.session.layer_z_order.remove(slot) @@ -767,7 +767,7 @@ def test_preset_row_truncates_long_filenames() -> None: }, paused=False, position_sec=0.0, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -1404,7 +1404,7 @@ def test_render_timeline_ctrl_right_toggles_enabled() -> None: controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.enabled is True assert controls.session.timeline.panel_open is True - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) def test_render_timeline_enable_opens_panel() -> None: @@ -1418,7 +1418,7 @@ def test_render_timeline_enable_opens_panel() -> None: controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) assert controls.session.timeline.enabled is True assert controls.session.timeline.panel_open is True - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▼") @@ -1431,11 +1431,11 @@ def test_render_timeline_right_opens_panel() -> None: controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 assert controls.session.timeline.panel_open is False - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.timeline.panel_open is True - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 2 view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) @@ -1443,7 +1443,7 @@ def test_render_timeline_right_opens_panel() -> None: controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.timeline.panel_open is False - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) assert _row_text(view, header_row).endswith(" ▶") @@ -1458,7 +1458,7 @@ def test_render_timeline_down_enters_submenu() -> None: controls.session.timeline.focus_row = 2 controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.session.timeline.submenu_focused is True + assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 assert controls.focus_descriptor == _desc(view, header_row) @@ -1472,7 +1472,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.session.timeline.focus_row = 2 controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.session.timeline.submenu_focused is True + assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 tl = controls.session.timeline @@ -1483,7 +1483,7 @@ def key_handler_for(key: int): if ( tl.panel_open and tl.enabled - and tl.submenu_focused + and isinstance(controls.focus_cursor, TimelineFocus) and key not in (pygame.K_UP, pygame.K_DOWN) ): return timeline_controls @@ -1525,8 +1525,7 @@ def test_timeline_submenu_vertical_navigation_repeats() -> None: slots=("layer_1", "layer_2", "layer_3", "layer_4"), ) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = 0 + controls.focus_cursor = TimelineFocus(0) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.session.timeline.focus_row == 1 @@ -1593,12 +1592,11 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = 0 + controls.focus_cursor = TimelineFocus(0) controls.handle_keydown(_keydown(pygame.K_UP)) - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) assert controls.focus_descriptor == RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) @@ -1611,10 +1609,10 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = False + controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.session.timeline.submenu_focused is True + assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 assert controls.key_repeat_armed is True @@ -1624,35 +1622,33 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: assert controls.session.timeline.focus_row == 0 -def test_render_timeline_submenu_down_from_last_row_wraps_to_transport() -> None: +def test_render_timeline_submenu_down_from_last_row_wraps_to_settings() -> None: stems = ("layer_1", "layer_2", "layer_3", "layer_4") controls = _make_controls(stems, timeline_enabled=True) view = controls.build_view_state(paused=False) - transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = len(stems) - 1 + controls.focus_cursor = TimelineFocus(len(stems) - 1) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.session.timeline.submenu_focused is False - assert controls.focus_descriptor == _desc(view, transport_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_descriptor == _desc(view, settings_row) -def test_render_timeline_submenu_up_from_transport_wraps_to_last_row() -> None: +def test_render_timeline_submenu_up_from_transport_wraps_to_config_header() -> None: stems = ("layer_1", "layer_2", "layer_3", "layer_4") controls = _make_controls(stems, timeline_enabled=True) view = controls.build_view_state(paused=False) transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) + config_row = view.layout.find_by_kind(RowKind.CONFIG_HEADER) controls.focus_descriptor = _desc(view, transport_row) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = False controls.handle_keydown(_keydown(pygame.K_UP)) - assert controls.session.timeline.submenu_focused is True - assert controls.session.timeline.focus_row == len(stems) - 1 - assert controls.focus_descriptor == _desc(view, transport_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_descriptor == _desc(view, config_row) def test_render_timeline_panel_closed_wrap_unchanged() -> None: @@ -1666,12 +1662,12 @@ def test_render_timeline_panel_closed_wrap_unchanged() -> None: controls.session.timeline.panel_open = False controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, settings_row) controls.focus_descriptor = _desc(view, transport_row) controls.handle_keydown(_keydown(pygame.K_UP)) - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, navigable[navigable.index(transport_row) - 1]) @@ -1749,7 +1745,7 @@ def test_t_opens_timeline_panel_when_enabled() -> None: controls.handle_keydown(_keydown(pygame.K_t)) assert controls.session.timeline.panel_open is True - assert controls.session.timeline.submenu_focused is True + assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -1758,12 +1754,12 @@ def test_t_closes_timeline_panel_and_focuses_header_when_open() -> None: view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = False + controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_t)) assert controls.session.timeline.panel_open is False - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, header_row) @@ -1773,7 +1769,7 @@ def test_t_from_submenu_closes_and_focuses_render_timeline_header() -> None: header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True + controls.focus_cursor = TimelineFocus(0) from cleave.viz.timeline_controls import TimelineControls @@ -1786,7 +1782,7 @@ def test_t_from_submenu_closes_and_focuses_render_timeline_header() -> None: timeline_controls.handle_keydown(_keydown(pygame.K_t)) assert controls.session.timeline.panel_open is False - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, header_row) @@ -1965,18 +1961,16 @@ def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) controls.session.timeline.panel_open = True - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = 1 + controls.focus_cursor = TimelineFocus(1) controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, timeline_header) - controls.session.timeline.submenu_focused = True - controls.session.timeline.focus_row = 2 + controls.focus_cursor = TimelineFocus(2) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.session.timeline.submenu_focused is False + assert not isinstance(controls.focus_cursor, TimelineFocus) assert controls.focus_descriptor == _desc(view, transport_row) assert timeline_header in quick @@ -2252,7 +2246,7 @@ def test_row_value_color_dim_for_focused_empty_preset() -> None: }, paused=False, position_sec=0.0, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -2263,7 +2257,7 @@ def test_row_value_color_dim_for_focused_empty_preset() -> None: tracks=state.tracks, paused=state.paused, position_sec=state.position_sec, - focus_descriptor=state.layout.descriptor(preset_row), + focus_cursor=MainFocus(state.layout.descriptor(preset_row)), move_mode_slot=state.move_mode_slot, toast_message=state.toast_message, toast_remaining_sec=state.toast_remaining_sec, @@ -2428,7 +2422,7 @@ def test_locked_sub_rows_use_locked_color() -> None: tracks=view.tracks, paused=view.paused, position_sec=view.position_sec, - focus_descriptor=view.layout.descriptor(len(view.layout) - 1), + focus_cursor=MainFocus(view.layout.descriptor(len(view.layout) - 1)), move_mode_slot=view.move_mode_slot, toast_message=view.toast_message, toast_remaining_sec=view.toast_remaining_sec, @@ -2443,7 +2437,7 @@ def test_locked_sub_rows_use_locked_color() -> None: tracks=view.tracks, paused=view.paused, position_sec=view.position_sec, - focus_descriptor=view.layout.descriptor(header_row), + focus_cursor=MainFocus(view.layout.descriptor(header_row)), move_mode_slot=view.move_mode_slot, toast_message=view.toast_message, toast_remaining_sec=view.toast_remaining_sec, diff --git a/tests/cleave/viz/test_focus_nav.py b/tests/cleave/viz/test_focus_nav.py new file mode 100644 index 0000000..2cb5374 --- /dev/null +++ b/tests/cleave/viz/test_focus_nav.py @@ -0,0 +1,160 @@ +"""Unit tests for unified focus ring navigation.""" + +from __future__ import annotations + +import pygame + +from cleave.viz.focus_nav import ( + MainFocus, + TimelineFocus, + build_focus_ring, + move_focus, + resolve_cursor, + timeline_strip_in_ring, +) +from cleave.viz.overlay import RenderTimelineBlock, TrackBlock, TuningViewState +from cleave.viz.row_semantics import RowDescriptor, RowKind +from tests.cleave.viz.test_controls import ( + _desc, + _keydown, + _make_controls, +) +from tests.cleave.viz.test_overlay import _minimal_view_state + + +def _timeline_open_state( + slots: tuple[str, ...] = ("layer_1", "layer_2"), +) -> TuningViewState: + tracks = { + slot: TrackBlock( + stem="drums", + preset_dir_label="dir", + preset_label="preset.milk", + blend_mode="black-key", + opacity_pct=50, + beat_sensitivity=1.0, + effects={}, + ) + for slot in slots + } + return _minimal_view_state( + layer_z_order=slots, + tracks=tracks, + render_timeline=RenderTimelineBlock(enabled=True, expanded=True), + ) + + +def test_timeline_strip_in_ring_requires_open_enabled_layers() -> None: + closed = _minimal_view_state( + render_timeline=RenderTimelineBlock(enabled=True, expanded=False), + ) + assert timeline_strip_in_ring(closed) is False + + disabled = _minimal_view_state( + render_timeline=RenderTimelineBlock(enabled=False, expanded=True), + ) + assert timeline_strip_in_ring(disabled) is False + + empty = _minimal_view_state(layer_z_order=()) + assert timeline_strip_in_ring(empty) is False + + open_state = _timeline_open_state() + assert timeline_strip_in_ring(open_state) is True + + +def test_build_focus_ring_without_timeline_segment() -> None: + state = _minimal_view_state() + ring = build_focus_ring(state) + expected = [ + MainFocus(descriptor) + for descriptor in state.layout.navigable_descriptors(state) + ] + assert ring == expected + assert all(isinstance(item, MainFocus) for item in ring) + + +def test_build_focus_ring_includes_timeline_rows_when_strip_active() -> None: + slots = ("layer_1", "layer_2", "layer_3") + state = _timeline_open_state(slots) + ring = build_focus_ring(state) + main_part = [ + MainFocus(descriptor) + for descriptor in state.layout.navigable_descriptors(state) + ] + timeline_part = [TimelineFocus(row) for row in range(len(slots))] + assert ring == main_part + timeline_part + + +def test_move_focus_down_from_last_timeline_row_wraps_to_settings() -> None: + slots = ("layer_1", "layer_2", "layer_3", "layer_4") + state = _timeline_open_state(slots) + settings = RowDescriptor(RowKind.SETTINGS_HEADER) + cursor = TimelineFocus(len(slots) - 1) + + result = move_focus(cursor, 1, state) + + assert isinstance(result, MainFocus) + assert result.descriptor == settings + + +def test_move_focus_up_from_settings_wraps_to_last_timeline_row() -> None: + slots = ("layer_1", "layer_2", "layer_3", "layer_4") + state = _timeline_open_state(slots) + cursor = MainFocus(RowDescriptor(RowKind.SETTINGS_HEADER)) + + result = move_focus(cursor, -1, state) + + assert result == TimelineFocus(len(slots) - 1) + + +def test_resolve_cursor_maps_stale_main_descriptor() -> None: + state = _minimal_view_state() + ring = build_focus_ring(state) + stale = MainFocus(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) + + resolved = resolve_cursor(stale, ring, state) + + assert isinstance(resolved, MainFocus) + assert resolved.descriptor == RowDescriptor(RowKind.SETTINGS_HEADER) + assert resolved in ring + + +def test_resolve_cursor_clamps_timeline_row_when_layer_count_shrinks() -> None: + slots = ("layer_1", "layer_2") + state = _timeline_open_state(slots) + ring = build_focus_ring(state) + stale = TimelineFocus(9) + + resolved = resolve_cursor(stale, ring, state) + + assert resolved == TimelineFocus(1) + assert resolved in ring + + +def test_move_focus_steps_through_main_ring() -> None: + state = _minimal_view_state() + ring = build_focus_ring(state) + start = ring[3] + assert move_focus(start, 1, state) == ring[4] + assert move_focus(start, -1, state) == ring[2] + assert move_focus(start, len(ring), state) == start + + +def test_bug2_up_from_transport_reaches_settings_when_timeline_open() -> None: + slots = ("layer_1", "layer_2", "layer_3", "layer_4") + controls = _make_controls(slots, timeline_enabled=True) + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = _desc(view, transport_row) + + controls.handle_keydown(_keydown(pygame.K_UP)) + assert not isinstance(controls.focus_cursor, TimelineFocus) + + controls.handle_keydown(_keydown(pygame.K_UP)) + assert controls.focus_descriptor == _desc(view, settings_row) + + controls.handle_keydown(_keydown(pygame.K_UP)) + assert isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_cursor.row == len(slots) - 1 diff --git a/tests/cleave/viz/test_input_dispatch.py b/tests/cleave/viz/test_input_dispatch.py index 57bd2d7..66a4e95 100644 --- a/tests/cleave/viz/test_input_dispatch.py +++ b/tests/cleave/viz/test_input_dispatch.py @@ -11,7 +11,9 @@ from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.viz.app import LiveVisualizerRuntime, VisualizerSeed +from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.controls import TuningControls +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import LayerRuntime, TuningSession from cleave.viz.input_dispatch import ( dispatch_keydown, @@ -48,7 +50,6 @@ def _make_runtime( tl = session.timeline tl.enabled = True tl.panel_open = panel_open - tl.submenu_focused = submenu_focused tl.recording = recording if recording: tl.armed_slots = {"layer_1"} @@ -98,15 +99,17 @@ def _make_runtime( playback=playback, duration_sec=120.0, ) + if submenu_focused: + runtime.controls.focus_cursor = TimelineFocus(0) + else: + runtime.controls.focus_cursor = MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) runtime.modal_host = runtime.controls.modal_host runtime.timeline_controls = TimelineControls( session, playback, 120.0, - on_close=lambda: ( - setattr(tl, "panel_open", False), - setattr(tl, "submenu_focused", False), - ), + on_close=runtime.controls.close_timeline_panel, + on_exit_submenu=runtime.controls.exit_timeline_submenu, ) return runtime @@ -165,7 +168,7 @@ def test_esc_while_recording_stops_take_panel_stays_open() -> None: assert dispatch_keydown(keydown(pygame.K_ESCAPE), runtime) is True assert runtime.seed.session.timeline.recording is False assert runtime.seed.session.timeline.panel_open is True - assert runtime.seed.session.timeline.submenu_focused is True + assert isinstance(runtime.controls.focus_cursor, TimelineFocus) def test_second_esc_after_stop_closes_submenu_panel() -> None: @@ -175,7 +178,7 @@ def test_second_esc_after_stop_closes_submenu_panel() -> None: assert dispatch_keydown(keydown(pygame.K_ESCAPE), runtime) is True assert runtime.seed.session.timeline.panel_open is False - assert runtime.seed.session.timeline.submenu_focused is False + assert not isinstance(runtime.controls.focus_cursor, TimelineFocus) def test_second_esc_after_stop_requests_overlay_hide_on_main() -> None: @@ -192,7 +195,7 @@ def test_t_while_recording_is_noop() -> None: runtime = _make_runtime(recording=True) assert dispatch_keydown(keydown(pygame.K_t), runtime) is True assert runtime.seed.session.timeline.panel_open is True - assert runtime.seed.session.timeline.submenu_focused is True + assert isinstance(runtime.controls.focus_cursor, TimelineFocus) def test_submenu_routing_up_down_to_tuning_enter_to_timeline() -> None: diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 5a2a17b..b5443b5 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -9,6 +9,7 @@ from cleave.extract import STEM_NAMES from cleave.viz.frame_rate import format_fps_display from cleave.viz.material_icons import row_icon_prefix_width +from cleave.viz.focus_nav import MainFocus from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.overlay import ( PanelScrollMetrics, @@ -68,7 +69,7 @@ def _effects_expanded_view_state() -> TuningViewState: tracks=tracks, paused=False, position_sec=0.0, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -275,7 +276,7 @@ def test_fps_color_ignores_transport_focus() -> None: overlay = TuningOverlay() state = _minimal_view_state( fps=30.0, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), ) with_fps = _copy_panel_surface(overlay, state) @@ -439,7 +440,7 @@ def _minimal_view_state(**kwargs: object) -> TuningViewState: }, "paused": False, "position_sec": 0.0, - "focus_descriptor": RowDescriptor(RowKind.TRANSPORT), + "focus_cursor": MainFocus(RowDescriptor(RowKind.TRANSPORT)), "move_mode_slot": None, "toast_message": None, "toast_remaining_sec": 0.0, @@ -860,7 +861,7 @@ def test_draw_track_header_with_solo_eye() -> None: }, paused=False, position_sec=0.0, - focus_descriptor=RowDescriptor(RowKind.TRANSPORT), + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 447ace7..3aa7ed2 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -23,7 +23,6 @@ def _make_timeline_controls( focus_row: int = 0, armed_slots: set[str] | None = None, panel_open: bool = True, - submenu_focused: bool = True, enabled: bool = True, position_sec: float = 0.0, recording: bool = False, @@ -50,7 +49,6 @@ def _make_timeline_controls( tl = session.timeline tl.enabled = enabled tl.panel_open = panel_open - tl.submenu_focused = submenu_focused tl.cues = list(cues or []) tl.focus_row = focus_row tl.armed_slots = set(armed_slots or ()) @@ -72,9 +70,7 @@ def _make_timeline_controls( on_close=lambda: ( close_calls.append(True), setattr(tl, "panel_open", False), - setattr(tl, "submenu_focused", False), ), - on_exit_submenu=lambda: setattr(tl, "submenu_focused", False), on_seek=lambda delta: seeks.append(delta), on_toast=toasts.append, ) From 08eb0c32025fe476567d02e7ccc869cb20dfdfee Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 23:12:55 +0100 Subject: [PATCH 5/7] Complete phase 5 --- .cursor/rules/architecture-principles.mdc | 2 +- .cursor/rules/live-tuning-ui.mdc | 10 +- .cursor/rules/project-context.mdc | 2 +- cleave/viz/app.py | 2 +- cleave/viz/controls.py | 3 +- cleave/viz/focus_context.py | 2 +- cleave/viz/focus_nav.py | 2 +- cleave/viz/help_overlay.py | 2 +- cleave/viz/overlay_draw.py | 3 +- cleave/viz/row_layout.py | 241 +++++++++++ cleave/viz/row_semantics.py | 6 + cleave/viz/timeline_overlay.py | 2 +- .../viz/{overlay.py => tuning_panel_draw.py} | 397 +----------------- cleave/viz/tuning_view_state.py | 164 +++++++- docs/architecture-improvements.md | 26 +- tests/cleave/viz/test_app.py | 2 +- tests/cleave/viz/test_controls.py | 9 +- tests/cleave/viz/test_focus_nav.py | 2 +- tests/cleave/viz/test_input_dispatch.py | 2 +- tests/cleave/viz/test_overlay.py | 12 +- .../cleave/viz/test_row_layout_resolution.py | 8 +- tests/cleave/viz/test_timeline_overlay.py | 2 +- 22 files changed, 459 insertions(+), 442 deletions(-) create mode 100644 cleave/viz/row_layout.py rename cleave/viz/{overlay.py => tuning_panel_draw.py} (77%) diff --git a/.cursor/rules/architecture-principles.mdc b/.cursor/rules/architecture-principles.mdc index 0e98341..cbe1553 100644 --- a/.cursor/rules/architecture-principles.mdc +++ b/.cursor/rules/architecture-principles.mdc @@ -12,7 +12,7 @@ Conventions established by [docs/architecture-refactor.md](docs/architecture-ref - **Single source for persisted config.** New persisted fields go through `persisted_session_payload` in [cleave/config_schema.py](cleave/config_schema.py) so load, save, and dirty tracking stay aligned; do not add a parallel serializer. - **Dirty tracking is computed, not marked.** Mutate session state and let the signature compare detect changes; never reintroduce `mark_config_dirty()`. See [cleave/config_snapshot.py](cleave/config_snapshot.py) (`persisted_session_signature`). - **Thin controllers, split by feature.** [cleave/viz/controls.py](cleave/viz/controls.py) `TuningControls` coordinates focus and input and delegates to [cleave/viz/config_save.py](cleave/viz/config_save.py), [cleave/viz/render_overlay_controls.py](cleave/viz/render_overlay_controls.py), [cleave/viz/render_post_fx_controls.py](cleave/viz/render_post_fx_controls.py); do not let one class accumulate unrelated responsibilities. -- **Model, controller, and view separate.** Session dataclasses in [cleave/viz/session.py](cleave/viz/session.py); view model via `TuningViewStateBuilder` in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py); [cleave/viz/overlay.py](cleave/viz/overlay.py) only draws. +- **Model, controller, and view separate.** Session dataclasses in [cleave/viz/session.py](cleave/viz/session.py); view model via `TuningViewStateBuilder` in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py); [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) draws the live tuning panel. - **Readiness enforced with types.** Prefer `VisualizerSeed`, `VisualizerCore`, `LiveVisualizerRuntime`, and `RenderVisualizerRuntime` in [cleave/viz/app.py](cleave/viz/app.py) over optional fields guarded by `assert ... is not None`. Core composes seed via `runtime.seed.*`. - **Typed dependency injection.** Pass [cleave/viz/focus_context.py](cleave/viz/focus_context.py) `FocusContext` (or similar small typed context), never a dict of lambdas or `setattr` by string. - **Public APIs across modules.** No cross-module underscore imports; expose helpers on a class or module if another package needs them. diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 4a694a7..1feae77 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -6,7 +6,7 @@ alwaysApply: false # Live tuning overlay layout -The focus-driven tree panel ([cleave/viz/overlay.py](cleave/viz/overlay.py) draws; [cleave/viz/controls.py](cleave/viz/controls.py) coordinates input; [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) builds `TuningViewState`; [cleave/viz/session.py](cleave/viz/session.py) holds session) splits into two vertical sections. Use these names in docs, issues, and UI work. +The focus-driven tree panel ([cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) draws; [cleave/viz/controls.py](cleave/viz/controls.py) coordinates input; [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) builds `TuningViewState`; [cleave/viz/row_layout.py](cleave/viz/row_layout.py) builds `RowLayout`; [cleave/viz/session.py](cleave/viz/session.py) holds session) splits into two vertical sections. Use these names in docs, issues, and UI work. Unsaved config edits show an asterisk on the active config path row. **Ctrl+Q** and window close route through `TuningControls.try_quit()` (delegates to [cleave/viz/config_save.py](cleave/viz/config_save.py)); when dirty, a centered three-option quit modal (Save / Don't save / Cancel) appears before exit. @@ -16,9 +16,9 @@ Confirm, save-choice, and unsaved-quit prompts are drawn by [cleave/viz/modal_ov ## Track rows section -The scrollable block below the pinned header: all rows where `row_kind` is not in `HEADER_ROW_KINDS` from [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) (`track_row_count` counts these rows from `build_row_layout`). Row interaction groups (sub-row frozensets, repeat keys, help affordances) are defined in the same module. +The scrollable block below the pinned header: all rows where `row_kind` is not in `HEADER_ROW_KINDS` from [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) (`state.layout.track_row_count()` counts these rows). Row interaction groups (sub-row frozensets, repeat keys, help affordances) are defined in the same module. -- One **track block** per slot in `layer_z_order` (dynamic slots `layer_1`..`layer_8`; default four; use `row_slot()` in [cleave/viz/overlay.py](cleave/viz/overlay.py) to read the slot from a row index). +- One **track block** per slot in `layer_z_order` (dynamic slots `layer_1`..`layer_8`; default four; use `state.layout.slot(index)` in [cleave/viz/row_layout.py](cleave/viz/row_layout.py) to read the slot from a row index). - **Base rows** (always present per stem): header, preset dir, preset, stem, blend, opacity, beat sensitivity, then **cleave effects** header (`RowKind.TRACK_EFFECTS_HEADER`). - **Stem row** (`RowKind.TRACK_STEM`): Left/Right cycles `STEM_SOURCES`; follows standard locked sub-row rules (blocked when layer locked; only **cleave effects** header remains navigable among locked sub-rows). - **Effect sub-rows** (`RowKind.TRACK_EFFECT`): one per entry in [cleave/effects/registry.py](cleave/effects/registry.py) for that stem; visible only when both the track block and `TrackBlock.effects_expanded` are expanded. @@ -44,7 +44,7 @@ Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_ov The pinned top block before a visual gap (`header_gap` in `draw()`): `header_row_count(state)` rows (3 when settings collapsed, 4 when expanded). - Row kinds: `RowKind.SETTINGS_HEADER`, optional `RowKind.SETTINGS_RENDER_MODE` when `session.settings.expanded`, `RowKind.CONFIG_HEADER` (active config path; Enter saves), `RowKind.TRANSPORT`. -- Layout order in `build_row_layout`: settings header, optional render-mode sub-row, config header, transport. +- Layout order in `RowLayout.build`: settings header, optional render-mode sub-row, config header, transport. - **Settings** header: label `Settings` in `ACTION`, expand arrow in value color; Left/Right toggles `session.settings.expanded` (UI-only). Sub-row when expanded: `└─ render mode: {mode}` cycles `visualizer.render_mode` in config (`full-quality`, `balanced`, `performance`; default `balanced`). - Enter on `CONFIG_HEADER` opens an `OVERWRITE` / `SAVE AS NEW` centered modal when overwrite is allowed (`allow_overwrite`; false for repo-root `cleave-viz.yaml`). When overwrite is not allowed, a `SAVE AS NEW` / `CANCEL` modal appears (Enter confirms the focused option, Esc dismisses). Overwrite still uses the yes/no centered modal. @@ -70,7 +70,7 @@ Colors live in [cleave/viz/theme.py](cleave/viz/theme.py). Label truncation uses **Expand arrows** (`▶` / `▼`): value role, not label. Drawn in the row value color (typically `VALUE`; follows focus, disabled, and locked state like other values). -**Visibility eye** (track, render overlay, render post-FX, and render timeline headers): `VALUE` when enabled, `DISABLED` when off. When soloed (`solo_slot` for main-panel layer slots via `TuningViewState.solo_slot`, `render_overlay_solo` / `render_post_fx_solo` for render blocks; neither persisted), the eye stays white on `SOLO_BG`. **Shift + Right** enters solo; **Shift + Left** exits solo for the focused header. **Render: TIMELINE** header has no solo; the timeline strip uses **Shift+Enter** on the focused row for override (`override_stems` on `TimelineRuntime`; left eye `OVERRIDE_BG`). Timeline strip eyes use the same icon helper ([render_visibility_icon](cleave/viz/overlay.py)). +**Visibility eye** (track, render overlay, render post-FX, and render timeline headers): `VALUE` when enabled, `DISABLED` when off. When soloed (`solo_slot` for main-panel layer slots via `TuningViewState.solo_slot`, `render_overlay_solo` / `render_post_fx_solo` for render blocks; neither persisted), the eye stays white on `SOLO_BG`. **Shift + Right** enters solo; **Shift + Left** exits solo for the focused header. **Render: TIMELINE** header has no solo; the timeline strip uses **Shift+Enter** on the focused row for override (`override_stems` on `TimelineRuntime`; left eye `OVERRIDE_BG`). Timeline strip eyes use the same icon helper ([render_visibility_icon](cleave/viz/tuning_panel_draw.py)). ### Accent colors (not label/value roles) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 3e82fc9..b4b0ee4 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -5,7 +5,7 @@ alwaysApply: true # Cleave project context -- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), overlay draw in [cleave/viz/overlay.py](cleave/viz/overlay.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). +- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); layout in [cleave/viz/row_layout.py](cleave/viz/row_layout.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). - Must-do list: [docs/todos.md](docs/todos.md). Aspirational: [docs/roadmap.md](docs/roadmap.md). - See [README.md](README.md) for usage. - Project layout: `projects//` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json`, `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the visualizer reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point). diff --git a/cleave/viz/app.py b/cleave/viz/app.py index 238277e..4cd0991 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -29,7 +29,7 @@ from cleave.viz.overlay_draw import OverlayDrawer from cleave.viz.loading import draw_loading_screen from cleave.viz.help_overlay import HelpOverlay -from cleave.viz.overlay import TuningOverlay +from cleave.viz.tuning_panel_draw import TuningOverlay from cleave.viz.timeline_controls import TimelineControls from cleave.viz.timeline_overlay import TimelineOverlay from cleave.viz.playback import PlaybackState, current_sec, init_playback diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 91b97f7..4a0a22f 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -37,12 +37,11 @@ row_behavior, row_triggers_layer_delete, ) -from cleave.viz.overlay import TuningViewState from cleave.viz.session import TuningSession +from cleave.viz.tuning_view_state import TuningViewState, TuningViewStateBuilder if TYPE_CHECKING: from cleave.viz.wiring import LayerManager -from cleave.viz.tuning_view_state import TuningViewStateBuilder TOAST_DURATION_SEC = 5.0 SEEK_SHORT = 10 diff --git a/cleave/viz/focus_context.py b/cleave/viz/focus_context.py index c0aefed..130c889 100644 --- a/cleave/viz/focus_context.py +++ b/cleave/viz/focus_context.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from cleave.viz.focus_nav import FocusCursor -from cleave.viz.overlay import TuningViewState +from cleave.viz.tuning_view_state import TuningViewState @dataclass(frozen=True) diff --git a/cleave/viz/focus_nav.py b/cleave/viz/focus_nav.py index 009c981..651c9d3 100644 --- a/cleave/viz/focus_nav.py +++ b/cleave/viz/focus_nav.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from cleave.viz.overlay import TuningViewState +from cleave.viz.tuning_view_state import TuningViewState from cleave.viz.row_semantics import RowDescriptor, RowKind diff --git a/cleave/viz/help_overlay.py b/cleave/viz/help_overlay.py index a79fbcf..74fc431 100644 --- a/cleave/viz/help_overlay.py +++ b/cleave/viz/help_overlay.py @@ -7,7 +7,7 @@ import pygame from cleave.viz.row_semantics import RowAffordance, RowKind, row_behavior -from cleave.viz.overlay import clip_rect_to_surface +from cleave.viz.tuning_panel_draw import clip_rect_to_surface from cleave.viz.theme import ( BACKGROUND, BACKGROUND_ALPHA, diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index ebb55ab..a8211e8 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -8,7 +8,8 @@ from cleave.viz import modal_overlay from cleave.viz.help_overlay import HelpOverlay from cleave.viz.modal import ModalHost -from cleave.viz.overlay import TuningOverlay, TuningViewState +from cleave.viz.tuning_panel_draw import TuningOverlay +from cleave.viz.tuning_view_state import TuningViewState from cleave.viz.timeline_overlay import TimelineOverlay, TimelineViewState diff --git a/cleave/viz/row_layout.py b/cleave/viz/row_layout.py new file mode 100644 index 0000000..86872f8 --- /dev/null +++ b/cleave/viz/row_layout.py @@ -0,0 +1,241 @@ +"""Row layout and visibility/navigability for the live tuning overlay.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from cleave.effects.registry import effect_roster +from cleave.viz.row_semantics import ( + RENDER_OVERLAY_BODY_NESTED_KINDS, + RENDER_OVERLAY_SUB_ROW_KINDS, + RENDER_OVERLAY_TITLE_NESTED_KINDS, + RENDER_POST_FX_SUB_ROW_KINDS, + SETTINGS_SUB_ROW_KINDS, + RowDescriptor, + RowKind, + TRACK_EFFECT_SUB_ROW_KINDS, + TRACK_SUB_ROW_KINDS, + row_behavior, + row_is_pinned, + row_navigable_when_layer_locked, + section_header_descriptor, +) + +if TYPE_CHECKING: + from cleave.viz.tuning_view_state import TuningViewState + + +def _sub_row_expanded(state: TuningViewState, desc: RowDescriptor) -> bool: + kind = desc.kind + if kind in SETTINGS_SUB_ROW_KINDS: + return state.settings.expanded + if kind in RENDER_OVERLAY_SUB_ROW_KINDS: + return state.render_overlay.expanded + if kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: + return state.render_overlay.expanded and state.render_overlay.title_expanded + if kind in RENDER_OVERLAY_BODY_NESTED_KINDS: + return state.render_overlay.expanded and state.render_overlay.body_expanded + if kind in RENDER_POST_FX_SUB_ROW_KINDS: + return state.render_post_fx.expanded + slot = desc.slot + if slot is not None and kind in TRACK_SUB_ROW_KINDS: + block = state.tracks[slot] + if not block.expanded: + return False + if kind in TRACK_EFFECT_SUB_ROW_KINDS: + return block.effects_expanded + return True + + +def row_draw_visible(state: TuningViewState, desc: RowDescriptor) -> bool: + if desc.kind in {RowKind.TIMELINE_LAYER_HINT, RowKind.RENDER_SECTION_GAP}: + return True + return _sub_row_expanded(state, desc) + + +def row_navigable(state: TuningViewState, desc: RowDescriptor) -> bool: + if not row_behavior(desc.kind).navigable: + return False + if not _sub_row_expanded(state, desc): + return False + slot = desc.slot + if slot is not None and desc.kind in TRACK_SUB_ROW_KINDS: + block = state.tracks[slot] + if block.locked and not row_navigable_when_layer_locked(desc.kind): + return False + return True + + +@dataclass(frozen=True) +class RowLayout: + rows: tuple[RowDescriptor, ...] + + @classmethod + def build(cls, state: TuningViewState) -> RowLayout: + row_list: list[RowDescriptor] = [ + RowDescriptor(RowKind.SETTINGS_HEADER), + ] + if state.settings.expanded: + row_list.append(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) + row_list.extend( + [ + RowDescriptor(RowKind.CONFIG_HEADER), + RowDescriptor(RowKind.TRANSPORT), + ] + ) + for slot in state.layer_z_order: + row_list.append(RowDescriptor(RowKind.TRACK_HEADER, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET_DIR, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_PRESET, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_STEM, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_BLEND, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_OPACITY, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_BEAT, slot=slot)) + row_list.append(RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=slot)) + block = state.tracks[slot] + if block.effects_expanded: + for effect_def in effect_roster(block.stem): + row_list.append( + RowDescriptor( + RowKind.TRACK_EFFECT, + slot=slot, + effect_id=effect_def.effect_id, + driver_slug=effect_def.driver_slug, + ) + ) + if block.expanded: + row_list.append( + RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot) + ) + row_list.append(RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)) + if state.render_timeline.enabled: + row_list.append(RowDescriptor(RowKind.TIMELINE_LAYER_HINT)) + row_list.append(RowDescriptor(RowKind.RENDER_SECTION_GAP)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_HEADER)) + if state.render_overlay.expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_POSITION)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BORDER_WIDTH)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_START_DELAY)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_DISPLAY_TIME)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER)) + if state.render_overlay.title_expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE)) + row_list.append( + RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) + ) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER)) + if state.render_overlay.body_expanded: + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT)) + row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE)) + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_HEADER)) + if state.render_post_fx.expanded: + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN)) + row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT)) + row_list.append(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) + return cls(tuple(row_list)) + + def __len__(self) -> int: + return len(self.rows) + + def count(self) -> int: + return len(self.rows) + + def descriptor(self, index: int) -> RowDescriptor: + if index < 0 or index >= len(self.rows): + raise IndexError(index) + return self.rows[index] + + def kind(self, index: int) -> RowKind: + return self.descriptor(index).kind + + def slot(self, index: int) -> str | None: + return self.descriptor(index).slot + + def find( + self, + slot: str, + kind: RowKind, + *, + effect_id: str | None = None, + driver_slug: str | None = None, + ) -> int: + for index, desc in enumerate(self.rows): + if desc.kind != kind or desc.slot != slot: + continue + if kind == RowKind.TRACK_EFFECT: + if desc.effect_id != effect_id or desc.driver_slug != driver_slug: + continue + return index + raise ValueError(f"no row for slot={slot!r} kind={kind!r}") + + def find_by_kind(self, kind: RowKind) -> int: + for index, desc in enumerate(self.rows): + if desc.kind == kind: + return index + raise ValueError(f"no row for kind={kind!r}") + + def find_descriptor(self, desc: RowDescriptor) -> int: + for index, row in enumerate(self.rows): + if row == desc: + return index + raise ValueError(f"descriptor not in layout: {desc!r}") + + def contains_descriptor(self, desc: RowDescriptor) -> bool: + return desc in self.rows + + def navigable_descriptors(self, state: TuningViewState) -> list[RowDescriptor]: + return [self.descriptor(index) for index in self.navigable_indices(state)] + + def resolve_navigable( + self, desc: RowDescriptor, state: TuningViewState + ) -> RowDescriptor: + navigable = self.navigable_descriptors(state) + if desc in navigable: + return desc + header = section_header_descriptor(desc) + if header in navigable: + return header + return RowDescriptor(RowKind.TRANSPORT) + + def header_row_count(self) -> int: + count = 0 + for row in self.rows: + if row_is_pinned(row.kind): + count += 1 + else: + break + return count + + def track_row_count(self) -> int: + """Count of scrollable content rows (all rows except pinned header rows).""" + return sum(1 for row in self.rows if not row_is_pinned(row.kind)) + + def sub_row_visible(self, state: TuningViewState, index: int) -> bool: + return row_draw_visible(state, self.descriptor(index)) + + def visible_indices(self, state: TuningViewState) -> list[int]: + """Row indices drawn in the panel (sub-rows hidden when collapsed).""" + return [ + index + for index in range(len(self)) + if row_draw_visible(state, self.descriptor(index)) + ] + + def navigable_indices(self, state: TuningViewState) -> list[int]: + """Row indices reachable via Up/Down (sub-rows skipped when collapsed).""" + return [ + index + for index in range(len(self)) + if row_navigable(state, self.descriptor(index)) + ] + + def quick_nav_indices(self) -> list[int]: + """Row indices for Ctrl+Up/Down: layer headers and transport only.""" + return [ + index + for index in range(len(self)) + if row_behavior(self.kind(index)).quick_nav_target + ] diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 2878b60..b8c83a3 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -66,6 +66,7 @@ class RowBehavior: affordance: RowAffordance help_title: str = "" navigable: bool = True + quick_nav_target: bool = False is_header: bool = False is_sub_header: bool = False is_pinned: bool = False @@ -83,6 +84,7 @@ class RowBehavior: RowAffordance.SEEK, is_header=True, repeatable=True, + quick_nav_target=True, ), RowKind.CONFIG_HEADER: RowBehavior( RowAffordance.ACTION, @@ -94,6 +96,7 @@ class RowBehavior: can_enter_move_mode=True, can_solo=True, can_enable_disable=True, + quick_nav_target=True, ), RowKind.TRACK_PRESET_DIR: RowBehavior( RowAffordance.PATH_DIR, @@ -164,6 +167,7 @@ class RowBehavior: can_enable_disable=True, can_solo=True, help_title="Render", + quick_nav_target=True, ), RowKind.RENDER_OVERLAY_POSITION: RowBehavior( RowAffordance.VALUE_STEP, @@ -232,6 +236,7 @@ class RowBehavior: can_enable_disable=True, can_solo=True, help_title="Render", + quick_nav_target=True, ), RowKind.RENDER_POST_FX_FADE_IN: RowBehavior( RowAffordance.VALUE_STEP, @@ -248,6 +253,7 @@ class RowBehavior: can_enable_disable=True, can_solo=False, help_title="Render", + quick_nav_target=True, ), RowKind.SETTINGS_HEADER: RowBehavior( RowAffordance.EXPAND, diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index b5fda4d..6b5c0d2 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -9,7 +9,7 @@ from cleave.extract import StemSource from cleave.timeline import TimelineCue, layer_visible_at, stem_abbreviation from cleave.viz.material_icons import visibility_icon_slot_width -from cleave.viz.overlay import clip_rect_to_surface, render_visibility_icon +from cleave.viz.tuning_panel_draw import clip_rect_to_surface, render_visibility_icon from cleave.viz.playback import format_mmss from cleave.viz.theme import ( ARMED_BG, diff --git a/cleave/viz/overlay.py b/cleave/viz/tuning_panel_draw.py similarity index 77% rename from cleave/viz/overlay.py rename to cleave/viz/tuning_panel_draw.py index c864c3b..3858781 100644 --- a/cleave/viz/overlay.py +++ b/cleave/viz/tuning_panel_draw.py @@ -1,4 +1,4 @@ -"""Live tuning tree overlay for the Cleave visualizer. +"""Pygame draw path for the live tuning tree panel. Row typography: LABEL prefixes, VALUE defaults, DISABLED/LOCKED state overrides. See cleave/viz/theme.py and .cursor/rules/live-tuning-ui.mdc. @@ -6,21 +6,12 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - from cleave.viz.focus_nav import FocusCursor +from dataclasses import dataclass +from typing import Literal import pygame -from cleave.config import RenderOverlayPosition -from cleave.config_schema import ( - default_render_overlay_runtime_values, - default_render_post_fx_runtime_values, -) -from cleave.effects.registry import effect_roster -from cleave.extract import StemSource, stem_control_label, stem_overlay_header +from cleave.extract import stem_control_label, stem_overlay_header from cleave.viz.row_semantics import ( LABELED_SUB_ROW_KINDS, RENDER_OVERLAY_ALL_SUB_ROW_KINDS, @@ -28,15 +19,9 @@ RENDER_OVERLAY_SUB_ROW_KINDS, RENDER_OVERLAY_TITLE_NESTED_KINDS, RENDER_POST_FX_SUB_ROW_KINDS, - SETTINGS_SUB_ROW_KINDS, - RowDescriptor, RowKind, - TRACK_EFFECT_SUB_ROW_KINDS, - TRACK_SUB_ROW_KINDS, row_blocked_by_layer_lock, row_is_pinned, - row_navigable_when_layer_locked, - section_header_descriptor, ) from cleave.viz.fonts import render_overlay_font_display from cleave.viz.text_fit import ( @@ -90,6 +75,7 @@ VALUE, tuning_ui_metrics, ) +from cleave.viz.tuning_view_state import TrackBlock, TuningViewState from cleave.viz.ui_tint import blit_tint Anchor = Literal["topleft", "bottomleft"] @@ -100,379 +86,6 @@ ROW_ICON_SUFFIX_GAP = _tuning_ui.row_icon_suffix_gap -@dataclass -class TrackBlock: - stem: StemSource - preset_dir_label: str - preset_label: str - blend_mode: str - opacity_pct: int - beat_sensitivity: float - effects: dict[str, dict[str, int]] - effects_expanded: bool = False - enabled: bool = True - visible: bool = True - expanded: bool = False - locked: bool = False - preset_empty: bool = False - - -_RO_OVERLAY_DEFAULTS = default_render_overlay_runtime_values() -_RO_POST_FX_DEFAULTS = default_render_post_fx_runtime_values() - - -@dataclass -class RenderOverlayBlock: - enabled: bool = _RO_OVERLAY_DEFAULTS["enabled"] - expanded: bool = _RO_OVERLAY_DEFAULTS["expanded"] - position: RenderOverlayPosition = _RO_OVERLAY_DEFAULTS["position"] - title_expanded: bool = _RO_OVERLAY_DEFAULTS["title_expanded"] - body_expanded: bool = _RO_OVERLAY_DEFAULTS["body_expanded"] - title_font_size: int = _RO_OVERLAY_DEFAULTS["title_font_size"] - title_font: str = _RO_OVERLAY_DEFAULTS["title_font"] - title_margin_bottom: int = _RO_OVERLAY_DEFAULTS["title_margin_bottom"] - body_font_size: int = _RO_OVERLAY_DEFAULTS["body_font_size"] - body_font: str = _RO_OVERLAY_DEFAULTS["body_font"] - opacity_pct: int = _RO_OVERLAY_DEFAULTS["opacity_pct"] - border_width: int = _RO_OVERLAY_DEFAULTS["border_width"] - start_delay: float = _RO_OVERLAY_DEFAULTS["start_delay"] - display_time: float = _RO_OVERLAY_DEFAULTS["display_time"] - solo: bool = False - - -@dataclass -class RenderPostFxBlock: - enabled: bool = _RO_POST_FX_DEFAULTS["enabled"] - expanded: bool = _RO_POST_FX_DEFAULTS["expanded"] - fade_in: float = _RO_POST_FX_DEFAULTS["fade_in"] - fade_out: float = _RO_POST_FX_DEFAULTS["fade_out"] - solo: bool = False - - -@dataclass -class RenderTimelineBlock: - enabled: bool = False - expanded: bool = False - - -@dataclass -class SettingsBlock: - expanded: bool = False - render_mode: str = "balanced" - - -@dataclass(frozen=True) -class RowLayout: - rows: tuple[RowDescriptor, ...] - - @classmethod - def build(cls, state: TuningViewState) -> RowLayout: - row_list: list[RowDescriptor] = [ - RowDescriptor(RowKind.SETTINGS_HEADER), - ] - if state.settings.expanded: - row_list.append(RowDescriptor(RowKind.SETTINGS_RENDER_MODE)) - row_list.extend( - [ - RowDescriptor(RowKind.CONFIG_HEADER), - RowDescriptor(RowKind.TRANSPORT), - ] - ) - for slot in state.layer_z_order: - row_list.append(RowDescriptor(RowKind.TRACK_HEADER, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_PRESET_DIR, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_PRESET, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_STEM, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_BLEND, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_OPACITY, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_BEAT, slot=slot)) - row_list.append(RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=slot)) - block = state.tracks[slot] - if block.effects_expanded: - for effect_def in effect_roster(block.stem): - row_list.append( - RowDescriptor( - RowKind.TRACK_EFFECT, - slot=slot, - effect_id=effect_def.effect_id, - driver_slug=effect_def.driver_slug, - ) - ) - if block.expanded: - row_list.append( - RowDescriptor(RowKind.LAYER_MANAGEMENT_DELETE, slot=slot) - ) - row_list.append(RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD)) - if state.render_timeline.enabled: - row_list.append(RowDescriptor(RowKind.TIMELINE_LAYER_HINT)) - row_list.append(RowDescriptor(RowKind.RENDER_SECTION_GAP)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_HEADER)) - if state.render_overlay.expanded: - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_POSITION)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_OPACITY)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BORDER_WIDTH)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_START_DELAY)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_DISPLAY_TIME)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_HEADER)) - if state.render_overlay.title_expanded: - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE)) - row_list.append( - RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) - ) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_HEADER)) - if state.render_overlay.body_expanded: - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT)) - row_list.append(RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE)) - row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_HEADER)) - if state.render_post_fx.expanded: - row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_IN)) - row_list.append(RowDescriptor(RowKind.RENDER_POST_FX_FADE_OUT)) - row_list.append(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)) - return cls(tuple(row_list)) - - def __len__(self) -> int: - return len(self.rows) - - def count(self) -> int: - return len(self.rows) - - def descriptor(self, index: int) -> RowDescriptor: - if index < 0 or index >= len(self.rows): - raise IndexError(index) - return self.rows[index] - - def kind(self, index: int) -> RowKind: - return self.descriptor(index).kind - - def slot(self, index: int) -> str | None: - return self.descriptor(index).slot - - def find( - self, - slot: str, - kind: RowKind, - *, - effect_id: str | None = None, - driver_slug: str | None = None, - ) -> int: - for index, desc in enumerate(self.rows): - if desc.kind != kind or desc.slot != slot: - continue - if kind == RowKind.TRACK_EFFECT: - if desc.effect_id != effect_id or desc.driver_slug != driver_slug: - continue - return index - raise ValueError(f"no row for slot={slot!r} kind={kind!r}") - - def find_by_kind(self, kind: RowKind) -> int: - for index, desc in enumerate(self.rows): - if desc.kind == kind: - return index - raise ValueError(f"no row for kind={kind!r}") - - def find_descriptor(self, desc: RowDescriptor) -> int: - for index, row in enumerate(self.rows): - if row == desc: - return index - raise ValueError(f"descriptor not in layout: {desc!r}") - - def contains_descriptor(self, desc: RowDescriptor) -> bool: - return desc in self.rows - - def navigable_descriptors(self, state: TuningViewState) -> list[RowDescriptor]: - return [self.descriptor(index) for index in self.navigable_indices(state)] - - def resolve_navigable( - self, desc: RowDescriptor, state: TuningViewState - ) -> RowDescriptor: - navigable = self.navigable_descriptors(state) - if desc in navigable: - return desc - header = section_header_descriptor(desc) - if header in navigable: - return header - return RowDescriptor(RowKind.TRANSPORT) - - def header_row_count(self) -> int: - count = 0 - for row in self.rows: - if row_is_pinned(row.kind): - count += 1 - else: - break - return count - - def track_row_count(self) -> int: - """Count of scrollable content rows (all rows except pinned header rows).""" - return sum(1 for row in self.rows if not row_is_pinned(row.kind)) - - def sub_row_visible(self, state: TuningViewState, index: int) -> bool: - desc = self.descriptor(index) - if desc.kind in {RowKind.RENDER_SECTION_GAP, RowKind.TIMELINE_LAYER_HINT}: - return True - if desc.kind in SETTINGS_SUB_ROW_KINDS: - return state.settings.expanded - if desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: - return state.render_overlay.expanded - if desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: - return state.render_overlay.expanded and state.render_overlay.title_expanded - if desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: - return state.render_overlay.expanded and state.render_overlay.body_expanded - if desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: - return state.render_post_fx.expanded - slot = desc.slot - if slot is None or desc.kind not in TRACK_SUB_ROW_KINDS: - return True - block = state.tracks[slot] - if not block.expanded: - return False - if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS: - return block.effects_expanded - return True - - def visible_indices(self, state: TuningViewState) -> list[int]: - """Row indices drawn in the panel (sub-rows hidden when collapsed).""" - return [ - index - for index in range(len(self)) - if self.sub_row_visible(state, index) - ] - - def navigable_indices(self, state: TuningViewState) -> list[int]: - """Row indices reachable via Up/Down (sub-rows skipped when collapsed).""" - indices: list[int] = [] - for index in range(len(self)): - desc = self.descriptor(index) - if desc.kind in { - RowKind.RENDER_SECTION_GAP, - RowKind.TIMELINE_LAYER_HINT, - }: - continue - if desc.kind in SETTINGS_SUB_ROW_KINDS: - if not state.settings.expanded: - continue - elif desc.kind in RENDER_OVERLAY_SUB_ROW_KINDS: - if not state.render_overlay.expanded: - continue - elif desc.kind in RENDER_OVERLAY_TITLE_NESTED_KINDS: - if not state.render_overlay.expanded or not state.render_overlay.title_expanded: - continue - elif desc.kind in RENDER_OVERLAY_BODY_NESTED_KINDS: - if not state.render_overlay.expanded or not state.render_overlay.body_expanded: - continue - elif desc.kind in RENDER_POST_FX_SUB_ROW_KINDS: - if not state.render_post_fx.expanded: - continue - elif desc.kind in TRACK_SUB_ROW_KINDS: - slot = desc.slot - assert slot is not None - block = state.tracks[slot] - if not block.expanded: - continue - if block.locked and not row_navigable_when_layer_locked(desc.kind): - continue - if desc.kind in TRACK_EFFECT_SUB_ROW_KINDS and not block.effects_expanded: - continue - indices.append(index) - return indices - - def quick_nav_indices(self) -> list[int]: - """Row indices for Ctrl+Up/Down: layer headers and transport only.""" - indices: list[int] = [] - for index in range(len(self)): - kind = self.kind(index) - if kind in ( - RowKind.TRACK_HEADER, - RowKind.RENDER_OVERLAY_HEADER, - RowKind.RENDER_POST_FX_HEADER, - RowKind.RENDER_TIMELINE_HEADER, - RowKind.TRANSPORT, - ): - indices.append(index) - return indices - - -@dataclass -class TuningViewState: - layer_z_order: tuple[str, ...] - tracks: dict[str, TrackBlock] - paused: bool - position_sec: float - focus_cursor: FocusCursor - move_mode_slot: str | None - toast_message: str | None - toast_remaining_sec: float - allow_overwrite: bool = True - active_config_label: str = "cleave-viz.yaml" - config_dirty: bool = False - solo_slot: str | None = None - solo_active: bool = False - render_overlay: RenderOverlayBlock = field(default_factory=RenderOverlayBlock) - render_post_fx: RenderPostFxBlock = field( - default_factory=RenderPostFxBlock - ) - render_timeline: RenderTimelineBlock = field( - default_factory=RenderTimelineBlock - ) - settings: SettingsBlock = field(default_factory=SettingsBlock) - timeline_recording: bool = False - timeline_override_active: bool = False - help_visible: bool = False - fps: float | None = None - layout: RowLayout = field(init=False, repr=False) - - def __post_init__(self) -> None: - object.__setattr__(self, "layout", RowLayout.build(self)) - - @property - def focus_descriptor(self) -> RowDescriptor: - from cleave.viz.focus_nav import cursor_main_descriptor - - return self.layout.resolve_navigable( - cursor_main_descriptor(self.focus_cursor), self - ) - - @focus_descriptor.setter - def focus_descriptor(self, descriptor: RowDescriptor) -> None: - from cleave.viz.focus_nav import MainFocus - - object.__setattr__(self, "focus_cursor", MainFocus(descriptor)) - - @property - def timeline_submenu_focused(self) -> bool: - from cleave.viz.focus_nav import cursor_timeline_submenu_focused - - return cursor_timeline_submenu_focused(self.focus_cursor) - - @timeline_submenu_focused.setter - def timeline_submenu_focused(self, value: bool) -> None: - from cleave.viz.focus_nav import ( - MainFocus, - TimelineFocus, - cursor_timeline_row, - ) - - if value: - row = ( - cursor_timeline_row(self.focus_cursor) - if isinstance(self.focus_cursor, TimelineFocus) - else 0 - ) - object.__setattr__(self, "focus_cursor", TimelineFocus(row)) - elif isinstance(self.focus_cursor, TimelineFocus): - object.__setattr__( - self, - "focus_cursor", - MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)), - ) - - @property - def focus_index(self) -> int: - resolved = self.layout.resolve_navigable(self.focus_descriptor, self) - return self.layout.find_descriptor(resolved) - - def track_sub_rows_visible(state: TuningViewState, slot: str) -> bool: return state.tracks[slot].expanded diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 8996515..b9b2384 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -4,21 +4,167 @@ import time from collections.abc import Callable +from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from cleave.config import RenderOverlayPosition +from cleave.config_schema import ( + default_render_overlay_runtime_values, + default_render_post_fx_runtime_values, +) +from cleave.extract import StemSource from cleave.preset_playlist import directory_display, preset_filename_display from cleave.viz.config_save import ConfigSaveController -from cleave.viz.focus_nav import FocusCursor -from cleave.viz.overlay import ( - RenderOverlayBlock, - RenderPostFxBlock, - RenderTimelineBlock, - SettingsBlock, - TrackBlock, - TuningViewState, -) from cleave.viz.playback import PlaybackState, current_sec +from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import TuningSession, config_path_display +if TYPE_CHECKING: + from cleave.viz.focus_nav import FocusCursor + from cleave.viz.row_layout import RowLayout + +_RO_OVERLAY_DEFAULTS = default_render_overlay_runtime_values() +_RO_POST_FX_DEFAULTS = default_render_post_fx_runtime_values() + + +@dataclass +class TrackBlock: + stem: StemSource + preset_dir_label: str + preset_label: str + blend_mode: str + opacity_pct: int + beat_sensitivity: float + effects: dict[str, dict[str, int]] + effects_expanded: bool = False + enabled: bool = True + visible: bool = True + expanded: bool = False + locked: bool = False + preset_empty: bool = False + + +@dataclass +class RenderOverlayBlock: + enabled: bool = _RO_OVERLAY_DEFAULTS["enabled"] + expanded: bool = _RO_OVERLAY_DEFAULTS["expanded"] + position: RenderOverlayPosition = _RO_OVERLAY_DEFAULTS["position"] + title_expanded: bool = _RO_OVERLAY_DEFAULTS["title_expanded"] + body_expanded: bool = _RO_OVERLAY_DEFAULTS["body_expanded"] + title_font_size: int = _RO_OVERLAY_DEFAULTS["title_font_size"] + title_font: str = _RO_OVERLAY_DEFAULTS["title_font"] + title_margin_bottom: int = _RO_OVERLAY_DEFAULTS["title_margin_bottom"] + body_font_size: int = _RO_OVERLAY_DEFAULTS["body_font_size"] + body_font: str = _RO_OVERLAY_DEFAULTS["body_font"] + opacity_pct: int = _RO_OVERLAY_DEFAULTS["opacity_pct"] + border_width: int = _RO_OVERLAY_DEFAULTS["border_width"] + start_delay: float = _RO_OVERLAY_DEFAULTS["start_delay"] + display_time: float = _RO_OVERLAY_DEFAULTS["display_time"] + solo: bool = False + + +@dataclass +class RenderPostFxBlock: + enabled: bool = _RO_POST_FX_DEFAULTS["enabled"] + expanded: bool = _RO_POST_FX_DEFAULTS["expanded"] + fade_in: float = _RO_POST_FX_DEFAULTS["fade_in"] + fade_out: float = _RO_POST_FX_DEFAULTS["fade_out"] + solo: bool = False + + +@dataclass +class RenderTimelineBlock: + enabled: bool = False + expanded: bool = False + + +@dataclass +class SettingsBlock: + expanded: bool = False + render_mode: str = "balanced" + + +@dataclass +class TuningViewState: + layer_z_order: tuple[str, ...] + tracks: dict[str, TrackBlock] + paused: bool + position_sec: float + focus_cursor: FocusCursor + move_mode_slot: str | None + toast_message: str | None + toast_remaining_sec: float + allow_overwrite: bool = True + active_config_label: str = "cleave-viz.yaml" + config_dirty: bool = False + solo_slot: str | None = None + solo_active: bool = False + render_overlay: RenderOverlayBlock = field(default_factory=RenderOverlayBlock) + render_post_fx: RenderPostFxBlock = field( + default_factory=RenderPostFxBlock + ) + render_timeline: RenderTimelineBlock = field( + default_factory=RenderTimelineBlock + ) + settings: SettingsBlock = field(default_factory=SettingsBlock) + timeline_recording: bool = False + timeline_override_active: bool = False + help_visible: bool = False + fps: float | None = None + layout: RowLayout = field(init=False, repr=False) + + def __post_init__(self) -> None: + from cleave.viz.row_layout import RowLayout + + object.__setattr__(self, "layout", RowLayout.build(self)) + + @property + def focus_descriptor(self) -> RowDescriptor: + from cleave.viz.focus_nav import cursor_main_descriptor + + return self.layout.resolve_navigable( + cursor_main_descriptor(self.focus_cursor), self + ) + + @focus_descriptor.setter + def focus_descriptor(self, descriptor: RowDescriptor) -> None: + from cleave.viz.focus_nav import MainFocus + + object.__setattr__(self, "focus_cursor", MainFocus(descriptor)) + + @property + def timeline_submenu_focused(self) -> bool: + from cleave.viz.focus_nav import cursor_timeline_submenu_focused + + return cursor_timeline_submenu_focused(self.focus_cursor) + + @timeline_submenu_focused.setter + def timeline_submenu_focused(self, value: bool) -> None: + from cleave.viz.focus_nav import ( + MainFocus, + TimelineFocus, + cursor_timeline_row, + ) + + if value: + row = ( + cursor_timeline_row(self.focus_cursor) + if isinstance(self.focus_cursor, TimelineFocus) + else 0 + ) + object.__setattr__(self, "focus_cursor", TimelineFocus(row)) + elif isinstance(self.focus_cursor, TimelineFocus): + object.__setattr__( + self, + "focus_cursor", + MainFocus(RowDescriptor(RowKind.RENDER_TIMELINE_HEADER)), + ) + + @property + def focus_index(self) -> int: + resolved = self.layout.resolve_navigable(self.focus_descriptor, self) + return self.layout.find_descriptor(resolved) + class TuningViewStateBuilder: """Build TuningViewState from session and UI state.""" diff --git a/docs/architecture-improvements.md b/docs/architecture-improvements.md index 6a08ead..2f0fa6a 100644 --- a/docs/architecture-improvements.md +++ b/docs/architecture-improvements.md @@ -14,7 +14,7 @@ Phases 3-5 harden the foundation against recurrence. **What to do.** Introduce a `RowLayout` wrapper (a frozen list of `RowDescriptor` plus the helpers that operate on it). `TuningViewState` holds one `RowLayout` instead of allowing callers to rebuild on demand. `build_row_layout` becomes an internal constructor; the public surface is `state.layout`. All helpers (`row_descriptor`, `find_row`, `navigable_row_indices`, etc.) become methods or free functions on `RowLayout`. `TuningViewStateBuilder.build()` calls `build_row_layout` exactly once. -**Scope.** `overlay.py` (layout functions), `tuning_view_state.py` (builder), any caller in `controls.py` and `overlay.py` that currently imports raw layout helpers. No behavior change; it is a mechanical refactor. +**Scope.** [cleave/viz/row_layout.py](cleave/viz/row_layout.py) (layout), [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) (builder), any caller in [cleave/viz/controls.py](cleave/viz/controls.py) that currently imports raw layout helpers. No behavior change; it is a mechanical refactor. --- @@ -26,7 +26,7 @@ Phases 3-5 harden the foundation against recurrence. **What to do.** Remove the `_row_value_color(state, transport_index)` call for FPS. Use a fixed theme constant (e.g. `DISABLED` or a dedicated `FPS_COLOR`). Move `fps` population into `TuningViewStateBuilder.build()`, passing the current fps value in at construction or as a build argument (the same way `paused` is passed today). Remove the `dataclasses.replace` patch in `app.py`. -**Scope.** `overlay.py` (FPS draw path), `tuning_view_state.py`, `app.py` (or wherever `dataclasses.replace` lives). Small and isolated. +**Scope.** [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) (FPS draw path), [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), [cleave/viz/app.py](cleave/viz/app.py). Small and isolated. --- @@ -40,7 +40,7 @@ Phases 3-5 harden the foundation against recurrence. Remove `_restore_focus`, `_refocus_track_header_if_sub_row`, and the index arithmetic in add/delete handlers; they become no-ops because descriptors are stable by construction. -**Scope.** `controls.py` (focus field + all navigation methods), `tuning_view_state.py` (view state carries `focus_descriptor`; `focus_index` becomes a derived property for callers that still need it temporarily), `overlay.py` (`_row_has_tree_focus` resolves descriptor to index via the layout). The helpers in Phase 1 make this straightforward because `RowLayout` already materializes the index→descriptor map. +**Scope.** [cleave/viz/controls.py](cleave/viz/controls.py) (focus field + all navigation methods), [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) (view state carries `focus_descriptor`; `focus_index` becomes a derived property for callers that still need it temporarily), [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) (`_row_has_tree_focus` resolves descriptor to index via the layout). The helpers in Phase 1 make this straightforward because `RowLayout` already materializes the index-to-descriptor map. --- @@ -60,20 +60,24 @@ Navigation produces a new `FocusCursor` from the old one plus a delta. Flatten t Move `FocusCursor` and the combined navigation function into `focus_context.py` (already exists for typed dependency injection) or a new `focus_nav.py`. `_move_focus` in `controls.py` becomes a one-liner delegating to it. -**Scope.** `controls.py` (focus field, `_move_focus`, `_move_quick_focus`, timeline bridge branches), `session.py` (`timeline.submenu_focused` becomes derived), `tuning_view_state.py` (view state carries `FocusCursor`), `timeline_controls.py` (reads `TimelineFocus.row`), `overlay.py` highlight check. +**Scope.** [cleave/viz/controls.py](cleave/viz/controls.py) (focus field, `_move_focus`, `_move_quick_focus`, timeline bridge branches), [cleave/viz/session.py](cleave/viz/session.py) (`timeline.submenu_focused` becomes derived), [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) (view state carries `FocusCursor`), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py) (reads `TimelineFocus.row`), [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) (highlight check). --- -## Phase 5 - Split `overlay.py` into layout/nav and draw modules +## Phase 5 - Split overlay into layout/nav and draw modules -**Why.** `overlay.py` is ~1 800 lines combining: layout construction (`build_row_layout`), navigability rules (`navigable_row_indices`, `quick_nav_row_indices`), visibility rules (`_sub_row_visible`), label and color computation, scroll metrics, and pygame draw calls. Navigability logic duplicates visibility logic (`_sub_row_visible` and `navigable_row_indices` share the same expand/collapse branches and can drift). `RowBehavior.navigable` in `row_semantics.py` is the intended source of truth for navigability but is not consulted by the actual navigation path. Changing navigation requires reading through draw code and vice versa. +**Status:** done. View models in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py); layout and nav in [cleave/viz/row_layout.py](cleave/viz/row_layout.py) with shared `row_draw_visible` / `row_navigable` predicates driven by `RowBehavior.navigable` and `quick_nav_target`; pygame panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); GL upload unchanged in [cleave/viz/overlay_draw.py](cleave/viz/overlay_draw.py). `overlay.py` removed; all call sites import from the new modules. + +**Why.** The former `overlay.py` (~1 800 lines) combined layout construction, navigability rules, visibility rules, label and color computation, scroll metrics, and pygame draw calls. Navigability logic duplicated visibility logic (`sub_row_visible` and `navigable_indices` shared the same expand/collapse branches and could drift). `RowBehavior.navigable` in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) was the intended source of truth for navigability but was not consulted by the actual navigation path. Changing navigation required reading through draw code and vice versa. **What to do.** Extract three modules: -- `row_layout.py` -- `RowLayout` (from Phase 1), `build_row_layout`, `navigable_row_indices`, `quick_nav_row_indices`, `visible_row_indices`. Navigability derived from `RowBehavior.navigable` and expand/collapsed state, not from hardcoded kind sets. `_sub_row_visible` and `navigable_row_indices` share one visibility predicate. -- `overlay_draw.py` -- pygame draw logic, color computation, scroll, font, glyph calls. Consumes `RowLayout` and `TuningViewState`; imports nothing from `controls.py`. -- `overlay.py` -- thin re-export shim until call sites are updated, then removed. +- [cleave/viz/row_layout.py](cleave/viz/row_layout.py) -- `RowLayout`, `RowLayout.build`, `navigable_indices`, `quick_nav_indices`, `visible_indices`. Navigability derived from `RowBehavior.navigable` and expand/collapsed state, not from hardcoded kind sets. `sub_row_visible` and `navigable_indices` share one visibility predicate (`row_draw_visible` / `_sub_row_expanded`). +- [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py) -- pygame draw logic, color computation, scroll, font, glyph calls. Consumes `RowLayout` and `TuningViewState`; imports nothing from `controls.py`. +- [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py) -- block dataclasses and `TuningViewState` (view model home alongside `TuningViewStateBuilder`). + +GL overlay upload stays in [cleave/viz/overlay_draw.py](cleave/viz/overlay_draw.py) (`OverlayDrawer`); that name was already taken, so pygame draw did not reuse it. -The result: adding a new row kind means updating `row_semantics.py` (descriptor) and `row_layout.py` (layout order and visibility predicate). Draw code is untouched unless the row has novel visual treatment. +The result: adding a new row kind means updating [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) (descriptor) and [cleave/viz/row_layout.py](cleave/viz/row_layout.py) (layout order and visibility predicate). Draw code is untouched unless the row has novel visual treatment. -**Scope.** Large but mechanical. Phases 1-4 should land first: Phase 1 already introduces `RowLayout` as the natural home for the layout module, and Phase 3 makes navigability use descriptors rather than raw index queries, making the split cleaner. +**Scope.** Large but mechanical. Phases 1-4 landed first: Phase 1 introduced `RowLayout` as the natural home for the layout module, and Phase 3 made navigability use descriptors rather than raw index queries, making the split cleaner. diff --git a/tests/cleave/viz/test_app.py b/tests/cleave/viz/test_app.py index 974b066..ab498ff 100644 --- a/tests/cleave/viz/test_app.py +++ b/tests/cleave/viz/test_app.py @@ -22,7 +22,7 @@ from cleave.viz.row_semantics import RowDescriptor, RowKind from cleave.viz.session import LayerRuntime, RenderPostFxRuntime, TuningSession from cleave.viz.modal import ModalHost -from cleave.viz.overlay import TuningOverlay +from cleave.viz.tuning_panel_draw import TuningOverlay from tests.support.compositor_mock import recording_compositor diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index b11de82..9d5a062 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -63,18 +63,17 @@ visibility_icon_prefix_width, ) from cleave.viz.row_semantics import RowDescriptor, RowKind -from cleave.viz.overlay import ( - TrackBlock, - TuningViewState, +from cleave.viz.tuning_panel_draw import ( TREE_INDENT, _row_bg_color, _row_indent, _row_text, _row_value_color, - render_visibility_icon, fit_row_text, + render_visibility_icon, track_header_prefix_width, ) +from cleave.viz.tuning_view_state import TrackBlock, TuningViewState from tests.support.viz import baseline_tuning_ui_metrics @@ -1564,7 +1563,7 @@ def test_key_repeat_armed_while_navigation_key_held() -> None: def test_held_key_repeat_keeps_overlay_visible() -> None: - from cleave.viz.overlay import TuningOverlay + from cleave.viz.tuning_panel_draw import TuningOverlay from cleave.viz.theme import FADE_DURATION_SEC, HOLD_IDLE_SEC controls = _make_controls() diff --git a/tests/cleave/viz/test_focus_nav.py b/tests/cleave/viz/test_focus_nav.py index 2cb5374..49abf42 100644 --- a/tests/cleave/viz/test_focus_nav.py +++ b/tests/cleave/viz/test_focus_nav.py @@ -12,7 +12,7 @@ resolve_cursor, timeline_strip_in_ring, ) -from cleave.viz.overlay import RenderTimelineBlock, TrackBlock, TuningViewState +from cleave.viz.tuning_view_state import RenderTimelineBlock, TrackBlock, TuningViewState from cleave.viz.row_semantics import RowDescriptor, RowKind from tests.cleave.viz.test_controls import ( _desc, diff --git a/tests/cleave/viz/test_input_dispatch.py b/tests/cleave/viz/test_input_dispatch.py index 66a4e95..2c6fec2 100644 --- a/tests/cleave/viz/test_input_dispatch.py +++ b/tests/cleave/viz/test_input_dispatch.py @@ -21,7 +21,7 @@ key_handler_for_runtime, ) from cleave.viz.modal import ModalHost, ModalKind -from cleave.viz.overlay import TuningOverlay +from cleave.viz.tuning_panel_draw import TuningOverlay from cleave.viz.timeline_controls import TimelineControls from tests.support.compositor_mock import recording_compositor from tests.support.viz import keydown, make_playlist, make_test_cfg, stub_playback_state diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index b5443b5..e5189cb 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -11,13 +11,9 @@ from cleave.viz.material_icons import row_icon_prefix_width from cleave.viz.focus_nav import MainFocus from cleave.viz.row_semantics import RowDescriptor, RowKind -from cleave.viz.overlay import ( +from cleave.viz.tuning_panel_draw import ( PanelScrollMetrics, - RenderOverlayBlock, - RenderTimelineBlock, - TrackBlock, TuningOverlay, - TuningViewState, _row_bg_color, _row_text, _row_value_color, @@ -30,6 +26,12 @@ TREE_INDENT, scroll_metrics, ) +from cleave.viz.tuning_view_state import ( + RenderOverlayBlock, + RenderTimelineBlock, + TrackBlock, + TuningViewState, +) from cleave.viz.theme import ( ACTION, BORDER_WIDTH, diff --git a/tests/cleave/viz/test_row_layout_resolution.py b/tests/cleave/viz/test_row_layout_resolution.py index e837773..7516e62 100644 --- a/tests/cleave/viz/test_row_layout_resolution.py +++ b/tests/cleave/viz/test_row_layout_resolution.py @@ -4,7 +4,13 @@ import pytest -from cleave.viz.overlay import RenderOverlayBlock, RenderPostFxBlock, SettingsBlock, TrackBlock, TuningViewState +from cleave.viz.tuning_view_state import ( + RenderOverlayBlock, + RenderPostFxBlock, + SettingsBlock, + TrackBlock, + TuningViewState, +) from cleave.viz.row_semantics import RowDescriptor, RowKind, section_header_descriptor from tests.cleave.viz.test_overlay import _minimal_view_state diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index b8b85cb..9c2d64e 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -9,7 +9,7 @@ from cleave.extract import STEM_NAMES from cleave.timeline import TimelineCue, layer_visible_at, stem_abbreviation from cleave.viz.material_icons import visibility_icon_slot_width -from cleave.viz.overlay import render_visibility_icon +from cleave.viz.tuning_panel_draw import render_visibility_icon from cleave.viz.theme import ( ARMED_BG, DISABLED, From f8f010e8c92840791ddcbb3b99400d94a271aad0 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 23:15:37 +0100 Subject: [PATCH 6/7] Move completed plan to archive --- docs/{ => legacy-plans}/architecture-improvements.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/{ => legacy-plans}/architecture-improvements.md (100%) diff --git a/docs/architecture-improvements.md b/docs/legacy-plans/architecture-improvements.md similarity index 100% rename from docs/architecture-improvements.md rename to docs/legacy-plans/architecture-improvements.md From 92c0c5729982ad82bf23a1cf9316e8f7b121a7cd Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 21 Jun 2026 23:25:16 +0100 Subject: [PATCH 7/7] Fix Ctrl + Up/Down section skip bug --- cleave/viz/row_layout.py | 2 +- cleave/viz/row_semantics.py | 1 + tests/cleave/viz/test_controls.py | 23 ++++++++++++++--------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/cleave/viz/row_layout.py b/cleave/viz/row_layout.py index 86872f8..c65b433 100644 --- a/cleave/viz/row_layout.py +++ b/cleave/viz/row_layout.py @@ -233,7 +233,7 @@ def navigable_indices(self, state: TuningViewState) -> list[int]: ] def quick_nav_indices(self) -> list[int]: - """Row indices for Ctrl+Up/Down: layer headers and transport only.""" + """Row indices for Ctrl+Up/Down: settings, transport, layer, and render headers.""" return [ index for index in range(len(self)) diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index b8c83a3..2a927bb 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -259,6 +259,7 @@ class RowBehavior: RowAffordance.EXPAND, is_header=True, help_title="Settings", + quick_nav_target=True, ), RowKind.SETTINGS_RENDER_MODE: RowBehavior( RowAffordance.VALUE_STEP, diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 9d5a062..1c1c1f2 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1843,10 +1843,11 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: view = controls.build_view_state(paused=False) quick = view.layout.quick_nav_indices() - assert len(quick) == 6 + assert len(quick) == 7 for index in quick: kind = view.layout.kind( index) assert kind in ( + RowKind.SETTINGS_HEADER, RowKind.TRACK_HEADER, RowKind.RENDER_OVERLAY_HEADER, RowKind.RENDER_POST_FX_HEADER, @@ -1854,6 +1855,7 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: RowKind.TRANSPORT, ) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) drums_header = next( i for i in range(len(view.layout)) @@ -1871,6 +1873,7 @@ def test_quick_nav_row_indices_headers_and_transport_only() -> None: i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) assert quick == [ + settings_row, transport_row, drums_header, bass_header, @@ -1901,11 +1904,14 @@ def test_ctrl_quick_nav_cycles_headers_and_transport() -> None: controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) assert controls.focus_descriptor == _desc(view, quick[5]) + controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) + assert controls.focus_descriptor == _desc(view, quick[6]) + controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) assert controls.focus_descriptor == _desc(view, quick[0]) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_descriptor == _desc(view, quick[5]) + assert controls.focus_descriptor == _desc(view, quick[6]) def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: @@ -1918,11 +1924,11 @@ def test_ctrl_quick_nav_from_sub_row_jumps_forward() -> None: controls.focus_descriptor = _desc(view, preset_row) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_descriptor == _desc(view, quick[2]) + assert controls.focus_descriptor == _desc(view, quick[3]) controls.focus_descriptor = _desc(view, preset_row) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_descriptor == _desc(view, quick[1]) + assert controls.focus_descriptor == _desc(view, quick[2]) def test_ctrl_quick_nav_from_config_header_row() -> None: @@ -1933,11 +1939,11 @@ def test_ctrl_quick_nav_from_config_header_row() -> None: controls.focus_descriptor = _desc(view, config_row) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) - assert controls.focus_descriptor == _desc(view, quick[-1]) + assert controls.focus_descriptor == _desc(view, quick[0]) controls.focus_descriptor = _desc(view, config_row) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_descriptor == _desc(view, quick[0]) + assert controls.focus_descriptor == _desc(view, quick[1]) def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: @@ -1957,11 +1963,10 @@ def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: view = controls.build_view_state(paused=False) quick = view.layout.quick_nav_indices() timeline_header = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - transport_row = view.layout.find_by_kind(RowKind.TRANSPORT) + settings_row = view.layout.find_by_kind(RowKind.SETTINGS_HEADER) controls.session.timeline.panel_open = True controls.focus_cursor = TimelineFocus(1) - controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_UP, mod=pygame.KMOD_CTRL)) assert not isinstance(controls.focus_cursor, TimelineFocus) @@ -1970,7 +1975,7 @@ def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: controls.focus_cursor = TimelineFocus(2) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) assert not isinstance(controls.focus_cursor, TimelineFocus) - assert controls.focus_descriptor == _desc(view, transport_row) + assert controls.focus_descriptor == _desc(view, settings_row) assert timeline_header in quick