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 92b6c67..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. @@ -37,14 +37,14 @@ 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 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 d0f5e86..4cd0991 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -29,12 +29,13 @@ 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 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 @@ -328,14 +338,15 @@ 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 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( @@ -350,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, + ), ) @@ -489,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 63aaa34..4a0a22f 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -11,18 +11,24 @@ 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 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, @@ -31,24 +37,11 @@ row_behavior, row_triggers_layer_delete, ) -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 +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 @@ -83,7 +76,9 @@ 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_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 @@ -107,36 +102,15 @@ def __init__( playback, duration_sec, preset_root, - get_focus_index=lambda: self.focus_index, + 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, 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 = find_row_by_kind(view, 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: @@ -236,8 +210,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 = row_kind(view, 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): @@ -249,16 +222,15 @@ 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.focus_descriptor.kind, ), ) 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 = self.focus_descriptor.kind if kind == RowKind.TRACK_PRESET_DIR: - slot = row_slot(view, 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 @@ -268,36 +240,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 = row_kind(view, self.focus_index) + kind = self.focus_descriptor.kind if row_triggers_layer_delete(kind): - slot = row_slot(view, 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 = row_kind(view, self.focus_index) + kind = self.focus_descriptor.kind if kind == RowKind.TRACK_HEADER: - slot = row_slot(view, 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 = row_kind(view, 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 = row_slot(view, 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 = row_slot(view, 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 @@ -309,7 +278,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 = self.focus_descriptor.slot if slot is not None: if ( self.session.layers[slot].locked @@ -347,86 +316,97 @@ 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) + @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 - view = self.build_view_state(paused=self.playback.paused) - self.focus_index = find_row_by_kind(view, 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 = navigable_row_indices(view) - if not navigable: - return - try: - pos = navigable.index(self.focus_index) - except ValueError: - 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) - if delta > 0 and self.focus_index == timeline_header: - tl.submenu_focused = True - tl.focus_row = 0 - return - if delta < 0 and self.focus_index == 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_index = 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 = quick_nav_row_indices(view) + 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 = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - if delta < 0: - self.focus_index = timeline_header - return - current = timeline_header + if 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 @@ -443,19 +423,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 = navigable_row_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: @@ -489,19 +459,23 @@ 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_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) - 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._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._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: @@ -511,7 +485,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 = self.focus_descriptor.slot ctrl = mod_ctrl(mod) forward = key == pygame.K_RIGHT @@ -586,10 +560,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( @@ -774,54 +748,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 find_row(view, 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) - if kind not in REPEAT_ROW_KINDS: - return - if row_slot(view, 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) - 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) - - def _focused_row_descriptor(self, view: TuningViewState) -> RowDescriptor | None: - try: - return row_descriptor(view, 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(build_row_layout(view)): - if row == descriptor: - self.focus_index = index - return - self.focus_index = min(self.focus_index, row_count(view) - 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() @@ -829,7 +760,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: @@ -857,7 +787,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) @@ -887,25 +816,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 = find_row(view, 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) - 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 - ): - 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] @@ -931,20 +842,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_index = self._render_timeline_header_index() + 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_index = self._render_timeline_header_index() + 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 bcf3e9b..130c889 100644 --- a/cleave/viz/focus_context.py +++ b/cleave/viz/focus_context.py @@ -5,12 +5,13 @@ from collections.abc import Callable from dataclasses import dataclass -from cleave.viz.overlay import TuningViewState +from cleave.viz.focus_nav import FocusCursor +from cleave.viz.tuning_view_state import TuningViewState @dataclass(frozen=True) class FocusContext: - get_focus_index: Callable[[], int] - set_focus_index: Callable[[int], 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..651c9d3 --- /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.tuning_view_state 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/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/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_draw.py b/cleave/viz/overlay_draw.py index f6bfe06..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, row_kind +from cleave.viz.tuning_panel_draw import TuningOverlay +from cleave.viz.tuning_view_state import TuningViewState from cleave.viz.timeline_overlay import TimelineOverlay, TimelineViewState @@ -46,7 +47,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.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 250a8de..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 find_row_by_kind -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 find_row_by_kind(view, 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) - - 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) 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 723e387..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 find_row_by_kind, row_kind -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 find_row_by_kind(view, 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: - 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_layout.py b/cleave/viz/row_layout.py new file mode 100644 index 0000000..c65b433 --- /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: settings, transport, layer, and render headers.""" + 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 f0f7157..2a927bb 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,11 +253,13 @@ class RowBehavior: can_enable_disable=True, can_solo=False, help_title="Render", + quick_nav_target=True, ), RowKind.SETTINGS_HEADER: RowBehavior( RowAffordance.EXPAND, is_header=True, help_title="Settings", + quick_nav_target=True, ), RowKind.SETTINGS_RENDER_MODE: RowBehavior( RowAffordance.VALUE_STEP, @@ -364,3 +371,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/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/settings_controls.py b/cleave/viz/settings_controls.py index 8e636d0..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 find_row_by_kind -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 find_row_by_kind(view, 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/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 79% rename from cleave/viz/overlay.py rename to cleave/viz/tuning_panel_draw.py index 2853633..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,18 +6,12 @@ from __future__ import annotations -from dataclasses import dataclass, field +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, @@ -25,14 +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, ) from cleave.viz.fonts import render_overlay_font_display from cleave.viz.text_fit import ( @@ -86,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"] @@ -96,321 +86,14 @@ 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 -class TuningViewState: - layer_z_order: tuple[str, ...] - tracks: dict[str, TrackBlock] - paused: bool - position_sec: float - focus_index: int - 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_submenu_focused: bool = False - timeline_recording: bool = False - timeline_override_active: bool = False - help_visible: bool = False - fps: float | None = None - - -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 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 +105,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 +174,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 +205,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 +248,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 +277,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 +302,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 +343,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 +426,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 +484,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 +496,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 +544,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 +582,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 +596,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 +611,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 +655,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 +734,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 +1027,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 +1072,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 +1090,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 +1233,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 +1437,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/tuning_view_state.py b/cleave/viz/tuning_view_state.py index fbec4c0..b9b2384 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -4,20 +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.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.""" @@ -29,7 +176,7 @@ def __init__( duration_sec: float, preset_root, *, - get_focus_index: Callable[[], int], + get_focus_cursor: Callable[[], FocusCursor], get_move_mode_slot: Callable[[], str | None], config_save: ConfigSaveController, get_toast_message: Callable[[], str | None], @@ -39,7 +186,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_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 @@ -50,6 +197,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) @@ -94,12 +242,12 @@ def build( ro = self.session.render_overlay pp = self.session.render_post_fx tl = self.session.timeline - return TuningViewState( + 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_cursor=self._get_focus_cursor(), move_mode_slot=self._get_move_mode_slot(), toast_message=toast_message, toast_remaining_sec=toast_remaining, @@ -140,8 +288,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, ) + 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/legacy-plans/architecture-improvements.md b/docs/legacy-plans/architecture-improvements.md new file mode 100644 index 0000000..2f0fa6a --- /dev/null +++ b/docs/legacy-plans/architecture-improvements.md @@ -0,0 +1,83 @@ +# 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 + +**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. + +**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. + +--- + +## 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`. + +**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. + +--- + +## 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. + +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.** [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. + +--- + +## Phase 4 - Unified focus model for the timeline bridge + +**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: + +``` +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.** [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 into layout/nav and draw modules + +**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: + +- [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 [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 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 8d430d4..ab498ff 100644 --- a/tests/cleave/viz/test_app.py +++ b/tests/cleave/viz/test_app.py @@ -17,10 +17,12 @@ _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 +from cleave.viz.tuning_panel_draw import TuningOverlay from tests.support.compositor_mock import recording_compositor @@ -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_config_dirty.py b/tests/cleave/viz/test_config_dirty.py index fc2473b..e437a5e 100644 --- a/tests/cleave/viz/test_config_dirty.py +++ b/tests/cleave/viz/test_config_dirty.py @@ -13,35 +13,34 @@ 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 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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)) @@ -49,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 = find_row_by_kind(view, 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 = find_row_by_kind(view, RowKind.RENDER_OVERLAY_POSITION) + controls.focus_descriptor = RowDescriptor(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_descriptor = RowDescriptor(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_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_FONT) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -134,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 = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM - ) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_TITLE_MARGIN_BOTTOM) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -144,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 = find_row_by_kind( - view, RowKind.RENDER_OVERLAY_BODY_FONT_SIZE - ) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_BODY_FONT_SIZE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -154,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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind( - view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, RowKind.SETTINGS_RENDER_MODE) + controls.focus_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -324,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 @@ -336,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 = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT)) @@ -359,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 = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) + controls.focus_descriptor = RowDescriptor(RowKind.RENDER_OVERLAY_HEADER) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_SHIFT)) @@ -369,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 = find_row_by_kind(view, 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 5fdfa52..1c1c1f2 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 @@ -61,29 +62,18 @@ track_header_lock_suffix_width, visibility_icon_prefix_width, ) -from cleave.viz.row_semantics import RowKind -from cleave.viz.overlay import ( - find_row, - find_row_by_kind, - header_row_count, - TrackBlock, - TuningViewState, +from cleave.viz.row_semantics import RowDescriptor, RowKind +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, - navigable_row_indices, - quick_nav_row_indices, - row_count, - row_kind, - row_slot, - row_visible, - visible_row_indices, ) +from cleave.viz.tuning_view_state import TrackBlock, TuningViewState 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_descriptor = RowDescriptor(RowKind.LAYER_MANAGEMENT_ADD) controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -226,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 = find_row( - view, "layer_1", RowKind.LAYER_MANAGEMENT_DELETE - ) + controls.focus_descriptor = view.layout.descriptor(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_descriptor = RowDescriptor(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,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 = find_row( - view, "layer_2", RowKind.LAYER_MANAGEMENT_DELETE - ) + 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)) @@ -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_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)) @@ -310,8 +306,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) @@ -335,7 +330,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" @@ -354,7 +349,16 @@ 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 _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: @@ -373,28 +377,27 @@ 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 + 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 @@ -413,7 +416,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)) @@ -436,24 +439,24 @@ 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.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: @@ -462,42 +465,37 @@ 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 - ) - 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 + i for i in range(len(view.layout)) if view.layout.kind(i) == RowKind.TRANSPORT ) + 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: @@ -505,18 +503,17 @@ 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 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: @@ -524,20 +521,20 @@ 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 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: @@ -545,7 +542,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 @@ -555,19 +552,18 @@ 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 + 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) @@ -581,7 +577,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( @@ -602,10 +598,9 @@ 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 - + controls.focus_descriptor = _desc(view, header_row) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True assert controls.move_mode_slot == "layer_2" @@ -625,10 +620,9 @@ 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 - + 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"] @@ -646,10 +640,9 @@ 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 - + 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"] @@ -664,8 +657,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): @@ -681,9 +673,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: @@ -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: @@ -719,16 +711,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 @@ -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 @@ -774,7 +766,7 @@ def test_preset_row_truncates_long_filenames() -> None: }, paused=False, position_sec=0.0, - focus_index=0, + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -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() @@ -831,13 +823,13 @@ 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 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) @@ -854,12 +846,12 @@ 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) 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 @@ -870,7 +862,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() @@ -898,7 +890,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 @@ -922,14 +914,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)) @@ -944,31 +936,31 @@ 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 + 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: 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: @@ -981,8 +973,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 @@ -1020,8 +1011,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): @@ -1045,8 +1035,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 @@ -1057,7 +1046,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 @@ -1074,7 +1063,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 @@ -1145,7 +1134,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 +1142,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 +1155,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,10 +1177,10 @@ 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 + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_font == "bravo" @@ -1206,10 +1195,10 @@ 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 + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.render_overlay.body_font == "alpha" @@ -1220,10 +1209,10 @@ 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 + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_font_size == 13 @@ -1234,10 +1223,10 @@ 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 + controls.focus_descriptor = _desc(view, margin_row) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.session.render_overlay.title_margin_bottom == 11 @@ -1248,10 +1237,10 @@ 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 + controls.focus_descriptor = _desc(view, font_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) assert controls.session.render_overlay.body_font_size == 17 @@ -1262,12 +1251,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,9 +1269,8 @@ 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) - controls.focus_index = title_header - + title_header = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_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 @@ -1295,12 +1283,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 = find_row_by_kind(view, RowKind.RENDER_OVERLAY_HEADER) - font_row = find_row_by_kind(view, RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) - controls.focus_index = font_row - + font_row = view.layout.find_by_kind(RowKind.RENDER_OVERLAY_TITLE_FONT_SIZE) + 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: @@ -1308,12 +1299,14 @@ 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) - controls.focus_index = font_row - + 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_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: @@ -1369,36 +1362,36 @@ 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) - controls.focus_index = header_row + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + 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)) @@ -1410,75 +1403,75 @@ 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: controls = _make_controls() view = controls.build_view_state(paused=False) - header_row = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + controls.focus_descriptor = _desc(view, header_row) assert controls.session.timeline.enabled is False assert controls.session.timeline.panel_open is False 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 = 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) - controls.focus_index = header_row + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + 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 = 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 + assert not isinstance(controls.focus_cursor, TimelineFocus) 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) - controls.focus_index = header_row + 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.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_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 = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + 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.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 @@ -1489,7 +1482,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 @@ -1508,19 +1501,19 @@ 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) - controls.focus_index = transport - navigable = navigable_row_indices(view) + transport = view.layout.find_by_kind(RowKind.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: @@ -1531,8 +1524,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 @@ -1549,15 +1541,14 @@ 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) - controls.focus_index = transport - + transport = view.layout.find_by_kind(RowKind.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: @@ -1572,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() @@ -1597,17 +1588,16 @@ 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) - controls.focus_index = header_row + 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_index == find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) + assert controls.focus_descriptor == RowDescriptor(RowKind.RENDER_TIMELINE_HEADER) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -1615,13 +1605,13 @@ 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) - controls.focus_index = header_row + 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 @@ -1631,55 +1621,53 @@ 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 = find_row_by_kind(view, 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_index == 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 = find_row_by_kind(view, RowKind.TRANSPORT) - controls.focus_index = transport_row + 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_index == 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: 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) - controls.focus_index = timeline_row + 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_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 not isinstance(controls.focus_cursor, TimelineFocus) + 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 not isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_descriptor == _desc(view, navigable[navigable.index(transport_row) - 1]) def test_render_timeline_disable_closes_panel() -> None: @@ -1687,9 +1675,8 @@ 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) - controls.focus_index = header_row - + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + 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 @@ -1698,12 +1685,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,9 +1724,8 @@ 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) - controls.focus_index = header_row - + header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RIGHT, mod=pygame.KMOD_CTRL)) assert events == [] @@ -1758,31 +1744,31 @@ 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 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_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 not isinstance(controls.focus_cursor, TimelineFocus) + 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 = find_row_by_kind(view, RowKind.RENDER_TIMELINE_HEADER) - controls.focus_index = header_row + 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 @@ -1795,8 +1781,8 @@ 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 controls.focus_index == header_row + assert not isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_descriptor == _desc(view, header_row) def test_t_toast_when_timeline_disabled() -> None: @@ -1813,8 +1799,8 @@ 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) - controls.focus_index = header_row + header_row = view.layout.find_by_kind(RowKind.TRACK_HEADER) + controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_RETURN)) assert controls.move_mode_slot == "layer_1" @@ -1826,9 +1812,9 @@ 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 + controls.focus_descriptor = _desc(view, transport_row) assert controls.playback.paused is False controls.handle_keydown(_keydown(pygame.K_RETURN)) @@ -1842,7 +1828,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_descriptor == RowDescriptor(RowKind.TRANSPORT) controls.handle_keydown(_keydown(pygame.K_SPACE)) assert controls.playback.paused is True @@ -1856,11 +1842,12 @@ 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) - assert len(quick) == 6 + quick = view.layout.quick_nav_indices() + assert len(quick) == 7 for index in quick: - kind = row_kind(view, index) + kind = view.layout.kind( index) assert kind in ( + RowKind.SETTINGS_HEADER, RowKind.TRACK_HEADER, RowKind.RENDER_OVERLAY_HEADER, RowKind.RENDER_POST_FX_HEADER, @@ -1868,27 +1855,25 @@ 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(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 == [ + settings_row, transport_row, drums_header, bass_header, @@ -1901,61 +1886,64 @@ 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_descriptor = _desc(view, quick[0]) + controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) + assert controls.focus_descriptor == _desc(view, quick[1]) - controls.focus_index = 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[2]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[2] + assert controls.focus_descriptor == _desc(view, quick[3]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[3] + assert controls.focus_descriptor == _desc(view, quick[4]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[4] + assert controls.focus_descriptor == _desc(view, quick[5]) controls.handle_keydown(_keydown(pygame.K_DOWN, mod=pygame.KMOD_CTRL)) - assert controls.focus_index == quick[5] + assert controls.focus_descriptor == _desc(view, quick[6]) 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[6]) 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 + 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[3]) - 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[2]) 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 + 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[0]) - 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[1]) def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: @@ -1963,34 +1951,31 @@ 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: 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) + 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 = 1 - controls.focus_index = find_row_by_kind(view, RowKind.TRANSPORT) + controls.focus_cursor = TimelineFocus(1) 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 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 controls.focus_index == transport_row + assert not isinstance(controls.focus_cursor, TimelineFocus) + assert controls.focus_descriptor == _desc(view, settings_row) assert timeline_header in quick @@ -2066,7 +2051,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)) @@ -2079,7 +2064,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" @@ -2093,7 +2078,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" @@ -2107,7 +2092,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)) @@ -2120,7 +2105,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" @@ -2138,7 +2123,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)) @@ -2152,7 +2137,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): @@ -2175,7 +2160,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 == () @@ -2204,7 +2189,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)) @@ -2233,10 +2218,10 @@ 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 + controls.focus_descriptor = _desc(view, header_row) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True view = controls.build_view_state(paused=False) @@ -2260,11 +2245,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_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -2275,7 +2261,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_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, @@ -2323,8 +2309,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 +2327,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 ] @@ -2350,7 +2336,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)) @@ -2368,8 +2354,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 @@ -2386,7 +2372,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)) @@ -2401,8 +2387,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 @@ -2413,20 +2398,20 @@ 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)) 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 +2426,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_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, @@ -2456,7 +2441,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_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, @@ -2471,7 +2456,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)) @@ -2487,15 +2472,15 @@ 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.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: @@ -2507,10 +2492,9 @@ 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 - + 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)) @@ -2530,8 +2514,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 @@ -2540,7 +2523,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 @@ -2614,8 +2597,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"] @@ -2626,32 +2608,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 @@ -2661,10 +2645,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)) @@ -2779,7 +2763,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)) @@ -2794,7 +2778,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 == {} @@ -2805,7 +2789,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)) @@ -2816,7 +2800,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" @@ -2842,13 +2826,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: @@ -2869,65 +2853,67 @@ 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.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) - 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.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) 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_descriptor = RowDescriptor(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_descriptor = RowDescriptor(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 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 = find_row_by_kind(view, 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 = find_row_by_kind(view, 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" @@ -2937,10 +2923,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_descriptor = RowDescriptor(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_descriptor = RowDescriptor(RowKind.SETTINGS_RENDER_MODE) controls.handle_keydown(_keydown(pygame.K_RIGHT)) assert controls.config_dirty @@ -2948,4 +2934,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_descriptor == RowDescriptor(RowKind.TRANSPORT) \ No newline at end of file diff --git a/tests/cleave/viz/test_focus_nav.py b/tests/cleave/viz/test_focus_nav.py new file mode 100644 index 0000000..49abf42 --- /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.tuning_view_state 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..2c6fec2 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, @@ -19,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 @@ -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_layer.py b/tests/cleave/viz/test_layer.py index b3e9e1c..8424cd2 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -16,8 +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.overlay import find_row_by_kind +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 ( @@ -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_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 05e7573..e5189cb 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -9,32 +9,28 @@ 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.overlay import ( +from cleave.viz.focus_nav import MainFocus +from cleave.viz.row_semantics import RowDescriptor, RowKind +from cleave.viz.tuning_panel_draw import ( PanelScrollMetrics, - RenderOverlayBlock, - RenderTimelineBlock, - TrackBlock, TuningOverlay, - TuningViewState, _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.tuning_view_state import ( + RenderOverlayBlock, + RenderTimelineBlock, + TrackBlock, + TuningViewState, ) from cleave.viz.theme import ( ACTION, @@ -75,7 +71,7 @@ def _effects_expanded_view_state() -> TuningViewState: tracks=tracks, paused=False, position_sec=0.0, - focus_index=0, + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -87,7 +83,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 +109,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 +147,9 @@ 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_descriptor = state.layout.descriptor( + state.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) - 1 + ) surface = pygame.Surface((1280, 720), pygame.SRCALPHA) overlay.notify_input() @@ -178,10 +176,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 = find_row_by_kind(state_top, 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) @@ -263,8 +261,29 @@ 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(), + 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 + + +def test_fps_color_ignores_transport_focus() -> None: + pygame.init() + overlay = TuningOverlay() + state = _minimal_view_state( + fps=30.0, + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), + ) + + 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(), @@ -273,7 +292,8 @@ 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 + assert sampled[:3] != HIGHLIGHT def test_panel_content_max_width_reserves_scrollbar() -> None: @@ -351,8 +371,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)"), @@ -422,7 +442,7 @@ def _minimal_view_state(**kwargs: object) -> TuningViewState: }, "paused": False, "position_sec": 0.0, - "focus_index": 0, + "focus_cursor": MainFocus(RowDescriptor(RowKind.TRANSPORT)), "move_mode_slot": None, "toast_message": None, "toast_remaining_sec": 0.0, @@ -445,7 +465,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 +485,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 +506,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 +526,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 +538,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 +560,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 +588,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 +616,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 +636,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 +670,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 +689,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 +708,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 +738,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 +768,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 +789,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 +802,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 +817,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 +839,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: @@ -843,7 +863,7 @@ def test_draw_track_header_with_solo_eye() -> None: }, paused=False, position_sec=0.0, - focus_index=0, + focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, toast_message=None, toast_remaining_sec=0.0, @@ -857,11 +877,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,8 +899,8 @@ def test_disabled_track_focus_uses_muted_highlight() -> None: ) }, ) - header_row = find_row_by_kind(state, RowKind.TRACK_HEADER) - state.focus_index = header_row + header_row = state.layout.find_by_kind( RowKind.TRACK_HEADER) + 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 @@ -892,8 +912,8 @@ 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) - state.focus_index = row + row = state.layout.find_by_kind(row_kind_target) + 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 @@ -902,8 +922,8 @@ 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) - state.focus_index = timeline_row + timeline_row = state.layout.find_by_kind( RowKind.RENDER_TIMELINE_HEADER) + 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 @@ -926,8 +946,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..72971bb 100644 --- a/tests/cleave/viz/test_overlay_targets.py +++ b/tests/cleave/viz/test_overlay_targets.py @@ -2,13 +2,13 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pygame 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 @@ -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] @@ -40,7 +39,8 @@ def test_draw_tuning_overlay_uploads_help_panel(_row_kind: MagicMock) -> 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( compositor, 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..7516e62 --- /dev/null +++ b/tests/cleave/viz/test_row_layout_resolution.py @@ -0,0 +1,153 @@ +"""Tests for RowLayout descriptor resolution helpers.""" + +from __future__ import annotations + +import pytest + +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 + + +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") 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, ) 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,