From 1d8e883137409c4147b248c6b75ff7fd50f4ab9c Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 19:28:12 +0100 Subject: [PATCH 01/12] Finalise v1 plan --- docs/song-markers.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/song-markers.md b/docs/song-markers.md index 7957205..3024fc3 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -26,15 +26,21 @@ Build UI and editing only. No snap, preset generation, or beat-phase logic yet. - Drops a song marker at the current playhead time. - Shows a toast and appends the marker to the list. +**When drop is allowed** + +- Allowed from the timeline strip and the main tuning panel. +- **Not** while a centered modal is open (modals keep consuming keys). +- **Not** while the timeline is recording. + ### Panel -- New expandable child under **Render: Timeline**: `song markers`. +- New expandable child under **Render: Timeline**: header label `song markers (N)` with expand arrow (e.g. `song markers (4)`). - List of marker times in `[mm:ss.ms]` format. -- Parent row should show a count when useful (e.g. `song markers (4)`). ### List interaction -- **Enter** on a highlighted song marker seeks the playhead to that time (audition / verify placement). +- **Sticky selection:** the highlighted list row stays put until the user moves focus (Up/Down), deletes it, or drops a marker (select the new or replaced marker). Do **not** auto-follow the playhead. +- **Enter** on a highlighted song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. - **Delete** removes the highlighted song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. From 95d7bffa48a8d95f2118d1be8822a92d927b3bfe Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 20:06:02 +0100 Subject: [PATCH 02/12] Phase 1: add UI/UX controls for song markers --- cleave/project.py | 36 +++-- cleave/song_markers.py | 64 +++++++++ cleave/viz/app.py | 5 +- cleave/viz/controls.py | 120 ++++++++++++++++- cleave/viz/input_dispatch.py | 6 + cleave/viz/layer_visibility.py | 2 + cleave/viz/playback.py | 8 +- cleave/viz/row_fields.py | 35 ++++- cleave/viz/row_sections.py | 34 +++++ cleave/viz/row_semantics.py | 29 ++++ cleave/viz/session.py | 14 ++ cleave/viz/theme.py | 4 +- cleave/viz/timeline_controls.py | 3 - cleave/viz/timeline_overlay.py | 15 +++ cleave/viz/timeline_panel_cache.py | 4 + cleave/viz/tuning_view_state.py | 8 ++ docs/song-markers.md | 2 +- tests/cleave/test_project.py | 87 ++++++++++++ tests/cleave/test_song_markers.py | 73 ++++++++++ tests/cleave/viz/test_controls.py | 125 +++++++++++++++++- tests/cleave/viz/test_input_dispatch.py | 27 ++++ tests/cleave/viz/test_layer.py | 22 +++ tests/cleave/viz/test_playback.py | 17 +++ tests/cleave/viz/test_row_fields.py | 16 ++- .../cleave/viz/test_row_layout_resolution.py | 3 + tests/cleave/viz/test_row_semantics.py | 4 + tests/cleave/viz/test_timeline_overlay.py | 31 +++++ tests/cleave/viz/test_timeline_panel_cache.py | 25 ++++ tests/cleave/viz/test_view_state_structure.py | 64 +++++++++ 29 files changed, 858 insertions(+), 25 deletions(-) create mode 100644 cleave/song_markers.py create mode 100644 tests/cleave/test_song_markers.py diff --git a/cleave/project.py b/cleave/project.py index c648ad3..187c48e 100644 --- a/cleave/project.py +++ b/cleave/project.py @@ -3,9 +3,10 @@ from __future__ import annotations import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path +from typing import Sequence import yaml @@ -21,6 +22,7 @@ class ProjectManifest: separated_at: str demucs_model: str restored_from: str | None = None + song_markers: tuple[float, ...] = () @classmethod def from_dict(cls, data: dict) -> ProjectManifest: @@ -33,6 +35,13 @@ def from_dict(cls, data: dict) -> ProjectManifest: raise ValueError("invalid project manifest: mix.filename") restored = data.get("restored-from") restored_from = None if restored is None else str(restored) + raw_markers = data.get("song-markers") + if raw_markers is None: + song_markers: tuple[float, ...] = () + elif isinstance(raw_markers, list): + song_markers = tuple(float(x) for x in raw_markers) + else: + raise ValueError("invalid project manifest: song-markers") return cls( version=int(data["version"]), slug=str(data["slug"]), @@ -41,6 +50,7 @@ def from_dict(cls, data: dict) -> ProjectManifest: separated_at=str(ingest["separated_at"]), demucs_model=str(ingest["demucs_model"]), restored_from=restored_from, + song_markers=song_markers, ) def to_dict(self) -> dict: @@ -56,6 +66,8 @@ def to_dict(self) -> dict: } if self.restored_from is not None: data["restored-from"] = self.restored_from + if self.song_markers: + data["song-markers"] = [float(t) for t in self.song_markers] return data @@ -71,15 +83,7 @@ def rewrite_manifest_slug( ) -> Path: """Update ``project.yaml`` *slug* and optional ``restored-from`` provenance.""" manifest = load_manifest(project_dir) - updated = ProjectManifest( - version=manifest.version, - slug=slug, - mix_filename=manifest.mix_filename, - original_path=manifest.original_path, - separated_at=manifest.separated_at, - demucs_model=manifest.demucs_model, - restored_from=restored_from, - ) + updated = replace(manifest, slug=slug, restored_from=restored_from) path = manifest_path(project_dir) with path.open("w", encoding="utf-8") as handle: yaml.safe_dump(updated.to_dict(), handle, sort_keys=False) @@ -105,6 +109,7 @@ def write_manifest( original_path: Path, demucs_model: str, separated_at: datetime | None = None, + song_markers: Sequence[float] = (), ) -> Path: when = separated_at or datetime.now(timezone.utc) manifest = ProjectManifest( @@ -114,6 +119,7 @@ def write_manifest( original_path=str(original_path.resolve()), separated_at=when.isoformat(), demucs_model=demucs_model, + song_markers=tuple(float(t) for t in song_markers), ) path = manifest_path(project_dir) with path.open("w", encoding="utf-8") as handle: @@ -121,6 +127,16 @@ def write_manifest( return path +def save_song_markers(project_dir: Path, markers: Sequence[float]) -> Path: + """Replace ``song-markers`` in ``project.yaml``, preserving ingest and provenance.""" + manifest = load_manifest(project_dir) + updated = replace(manifest, song_markers=tuple(float(t) for t in markers)) + path = manifest_path(project_dir) + with path.open("w", encoding="utf-8") as handle: + yaml.safe_dump(updated.to_dict(), handle, sort_keys=False) + return path + + def mix_path(project_dir: Path) -> Path: manifest = load_manifest(project_dir) return project_dir / manifest.mix_filename diff --git a/cleave/song_markers.py b/cleave/song_markers.py new file mode 100644 index 0000000..86b6596 --- /dev/null +++ b/cleave/song_markers.py @@ -0,0 +1,64 @@ +"""Pure domain helpers for project-scoped song markers.""" + +from __future__ import annotations + +import bisect +from typing import Sequence + + +def nearest_index(times: Sequence[float], t: float) -> int: + """Return the index of the song marker nearest to ``t``. + + On an exact distance tie, prefers the earlier marker (lower index). + """ + if not times: + raise ValueError("nearest_index requires at least one song marker") + best_i = 0 + best_d = abs(times[0] - t) + for i in range(1, len(times)): + d = abs(times[i] - t) + if d < best_d: + best_i = i + best_d = d + return best_i + + +def place_marker( + times: Sequence[float], + t: float, + window: float = 2.0, +) -> tuple[tuple[float, ...], int | None, float | None]: + """Insert ``t`` into sorted song markers, or replace within ``window`` seconds. + + If any existing marker lies within ``window`` of ``t``, the nearest one is + replaced (earlier marker on a tie). Otherwise ``t`` is inserted in sorted + order. + + Returns ``(new_times, replaced_index, replaced_time)``. On replace, + ``replaced_index`` is the index of the new marker in ``new_times`` and + ``replaced_time`` is the previous time. On insert, both are ``None``. + """ + if not times: + return (float(t),), None, None + + idx = nearest_index(times, t) + if abs(times[idx] - t) <= window: + old = float(times[idx]) + updated = [float(x) for x in times] + updated[idx] = float(t) + updated.sort() + new_idx = updated.index(float(t)) + return tuple(updated), new_idx, old + + updated = [float(x) for x in times] + bisect.insort(updated, float(t)) + return tuple(updated), None, None + + +def format_marker_time(t: float) -> str: + """Format a song marker time as ``mm:ss.cc`` (minutes, seconds, hundredths).""" + total_hundredths = max(0, int(round(float(t) * 100.0))) + minutes = total_hundredths // 6000 + seconds = (total_hundredths % 6000) // 100 + hundredths = total_hundredths % 100 + return f"{minutes:02d}:{seconds:02d}.{hundredths:02d}" diff --git a/cleave/viz/app.py b/cleave/viz/app.py index cbe8399..88676f1 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -20,6 +20,7 @@ from cleave.signals import Signals from cleave.pcm_io import load_mix_pcm from cleave.stem_pcm import StemPcmBank, load_stem_pcm, LIVE_PROJECTM_FPS, samples_for_dt, samples_per_frame +from cleave.project import load_manifest from cleave.viz.bootstrap import load_stem_signals from cleave.viz.controls import TuningControls from cleave.viz.session import TimelineRuntime, TuningSession, session_from_cfg @@ -111,6 +112,8 @@ def build_runtime_base( playlists: dict[str, PresetPlaylist], ) -> VisualizerSeed: pcm_bank = load_stem_pcm(project_dir) + session = session_from_cfg(cfg, playlists) + session.song_markers.times = list(load_manifest(project_dir).song_markers) return VisualizerSeed( project_dir=project_dir, audio_path=audio_path, @@ -120,7 +123,7 @@ def build_runtime_base( display_width=cfg.editor.display_width, display_height=cfg.editor.display_height, window_title=f"Cleave — {project_dir.name}", - session=session_from_cfg(cfg, playlists), + session=session, cfg=cfg, pcm_bank=pcm_bank, duration_sec=pcm_bank.duration_sec, diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 1d717ec..5c7862f 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -14,12 +14,14 @@ from cleave.config_schema import PRESET_SWITCHING_MODES, PRESET_SWITCHING_SCOPES from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES +from cleave.project import save_song_markers +from cleave.song_markers import format_marker_time, nearest_index, place_marker from cleave.viz.config_save import ConfigSaveController from cleave.viz.preset_curation_controls import PresetCurationController from cleave.viz.key_repeat import KeyRepeatController, add_current_preset_key_pressed, delete_key_pressed, mod_ctrl, mod_shift from cleave.viz.modal import ModalHost from cleave.viz.panel_notification import PanelNotificationHost -from cleave.viz.playback import PlaybackState, seek, toggle_pause +from cleave.viz.playback import PlaybackState, current_sec, seek, seek_to, toggle_pause from cleave.viz.live_layer_bindings import LiveLayerBindings from cleave.viz.render_overlay_controls import RenderOverlayControls from cleave.viz.render_post_fx_bindings import RenderPostFxBindings @@ -298,6 +300,15 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if delete_key_pressed(event): kind = self.focus_descriptor.kind + if kind == RowKind.SONG_MARKER_ITEM: + desc = self.focus_descriptor + if desc.marker_index is not None: + if section_lock_blocks_mutation( + self.session, self.focus_descriptor + ): + return True + self._delete_song_marker(desc.marker_index) + return True if kind == RowKind.TRACK_USER_PRESET_ITEM: slot = self.focus_descriptor.slot desc = self.focus_descriptor @@ -386,6 +397,17 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if event.key == pygame.K_RETURN: kind = self.focus_descriptor.kind + if kind == RowKind.SONG_MARKER_ITEM: + desc = self.focus_descriptor + if desc.marker_index is not None: + if section_lock_blocks_mutation( + self.session, self.focus_descriptor + ): + return True + times = self.session.song_markers.times + if 0 <= desc.marker_index < len(times): + self.seek_to(times[desc.marker_index]) + return True if kind == RowKind.TRACK_USER_PRESET_ADD: slot = self.focus_descriptor.slot if slot is not None: @@ -473,6 +495,14 @@ def _apply_focus_cursor(self, cursor: FocusCursor) -> None: self._focus_cursor = cursor if isinstance(cursor, TimelineFocus): self.session.timeline.focus_row = cursor.row + return + if isinstance(cursor, MainFocus): + desc = cursor.descriptor + if ( + desc.kind == RowKind.SONG_MARKER_ITEM + and desc.marker_index is not None + ): + self.session.song_markers.selected_index = desc.marker_index def _normalize_focus_cursor(self) -> None: view = self.build_view_state(paused=self.playback.paused) @@ -964,6 +994,12 @@ def _set_user_presets_expanded(self, slot: str, expanded: bool) -> None: return layer.user_presets_expanded = expanded + def _set_song_markers_expanded(self, expanded: bool) -> None: + markers = self.session.song_markers + if markers.expanded == expanded: + return + markers.expanded = expanded + def _set_beat(self, slot: str, value: float) -> None: layer = self.session.layers[slot] layer.beat_sensitivity = clamp_beat_sensitivity(value) @@ -976,6 +1012,88 @@ def _do_seek(self, delta_sec: float) -> None: else: seek(self.playback, delta_sec, self.duration_sec) + def seek_to(self, position_sec: float) -> None: + """Absolute seek; routes through ``on_seek`` (PCM flush + preset re-apply).""" + target = max(0.0, min(float(position_sec), self.duration_sec)) + current = current_sec(self.playback, self.duration_sec) + if self._layer_bindings is not None: + self._layer_bindings.on_seek(target - current) + else: + seek_to(self.playback, target, self.duration_sec) + + def drop_song_marker(self) -> None: + """Drop or replace a song marker at the playhead; persist to project.yaml.""" + if self.session.timeline.recording: + return + t = current_sec(self.playback, self.duration_sec) + markers = self.session.song_markers + new_times, replaced_index, replaced_time = place_marker(markers.times, t) + markers.times = list(new_times) + markers.expanded = True + self.session.timeline.panel_open = True + if replaced_index is not None: + markers.selected_index = replaced_index + assert replaced_time is not None + self.show_notification( + f"Song marker replaced " + f"{format_marker_time(replaced_time)} -> " + f"{format_marker_time(new_times[replaced_index])}" + ) + else: + markers.selected_index = nearest_index(new_times, t) + self.show_notification(f"Song marker {format_marker_time(t)}") + if self.project_dir is not None: + save_song_markers(self.project_dir, markers.times) + selected = markers.selected_index + if selected is not None: + self._apply_focus_cursor( + MainFocus( + RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=selected) + ) + ) + + def _delete_song_marker(self, index: int) -> None: + markers = self.session.song_markers + if index < 0 or index >= len(markers.times): + return + label = format_marker_time(markers.times[index]) + self._modal_host.prompt_yes_no( + f"Remove song marker {label}?", + on_confirm=lambda: self._confirm_delete_song_marker(index), + ) + + def _confirm_delete_song_marker(self, index: int) -> None: + markers = self.session.song_markers + if index < 0 or index >= len(markers.times): + return + removed = markers.times.pop(index) + if not markers.times: + markers.selected_index = None + elif markers.selected_index is None: + pass + elif markers.selected_index == index: + markers.selected_index = min(index, len(markers.times) - 1) + elif markers.selected_index > index: + markers.selected_index -= 1 + if self.project_dir is not None: + save_song_markers(self.project_dir, markers.times) + self.show_notification( + f"Song marker removed {format_marker_time(removed)}" + ) + if markers.selected_index is not None: + self._apply_focus_cursor( + MainFocus( + RowDescriptor( + RowKind.SONG_MARKER_ITEM, + marker_index=markers.selected_index, + ) + ) + ) + else: + self._apply_focus_cursor( + MainFocus(RowDescriptor(RowKind.SONG_MARKERS_HEADER)) + ) + def show_notification(self, message: str) -> None: self._notification_host.show(message) diff --git a/cleave/viz/input_dispatch.py b/cleave/viz/input_dispatch.py index 5838e13..0f4355d 100644 --- a/cleave/viz/input_dispatch.py +++ b/cleave/viz/input_dispatch.py @@ -57,6 +57,12 @@ def _handle_global_keydown( runtime.controls.prompt_save_config() return True + if event.key == pygame.K_RETURN and mod_ctrl(event.mod): + if runtime.seed.session.timeline.recording: + return True + runtime.controls.drop_song_marker() + return True + if event.key == pygame.K_h: runtime.seed.session.help_visible = not runtime.seed.session.help_visible return True diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index c436e37..db45d4a 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -260,4 +260,6 @@ def build_timeline_view_state( bar_grid_times=shift_bars_by_beats( bar_times, beat_times, tl.bar_phase_offset ), + song_marker_times=tuple(session.song_markers.times), + selected_song_marker_index=session.song_markers.selected_index, ) diff --git a/cleave/viz/playback.py b/cleave/viz/playback.py index 33e6ae6..d96aadd 100644 --- a/cleave/viz/playback.py +++ b/cleave/viz/playback.py @@ -22,10 +22,14 @@ def format_mmss(sec: float) -> str: return f"{s // 60:02d}:{s % 60:02d}" +def seek_to(state: PlaybackState, position_sec: float, duration_sec: float) -> None: + clamped = max(0.0, min(float(position_sec), duration_sec)) + state.player.seek(clamped) + + def seek(state: PlaybackState, delta_sec: float, duration_sec: float) -> None: t = current_sec(state, duration_sec) - position_sec = max(0.0, min(t + delta_sec, duration_sec)) - state.player.seek(position_sec) + seek_to(state, t + delta_sec, duration_sec) def toggle_pause(state: PlaybackState, _duration_sec: float) -> None: diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index f73e06a..9d9dfba 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -21,6 +21,7 @@ ui_fade_display, ) from cleave.extract import stem_control_label, stem_overlay_header +from cleave.song_markers import format_marker_time from cleave.viz.fonts import render_overlay_font_display from cleave.viz.row_sections import ( apply_expand_toggle, @@ -857,6 +858,16 @@ def _format_track_user_preset_item( return user_preset_item_display_name(block.user_presets, desc.preset_index) +def _format_song_markers_count(state: TuningViewState, _desc: RowDescriptor) -> str: + return f"({len(state.render_timeline.song_marker_times)})" + + +def _format_song_marker_item(state: TuningViewState, desc: RowDescriptor) -> str: + assert desc.marker_index is not None + times = state.render_timeline.song_marker_times + return f"[{format_marker_time(times[desc.marker_index])}]" + + def _format_transport(_state: TuningViewState, _desc: RowDescriptor) -> str: return "" @@ -1335,6 +1346,17 @@ def _apply_transport( panel_label="snap to bars", present_style=RowPresentStyle.FULL_LINE, ), + RowKind.SONG_MARKERS_HEADER: RowFieldDef( + panel_label="song markers", + present_style=RowPresentStyle.EXPAND_SUBHEADER, + format_value=_format_song_markers_count, + apply_horizontal=_apply_expand_subheader, + ), + RowKind.SONG_MARKER_ITEM: RowFieldDef( + panel_label="", + present_style=RowPresentStyle.FULL_LINE, + format_value=_format_song_marker_item, + ), RowKind.PANEL_NOTIFICATION: RowFieldDef( panel_label="", present_style=RowPresentStyle.FULL_LINE, @@ -1380,7 +1402,13 @@ def expand_subheader_prefix(kind: RowKind) -> str: def format_expand_subheader_value(state: TuningViewState, desc: RowDescriptor) -> str: - return expand_arrow_for_header(state, desc.kind, desc.slot) + arrow = expand_arrow_for_header(state, desc.kind, desc.slot) + field = row_field_def(desc.kind) + if field.format_value is not None: + suffix = field.format_value(state, desc) + if suffix: + return f"{suffix} {arrow}" + return arrow def row_expand_subheader_display_text( @@ -1468,6 +1496,11 @@ def row_full_line_display_text(state: TuningViewState, desc: RowDescriptor) -> s return field.format_value(state, desc) if desc.kind == RowKind.TRANSPORT: return "" + if field.format_value is not None: + return ( + tree_branch_prefix(_full_line_branch_depth(desc.kind)) + + field.format_value(state, desc) + ) return full_line_prefix(desc.kind) diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 77ae6a7..0e9c1bd 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -87,6 +87,10 @@ def _toggle_user_presets(controls: TuningControls, slot: str | None, forward: bo controls._set_user_presets_expanded(slot, forward) +def _toggle_song_markers(controls: TuningControls, _slot: str | None, forward: bool) -> None: + controls._set_song_markers_expanded(forward) + + def _open_timeline_panel(controls: TuningControls, forward: bool) -> None: if forward: controls._open_timeline_panel() @@ -234,6 +238,10 @@ def _user_presets_expanded(state: TuningViewState, slot: str | None) -> bool: return state.tracks[slot].user_presets_expanded +def _song_markers_expanded(state: TuningViewState, _slot: str | None) -> bool: + return state.render_timeline.song_markers_expanded + + def _append_track_effect_rows( row_list: list[RowDescriptor], state: TuningViewState, @@ -272,6 +280,17 @@ def _append_user_preset_rows( row_list.append(RowDescriptor(RowKind.TRACK_USER_PRESET_ADD, slot=slot)) +def _append_song_marker_rows( + row_list: list[RowDescriptor], + state: TuningViewState, + _slot: str | None, +) -> None: + for index in range(len(state.render_timeline.song_marker_times)): + row_list.append( + RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=index) + ) + + SETTINGS_UI_SECTION = ExpandSectionDef( header_kind=RowKind.SETTINGS_UI_HEADER, context="global", @@ -513,6 +532,15 @@ def _hard_cut_enabled(state: TuningViewState, desc: RowDescriptor) -> bool: SectionNode(panel_anchor=TIMELINE_PANEL_ANCHOR), ) +SONG_MARKERS_SECTION = ExpandSectionDef( + header_kind=RowKind.SONG_MARKERS_HEADER, + context="global", + read_expanded=_song_markers_expanded, + toggle=_toggle_song_markers, + children=(), + append_dynamic_children=_append_song_marker_rows, +) + def _collect_expand_sections( *roots: ExpandSectionDef, @@ -544,6 +572,7 @@ def walk_nodes(nodes: tuple[SectionNode, ...]) -> None: _ALL_EXPAND_SECTIONS = _collect_expand_sections( SETTINGS_SECTION, TRACK_SECTION, + SONG_MARKERS_SECTION, extra_nodes=RENDER_SECTION_NODES, ) @@ -642,6 +671,8 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.SONG_MARKERS_HEADER, + RowKind.SONG_MARKER_ITEM, } ) @@ -684,6 +715,8 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TIMELINE_BAR_GRID] = 1 depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 depths[RowKind.TIMELINE_SNAP_TO_BARS] = 1 + depths[RowKind.SONG_MARKERS_HEADER] = 1 + depths[RowKind.SONG_MARKER_ITEM] = 2 return depths @@ -802,6 +835,7 @@ def append_render_section_rows( row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_GRID)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) + append_expand_section_rows(row_list, SONG_MARKERS_SECTION, state) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 783df8c..03249a8 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -79,6 +79,8 @@ class RowKind(Enum): TIMELINE_BAR_GRID = auto() TIMELINE_SNAP_TO_BEATS = auto() TIMELINE_SNAP_TO_BARS = auto() + SONG_MARKERS_HEADER = auto() + SONG_MARKER_ITEM = auto() SETTINGS_HEADER = auto() SETTINGS_PREVIEW_QUALITY = auto() SETTINGS_UI_HEADER = auto() @@ -96,6 +98,7 @@ class RowDescriptor: effect_id: str | None = None driver_slug: str | None = None preset_index: int | None = None + marker_index: int | None = None class RowAffordance(Enum): @@ -748,6 +751,29 @@ class RowBehavior: "(irreversible).", ), ), + RowKind.SONG_MARKERS_HEADER: RowBehavior( + RowAffordance.EXPAND, + is_sub_header=True, + help_title="Song markers", + help_description=( + "Manual song markers for major transitions.", + "Ctrl+Enter drops a marker at the playhead.", + ), + ), + RowKind.SONG_MARKER_ITEM: RowBehavior( + RowAffordance.ACTION, + navigable=True, + blocked_by_section_lock=True, + help_title="Song marker", + help_entries=( + ("Enter", "seek to marker"), + ("Delete", "confirm remove"), + ), + help_description=( + "A song marker time. Enter seeks the playhead;", + "Delete asks to remove the marker.", + ), + ), RowKind.SETTINGS_HEADER: RowBehavior( RowAffordance.EXPAND, is_header=True, @@ -842,6 +868,7 @@ class RowBehavior: TRACK_USER_PRESET_SUB_ROW_KINDS = frozenset( {RowKind.TRACK_USER_PRESET_ITEM, RowKind.TRACK_USER_PRESET_ADD} ) +SONG_MARKER_SUB_ROW_KINDS = frozenset({RowKind.SONG_MARKER_ITEM}) PRESET_FILE_ROW_KINDS = frozenset({RowKind.TRACK_PRESET, RowKind.TRACK_USER_PRESET_ITEM}) _SECTION_LOCK_BLOCKING_AFFORDANCES = frozenset( @@ -983,4 +1010,6 @@ def section_header_descriptor(desc: RowDescriptor) -> RowDescriptor: return RowDescriptor(RowKind.TRACK_EFFECTS_HEADER, slot=desc.slot) if kind in TRACK_USER_PRESET_SUB_ROW_KINDS: return RowDescriptor(RowKind.TRACK_USER_PRESETS, slot=desc.slot) + if kind in SONG_MARKER_SUB_ROW_KINDS: + return RowDescriptor(RowKind.SONG_MARKERS_HEADER) return desc diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 8eac09b..ee0f728 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -154,6 +154,19 @@ def default_timeline_runtime() -> TimelineRuntime: return TimelineRuntime() +@dataclass +class SongMarkerRuntime: + """Project-scoped song markers held live; not part of viz YAML.""" + + times: list[float] = field(default_factory=list) + selected_index: int | None = None + expanded: bool = False + + +def default_song_marker_runtime() -> SongMarkerRuntime: + return SongMarkerRuntime() + + @dataclass class SettingsRuntime: expanded: bool = False @@ -200,6 +213,7 @@ class TuningSession: ) render_post_fx_solo: bool = False timeline: TimelineRuntime = field(default_factory=default_timeline_runtime) + song_markers: SongMarkerRuntime = field(default_factory=default_song_marker_runtime) settings: SettingsRuntime = field(default_factory=SettingsRuntime) help_visible: bool = False preset_skip_notify_tracker: PresetSkipNotifyTracker = field( diff --git a/cleave/viz/theme.py b/cleave/viz/theme.py index 3374c9c..7c7e7e5 100644 --- a/cleave/viz/theme.py +++ b/cleave/viz/theme.py @@ -9,7 +9,7 @@ Accent colors for modes and icons (not label/value roles): HIGHLIGHT, HIGHLIGHT_MUTED, MOVE_MODE, LOCK_ICON, PRESET_ICON, PRESET_FILE_ICON, - TIMELINE_BAR_ON, BAR_GRID, PLAYHEAD + TIMELINE_BAR_ON, BAR_GRID, PLAYHEAD, SONG_MARKER, SONG_MARKER_SELECTED Layout scales: UI_SCALE (1.2) — main tuning panel, help, modals, and Material Icons spacing @@ -172,6 +172,8 @@ def timeline_panel_height_px( TIMELINE_BAR_ON: tuple[int, int, int] = (200, 225, 255) BAR_GRID: tuple[int, int, int] = (70, 95, 130) PLAYHEAD: tuple[int, int, int] = (90, 50, 130) +SONG_MARKER: tuple[int, int, int] = (220, 40, 40) +SONG_MARKER_SELECTED: tuple[int, int, int] = (255, 170, 40) BORDER_WIDTH: int = 2 SCROLLBAR_TRACK: tuple[int, int, int] = DISABLED diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index eb1953a..34e97ef 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -142,9 +142,6 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self._refresh_visibility() return True - if event.key == pygame.K_RETURN and mod_ctrl(event.mod): - return True - if event.key == pygame.K_RETURN and mod_shift(event.mod): if not self.session.timeline.recording: self._toggle_override_focused_row() diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index fe099e6..4b2efd3 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -42,6 +42,8 @@ LABEL, PLAYHEAD, REC_BG, + SONG_MARKER, + SONG_MARKER_SELECTED, TIMELINE_BAR_ON, VALUE, timeline_panel_height_px, @@ -99,6 +101,8 @@ class TimelineViewState: arm_flash_start_ms: dict[str, int] = field(default_factory=dict) show_bar_grid: bool = False bar_grid_times: tuple[float, ...] = () + song_marker_times: tuple[float, ...] = () + selected_song_marker_index: int | None = None def visibility_segments( @@ -626,6 +630,17 @@ def _build_static_panel( HIGHLIGHT, ) + for marker_i, marker_t in enumerate(state.song_marker_times): + marker_x = time_to_x(marker_t, bar_left, bar_width, state.duration_sec) + selected = marker_i == state.selected_song_marker_index + pygame.draw.line( + panel, + SONG_MARKER_SELECTED if selected else SONG_MARKER, + (marker_x, layout.bar_top), + (marker_x, layout.bar_bottom - 1), + 4 if selected else 2, + ) + if BORDER_WIDTH > 0: pygame.draw.rect( panel, diff --git a/cleave/viz/timeline_panel_cache.py b/cleave/viz/timeline_panel_cache.py index 6158d41..3804e3e 100644 --- a/cleave/viz/timeline_panel_cache.py +++ b/cleave/viz/timeline_panel_cache.py @@ -51,6 +51,8 @@ class TimelineStaticSignature: visibility_bucket: int show_bar_grid: bool bar_grid_times: tuple[float, ...] + song_marker_times: tuple[float, ...] + selected_song_marker_index: int | None @dataclass @@ -132,6 +134,8 @@ def timeline_static_signature( visibility_bucket=visibility_bucket(visibility), show_bar_grid=state.show_bar_grid, bar_grid_times=state.bar_grid_times, + song_marker_times=state.song_marker_times, + selected_song_marker_index=state.selected_song_marker_index, ) diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 828df5c..d2e0f36 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -133,6 +133,8 @@ class RenderTimelineBlock: bar_phase_offset: int = 0 show_bar_grid: bool = False locked: bool = False + song_markers_expanded: bool = False + song_marker_times: tuple[float, ...] = () @dataclass @@ -303,6 +305,8 @@ def view_state_structure_signature( "render_timeline": { "enabled": tl.enabled, "panel_open": tl.panel_open, + "song_markers_expanded": session.song_markers.expanded, + "song_marker_count": len(session.song_markers.times), }, "timeline": {"enabled": tl.enabled}, } @@ -427,6 +431,8 @@ def _build_structure( expanded=tl.panel_open, bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, + song_markers_expanded=self.session.song_markers.expanded, + song_marker_times=tuple(self.session.song_markers.times), ) layout_state = TuningViewState( layer_z_order=layer_z_order, @@ -575,6 +581,8 @@ def build( bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, locked=tl.locked, + song_markers_expanded=self.session.song_markers.expanded, + song_marker_times=tuple(self.session.song_markers.times), ), settings=replace( structure.settings, diff --git a/docs/song-markers.md b/docs/song-markers.md index 3024fc3..be0b7fc 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -41,7 +41,7 @@ Build UI and editing only. No snap, preset generation, or beat-phase logic yet. - **Sticky selection:** the highlighted list row stays put until the user moves focus (Up/Down), deletes it, or drops a marker (select the new or replaced marker). Do **not** auto-follow the playhead. - **Enter** on a highlighted song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. -- **Delete** removes the highlighted song marker. +- **Delete** prompts a confirm modal, then removes the highlighted song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. ### Timeline display diff --git a/tests/cleave/test_project.py b/tests/cleave/test_project.py index 8a0c4ee..2d9b99b 100644 --- a/tests/cleave/test_project.py +++ b/tests/cleave/test_project.py @@ -16,6 +16,7 @@ mix_path, resolve_mix_path, rewrite_manifest_slug, + save_song_markers, write_manifest, ) @@ -183,6 +184,7 @@ def test_rewrite_manifest_slug_updates_slug_and_restored_from(tmp_path: Path) -> mix_filename="old-slug.flac", original_path=tmp_path / "source.flac", demucs_model="htdemucs", + song_markers=(12.5, 64.0), ) rewrite_manifest_slug(project, "new-slug", restored_from="old-slug") @@ -191,3 +193,88 @@ def test_rewrite_manifest_slug_updates_slug_and_restored_from(tmp_path: Path) -> assert manifest.slug == "new-slug" assert manifest.restored_from == "old-slug" assert manifest.mix_filename == "old-slug.flac" + assert manifest.song_markers == (12.5, 64.0) + + +def test_manifest_round_trip_without_song_markers(tmp_path: Path) -> None: + project = tmp_path / "song" + project.mkdir() + write_manifest( + project, + slug="song", + mix_filename="song.flac", + original_path=tmp_path / "source.flac", + demucs_model="htdemucs", + ) + + with (project / PROJECT_FILENAME).open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + + assert "song-markers" not in data + assert load_manifest(project).song_markers == () + + +def test_manifest_round_trip_with_song_markers(tmp_path: Path) -> None: + project = tmp_path / "song" + project.mkdir() + manifest = ProjectManifest( + version=1, + slug="song", + mix_filename="song.flac", + original_path=str((tmp_path / "source.flac").resolve()), + separated_at="2026-06-08T20:15:00+00:00", + demucs_model="htdemucs", + restored_from="original-slug", + song_markers=(8.25, 64.5, 120.0), + ) + with (project / PROJECT_FILENAME).open("w", encoding="utf-8") as handle: + yaml.safe_dump(manifest.to_dict(), handle, sort_keys=False) + + loaded = load_manifest(project) + assert loaded == manifest + + with (project / PROJECT_FILENAME).open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + assert data["version"] == 1 + assert data["song-markers"] == [8.25, 64.5, 120.0] + assert data["restored-from"] == "original-slug" + assert data["ingest"]["demucs_model"] == "htdemucs" + + +def test_save_song_markers_preserves_ingest(tmp_path: Path) -> None: + project = tmp_path / "song" + project.mkdir() + original = tmp_path / "source.flac" + original.write_bytes(b"audio") + when = datetime(2026, 6, 8, 20, 15, tzinfo=timezone.utc) + write_manifest( + project, + slug="song", + mix_filename="song.flac", + original_path=original, + demucs_model="htdemucs_ft", + separated_at=when, + ) + rewrite_manifest_slug(project, "song", restored_from="archived-slug") + + save_song_markers(project, (10.0, 42.5)) + + manifest = load_manifest(project) + assert manifest.song_markers == (10.0, 42.5) + assert manifest.slug == "song" + assert manifest.mix_filename == "song.flac" + assert manifest.original_path == str(original.resolve()) + assert manifest.separated_at == "2026-06-08T20:15:00+00:00" + assert manifest.demucs_model == "htdemucs_ft" + assert manifest.restored_from == "archived-slug" + assert manifest.version == 1 + + with (project / PROJECT_FILENAME).open(encoding="utf-8") as handle: + data = yaml.safe_load(handle) + assert data["ingest"] == { + "original_path": str(original.resolve()), + "separated_at": "2026-06-08T20:15:00+00:00", + "demucs_model": "htdemucs_ft", + } + assert data["restored-from"] == "archived-slug" + assert data["song-markers"] == [10.0, 42.5] diff --git a/tests/cleave/test_song_markers.py b/tests/cleave/test_song_markers.py new file mode 100644 index 0000000..a223110 --- /dev/null +++ b/tests/cleave/test_song_markers.py @@ -0,0 +1,73 @@ +"""Tests for cleave.song_markers domain helpers.""" + +from __future__ import annotations + +import pytest + +from cleave.song_markers import format_marker_time, nearest_index, place_marker + + +def test_place_marker_insert_sorted() -> None: + times, replaced_index, replaced_time = place_marker((10.0, 30.0), 20.0) + assert times == (10.0, 20.0, 30.0) + assert replaced_index is None + assert replaced_time is None + + +def test_place_marker_insert_empty() -> None: + times, replaced_index, replaced_time = place_marker((), 12.5) + assert times == (12.5,) + assert replaced_index is None + assert replaced_time is None + + +def test_place_marker_replace_within_2s() -> None: + times, replaced_index, replaced_time = place_marker((10.0, 30.0), 11.5) + assert times == (11.5, 30.0) + assert replaced_index == 0 + assert replaced_time == 10.0 + + +def test_place_marker_replace_nearest_of_two() -> None: + times, replaced_index, replaced_time = place_marker((10.0, 12.0), 10.5) + assert times == (10.5, 12.0) + assert replaced_index == 0 + assert replaced_time == 10.0 + + times, replaced_index, replaced_time = place_marker((10.0, 12.0), 11.5) + assert times == (10.0, 11.5) + assert replaced_index == 1 + assert replaced_time == 12.0 + + +def test_place_marker_outside_window_inserts() -> None: + times, replaced_index, replaced_time = place_marker((10.0, 20.0), 13.0) + assert times == (10.0, 13.0, 20.0) + assert replaced_index is None + assert replaced_time is None + + +def test_place_marker_window_boundary_replaces() -> None: + times, replaced_index, replaced_time = place_marker((10.0,), 12.0) + assert times == (12.0,) + assert replaced_index == 0 + assert replaced_time == 10.0 + + +def test_nearest_index() -> None: + assert nearest_index((10.0, 20.0, 30.0), 21.0) == 1 + assert nearest_index((10.0, 20.0), 15.0) == 0 # earlier on tie + + +def test_nearest_index_empty_raises() -> None: + with pytest.raises(ValueError, match="at least one song marker"): + nearest_index((), 1.0) + + +def test_format_marker_time() -> None: + assert format_marker_time(0.0) == "00:00.00" + assert format_marker_time(65.0) == "01:05.00" + assert format_marker_time(65.123) == "01:05.12" + assert format_marker_time(65.129) == "01:05.13" + assert format_marker_time(125.456) == "02:05.46" + assert format_marker_time(-1.0) == "00:00.00" diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 191e154..8d6f971 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -2101,6 +2101,7 @@ def test_render_timeline_down_enters_submenu() -> None: grid_row = view.layout.find_by_kind(RowKind.TIMELINE_BAR_GRID) snap_beats_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) + markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -2124,6 +2125,10 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, snap_bars_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, markers_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2144,6 +2149,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2271,7 +2277,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) - assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) + assert controls.focus_descriptor == RowDescriptor(RowKind.SONG_MARKERS_HEADER) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -2280,8 +2286,8 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) - controls.focus_descriptor = _desc(view, snap_row) + markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) + controls.focus_descriptor = _desc(view, markers_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) @@ -2380,6 +2386,7 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.SONG_MARKERS_HEADER, ): row = view.layout.find_by_kind(kind) assert _row_value_color(view, row) == DISABLED, kind @@ -4283,4 +4290,114 @@ def test_ctrl_f_b_ignored_on_non_preset_rows() -> None: controls.handle_keydown(_keydown(pygame.K_f, mod=pygame.KMOD_CTRL)) controls.handle_keydown(_keydown(pygame.K_b, mod=pygame.KMOD_CTRL)) mock_curation.prompt_favourite.assert_not_called() - mock_curation.prompt_blacklist.assert_not_called() \ No newline at end of file + mock_curation.prompt_blacklist.assert_not_called() + + +def test_drop_song_marker_opens_panel_focuses_item() -> None: + controls = _make_controls(("layer_1",)) + controls.playback.player.seek(15.0) + controls.drop_song_marker() + markers = controls.session.song_markers + assert markers.times == [15.0] + assert markers.selected_index == 0 + assert markers.expanded is True + assert controls.session.timeline.panel_open is True + assert controls.focus_descriptor == RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + view = controls.build_view_state(paused=False) + assert RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=0) in view.layout.rows + + +def test_enter_on_song_marker_seeks() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + controls.session.song_markers.times = [10.0, 40.0] + controls.session.song_markers.expanded = True + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + controls.playback.player.seek(0.0) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert controls.playback.player.current_sec() == 40.0 + + +def test_delete_song_marker_opens_confirm_modal() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.times = [5.0, 15.0, 25.0] + markers.expanded = True + markers.selected_index = 1 + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + assert controls.handle_keydown(_keydown(pygame.K_DELETE)) is True + modal_view = controls.modal_host.view_state() + assert modal_view is not None + assert modal_view.kind == ModalKind.YES_NO + assert modal_view.options == ("Yes", "No") + assert modal_view.message == "Remove song marker 00:15.00?" + assert markers.times == [5.0, 15.0, 25.0] + assert markers.selected_index == 1 + + +def test_delete_song_marker_confirm_removes_and_adjusts_selection() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.times = [5.0, 15.0, 25.0] + markers.expanded = True + markers.selected_index = 1 + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + assert controls.handle_keydown(_keydown(pygame.K_DELETE)) is True + controls.handle_modal_keydown(_keydown(pygame.K_RETURN)) + assert not controls.modal_host.active + assert markers.times == [5.0, 25.0] + assert markers.selected_index == 1 + assert controls.focus_descriptor == RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + view = controls.build_view_state(paused=False) + assert view.notification_message == "Song marker removed 00:15.00" + + +def test_delete_song_marker_cancel_leaves_unchanged() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.times = [5.0, 15.0, 25.0] + markers.expanded = True + markers.selected_index = 1 + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + assert controls.handle_keydown(_keydown(pygame.K_DELETE)) is True + controls.handle_modal_keydown(_keydown(pygame.K_ESCAPE)) + assert not controls.modal_host.active + assert markers.times == [5.0, 15.0, 25.0] + assert markers.selected_index == 1 + assert controls.focus_descriptor == RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + + +def test_up_down_syncs_song_marker_selected_index() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.times = [5.0, 15.0] + markers.expanded = True + markers.selected_index = 0 + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + assert controls.handle_keydown(_keydown(pygame.K_DOWN)) is True + assert markers.selected_index == 1 + assert controls.focus_descriptor == RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + assert controls.handle_keydown(_keydown(pygame.K_UP)) is True + assert markers.selected_index == 0 \ No newline at end of file diff --git a/tests/cleave/viz/test_input_dispatch.py b/tests/cleave/viz/test_input_dispatch.py index a4731f8..375aa0a 100644 --- a/tests/cleave/viz/test_input_dispatch.py +++ b/tests/cleave/viz/test_input_dispatch.py @@ -125,6 +125,33 @@ def test_ctrl_s_opens_save_dialog_from_timeline_context() -> None: assert modal_view.kind == ModalKind.SAVE_CHOICE +def test_ctrl_enter_drops_song_marker_from_timeline_context() -> None: + runtime = _make_runtime() + runtime.controls.playback.player.seek(12.5) + assert dispatch_keydown( + keydown(pygame.K_RETURN, mod=pygame.KMOD_CTRL), + runtime, + ) is True + markers = runtime.seed.session.song_markers + assert markers.times == [12.5] + assert markers.selected_index == 0 + assert markers.expanded is True + assert runtime.seed.session.timeline.panel_open is True + assert runtime.controls.focus_descriptor == RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + + +def test_ctrl_enter_refused_while_recording() -> None: + runtime = _make_runtime(recording=True) + runtime.controls.playback.player.seek(12.5) + assert dispatch_keydown( + keydown(pygame.K_RETURN, mod=pygame.KMOD_CTRL), + runtime, + ) is True + assert runtime.seed.session.song_markers.times == [] + + def test_ctrl_s_blocked_while_solo_active() -> None: runtime = _make_runtime(submenu_focused=False) runtime.controls.session.solo_slot = "layer_1" diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index 66db892..da268dd 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -641,3 +641,25 @@ def test_build_timeline_view_state_empty_signals_empty_bar_grid() -> None: state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) assert state.show_bar_grid is True assert state.bar_grid_times == () + + +def test_build_timeline_view_state_includes_song_markers() -> None: + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + ) + session.song_markers.times = [12.5, 64.0] + session.song_markers.selected_index = 1 + state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) + assert state.song_marker_times == (12.5, 64.0) + assert state.selected_song_marker_index == 1 + + +def test_build_timeline_view_state_default_song_markers_empty() -> None: + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + ) + state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) + assert state.song_marker_times == () + assert state.selected_song_marker_index is None diff --git a/tests/cleave/viz/test_playback.py b/tests/cleave/viz/test_playback.py index c9dd04e..93dd09b 100644 --- a/tests/cleave/viz/test_playback.py +++ b/tests/cleave/viz/test_playback.py @@ -7,6 +7,7 @@ current_sec, format_mmss, seek, + seek_to, toggle_pause, ) from tests.support.viz import StubMixPlayer @@ -51,6 +52,22 @@ def test_seek_clamps_at_bounds() -> None: assert player.current_sec() == 0.0 +def test_seek_to_absolute() -> None: + player = StubMixPlayer(position_sec=10.0) + state = PlaybackState(player=player) + seek_to(state, position_sec=42.5, duration_sec=120.0) + assert player.current_sec() == 42.5 + + +def test_seek_to_clamps_at_bounds() -> None: + player = StubMixPlayer(position_sec=10.0) + state = PlaybackState(player=player) + seek_to(state, position_sec=200.0, duration_sec=120.0) + assert player.current_sec() == 120.0 + seek_to(state, position_sec=-5.0, duration_sec=120.0) + assert player.current_sec() == 0.0 + + def test_toggle_pause_delegates_to_player() -> None: player = TrackingMixPlayer() state = PlaybackState(player=player) diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 0ef1f69..44b8f62 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -236,6 +236,20 @@ def test_row_expand_subheader_display_text() -> None: assert row_expand_subheader_display_text(state, desc) == "└─ preset switching ▶" +def test_song_markers_expand_subheader_includes_count() -> None: + from cleave.viz.tuning_view_state import RenderTimelineBlock + + state = _minimal_view_state( + render_timeline=RenderTimelineBlock( + expanded=True, + song_markers_expanded=False, + song_marker_times=(1.0, 2.0, 3.0, 4.0), + ) + ) + desc = RowDescriptor(RowKind.SONG_MARKERS_HEADER) + assert row_expand_subheader_display_text(state, desc) == "└─ song markers (4) ▶" + + def test_composite_header_render_overlay_metadata() -> None: field = row_field_def(RowKind.RENDER_OVERLAY_HEADER) assert field.present_style == RowPresentStyle.COMPOSITE_HEADER @@ -276,7 +290,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 68 + assert len(ROW_FIELDS) == 70 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_layout_resolution.py b/tests/cleave/viz/test_row_layout_resolution.py index 2ca6321..07da4a5 100644 --- a/tests/cleave/viz/test_row_layout_resolution.py +++ b/tests/cleave/viz/test_row_layout_resolution.py @@ -271,3 +271,6 @@ def test_section_header_descriptor_mappings() -> None: assert section_header_descriptor( RowDescriptor(RowKind.TRACK_PRESET_DURATION, slot="layer_1") ) == RowDescriptor(RowKind.TRACK_PRESET_SWITCHING, slot="layer_1") + assert section_header_descriptor( + RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=0) + ) == RowDescriptor(RowKind.SONG_MARKERS_HEADER) diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index b439768..bf61760 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -262,6 +262,8 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESETS) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_BAR_PHASE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_BARS) is True + assert row_blocked_by_section_lock(RowKind.SONG_MARKER_ITEM) is True + assert row_blocked_by_section_lock(RowKind.SONG_MARKERS_HEADER) is False def test_render_headers_navigable_when_section_locked() -> None: @@ -271,10 +273,12 @@ def test_render_headers_navigable_when_section_locked() -> None: RowKind.RENDER_TIMELINE_HEADER, RowKind.RENDER_OVERLAY_TITLE_HEADER, RowKind.RENDER_POST_FX_CHROMA_BOOST_HEADER, + RowKind.SONG_MARKERS_HEADER, ): assert row_navigable_when_section_locked(kind) is True assert row_navigable_when_section_locked(RowKind.RENDER_OVERLAY_POSITION) is False assert row_navigable_when_section_locked(RowKind.TIMELINE_PRESETS) is False + assert row_navigable_when_section_locked(RowKind.SONG_MARKER_ITEM) is False def test_section_locked_resolves_render_sections() -> None: diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index f1663b3..377bbc5 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -17,6 +17,8 @@ OVERRIDE_GLYPH, OVERRIDE_GLYPH_OFF, SOLO_BG, + SONG_MARKER, + SONG_MARKER_SELECTED, timeline_ui_metrics, ) from cleave.viz.timeline_overlay import ( @@ -73,6 +75,8 @@ def _view_state( arm_flash_start_ms: dict[str, int] | None = None, show_bar_grid: bool = False, bar_grid_times: tuple[float, ...] = (), + song_marker_times: tuple[float, ...] = (), + selected_song_marker_index: int | None = None, ) -> TimelineViewState: order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) lane_map = dict(lanes or {}) @@ -115,6 +119,8 @@ def _view_state( arm_flash_start_ms=dict(arm_flash_start_ms or ()), show_bar_grid=show_bar_grid, bar_grid_times=bar_grid_times, + song_marker_times=song_marker_times, + selected_song_marker_index=selected_song_marker_index, ) @@ -582,6 +588,31 @@ def test_draw_does_not_crash() -> None: surface.subsurface(panel) +def test_song_markers_drawn_on_static_panel() -> None: + pygame.init() + overlay = TimelineOverlay() + state = _view_state( + duration_sec=100.0, + song_marker_times=(25.0, 75.0), + selected_song_marker_index=1, + ) + composed = overlay.compose_panel( + state, + viewport_width=1280, + viewport_height=720, + ) + assert composed is not None + static = overlay._cache.panel + assert static is not None + assert overlay.bar_layout is not None + bar_left, bar_width, _ = overlay.bar_layout + mid_y = static.get_height() // 2 + x_unselected = time_to_x(25.0, bar_left, bar_width, 100.0) + x_selected = time_to_x(75.0, bar_left, bar_width, 100.0) + assert static.get_at((x_unselected, mid_y))[:3] == SONG_MARKER + assert static.get_at((x_selected, mid_y))[:3] == SONG_MARKER_SELECTED + + def test_draw_when_disabled() -> None: pygame.init() overlay = TimelineOverlay() diff --git a/tests/cleave/viz/test_timeline_panel_cache.py b/tests/cleave/viz/test_timeline_panel_cache.py index 0eda662..875efe8 100644 --- a/tests/cleave/viz/test_timeline_panel_cache.py +++ b/tests/cleave/viz/test_timeline_panel_cache.py @@ -96,6 +96,31 @@ def test_static_signature_changes_on_bar_grid_times() -> None: assert _static_sig(phase0) != _static_sig(phase1) +def test_static_signature_changes_on_song_marker_times() -> None: + empty = _view_state(song_marker_times=()) + with_markers = _view_state(song_marker_times=(10.0, 40.0)) + assert _static_sig(empty) != _static_sig(with_markers) + moved = _view_state(song_marker_times=(12.0, 40.0)) + assert _static_sig(with_markers) != _static_sig(moved) + + +def test_static_signature_changes_on_selected_song_marker_index() -> None: + none_selected = _view_state( + song_marker_times=(10.0, 40.0), + selected_song_marker_index=None, + ) + first = _view_state( + song_marker_times=(10.0, 40.0), + selected_song_marker_index=0, + ) + second = _view_state( + song_marker_times=(10.0, 40.0), + selected_song_marker_index=1, + ) + assert _static_sig(none_selected) != _static_sig(first) + assert _static_sig(first) != _static_sig(second) + + def test_compose_rebuilds_static_panel_when_recording_playhead_moves() -> None: pygame.init() overlay = TimelineOverlay() diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 6c8c135..8a14aad 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -148,11 +148,13 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: bar_grid = RowDescriptor(RowKind.TIMELINE_BAR_GRID) snap_beats = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) + markers_header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) assert presets not in view_closed.layout.rows assert bar_phase not in view_closed.layout.rows assert bar_grid not in view_closed.layout.rows assert snap_beats not in view_closed.layout.rows assert snap_bars not in view_closed.layout.rows + assert markers_header not in view_closed.layout.rows session.timeline.panel_open = True view_open = builder.build(paused=False) @@ -162,15 +164,18 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid in view_open.layout.rows assert snap_beats in view_open.layout.rows assert snap_bars in view_open.layout.rows + assert markers_header in view_open.layout.rows presets_idx = view_open.layout.rows.index(presets) bar_phase_idx = view_open.layout.rows.index(bar_phase) bar_grid_idx = view_open.layout.rows.index(bar_grid) snap_beats_idx = view_open.layout.rows.index(snap_beats) snap_bars_idx = view_open.layout.rows.index(snap_bars) + markers_idx = view_open.layout.rows.index(markers_header) assert bar_phase_idx == presets_idx + 1 assert bar_grid_idx == bar_phase_idx + 1 assert snap_beats_idx == bar_grid_idx + 1 assert snap_bars_idx == snap_beats_idx + 1 + assert markers_idx == snap_bars_idx + 1 session.timeline.panel_open = False view_closed_again = builder.build(paused=False) @@ -180,6 +185,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid not in view_closed_again.layout.rows assert snap_beats not in view_closed_again.layout.rows assert snap_bars not in view_closed_again.layout.rows + assert markers_header not in view_closed_again.layout.rows def test_structure_signature_invalidates_on_highlight_rolloff_mode() -> None: @@ -269,3 +275,61 @@ def test_minimal_view_state_still_builds_layout() -> None: assert state.layout is not None assert state.layout_frame is not None assert len(state.layout.rows) > 0 + + +def test_structure_signature_invalidates_on_song_marker_count() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.song_markers.times = [10.0, 20.0] + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + +def test_structure_signature_invalidates_on_song_markers_expanded() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.song_markers.expanded = False + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.song_markers.expanded = True + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + +def test_row_layout_includes_song_marker_items_when_expanded() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + session.timeline.panel_open = True + session.song_markers.times = [8.0, 32.5, 64.0] + session.song_markers.expanded = True + builder = controls._view_state + + view = builder.build(paused=False) + header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) + assert header in view.layout.rows + items = [ + desc + for desc in view.layout.rows + if desc.kind == RowKind.SONG_MARKER_ITEM + ] + assert len(items) == 3 + assert [desc.marker_index for desc in items] == [0, 1, 2] + header_idx = view.layout.rows.index(header) + assert view.layout.rows.index(items[0]) == header_idx + 1 + + session.song_markers.expanded = False + view_collapsed = builder.build(paused=False) + assert header in view_collapsed.layout.rows + assert not any( + desc.kind == RowKind.SONG_MARKER_ITEM for desc in view_collapsed.layout.rows + ) From 755a017fbea0df3f29c23b1fda932863dcebf925 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 20:31:55 +0100 Subject: [PATCH 03/12] Save project yaml and viz yaml when user saves --- cleave/viz/config_save.py | 31 +++++- cleave/viz/controls.py | 8 +- docs/song-markers.md | 4 +- tests/cleave/viz/test_controls.py | 155 +++++++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 12 deletions(-) diff --git a/cleave/viz/config_save.py b/cleave/viz/config_save.py index 90486cf..dfebc7c 100644 --- a/cleave/viz/config_save.py +++ b/cleave/viz/config_save.py @@ -11,6 +11,7 @@ from cleave.config import VIZ_CONFIG_FILENAME, CleaveConfig from cleave.config_schema import persisted_session_payload +from cleave.project import save_song_markers from cleave.viz.modal import ModalHost, ModalKind from cleave.viz.session import TuningSession, allow_overwrite_for_path @@ -18,7 +19,12 @@ class ConfigSaveController: - """Dirty tracking, save dialogs, and deferred quit.""" + """Dirty tracking, save dialogs, and deferred quit. + + Viz YAML fields use ``persisted_session_payload``. Song markers are + project-scoped (``project.yaml``) and participate in dirty via a separate + baseline of marker times; they flush on successful Save, not on each edit. + """ def __init__( self, @@ -26,6 +32,7 @@ def __init__( cfg: CleaveConfig, modal_host: ModalHost, *, + project_dir: Path | None = None, launch_config_path: Path | None = None, repo_root_example: Path | None = None, on_save_new_config: Callable[[], Path | None] | None = None, @@ -36,6 +43,7 @@ def __init__( self.session = session self.cfg = cfg self._modal = modal_host + self._project_dir = project_dir self._active_config_path = launch_config_path self._repo_root_example = ( repo_root_example @@ -48,6 +56,7 @@ def __init__( self._move_mode_signature = move_mode_signature self._saved_signature = self._persisted_signature() + self._saved_song_markers = tuple(session.song_markers.times) self._pending_exit = False self._quit_after_save = False @@ -57,10 +66,24 @@ def active_config_path(self) -> Path | None: @property def config_dirty(self) -> bool: - return self._persisted_signature() != self._saved_signature + return ( + self._persisted_signature() != self._saved_signature + or tuple(self.session.song_markers.times) != self._saved_song_markers + ) def clear_config_dirty(self) -> None: self._saved_signature = self._persisted_signature() + self._saved_song_markers = tuple(self.session.song_markers.times) + + def _flush_song_markers(self) -> None: + if self._project_dir is None: + return + save_song_markers(self._project_dir, self.session.song_markers.times) + + def _commit_save(self) -> None: + """Flush project song markers (when available) and clear dirty baselines.""" + self._flush_song_markers() + self.clear_config_dirty() def _persisted_signature(self) -> str: payload = persisted_session_payload(self.cfg, self.session) @@ -129,7 +152,7 @@ def _trigger_save_new(self) -> None: else: self._active_config_path = saved_path filename = saved_path.name - self.clear_config_dirty() + self._commit_save() self._show_save_notification(f"Config saved to {filename}") self._finish_quit_after_save() @@ -163,7 +186,7 @@ def on_confirm() -> None: written = basename if not written: written = basename - self.clear_config_dirty() + self._commit_save() self._show_save_notification(f"Config overwritten: {written}") self._finish_quit_after_save() diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 5c7862f..2e508dd 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -14,7 +14,6 @@ from cleave.config_schema import PRESET_SWITCHING_MODES, PRESET_SWITCHING_SCOPES from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES -from cleave.project import save_song_markers from cleave.song_markers import format_marker_time, nearest_index, place_marker from cleave.viz.config_save import ConfigSaveController from cleave.viz.preset_curation_controls import PresetCurationController @@ -114,6 +113,7 @@ def __init__( session, cfg, self._modal_host, + project_dir=project_dir, launch_config_path=launch_config_path, repo_root_example=repo_root_example, on_save_new_config=on_save_new_config, @@ -1022,7 +1022,7 @@ def seek_to(self, position_sec: float) -> None: seek_to(self.playback, target, self.duration_sec) def drop_song_marker(self) -> None: - """Drop or replace a song marker at the playhead; persist to project.yaml.""" + """Drop or replace a song marker at the playhead (session until Save).""" if self.session.timeline.recording: return t = current_sec(self.playback, self.duration_sec) @@ -1042,8 +1042,6 @@ def drop_song_marker(self) -> None: else: markers.selected_index = nearest_index(new_times, t) self.show_notification(f"Song marker {format_marker_time(t)}") - if self.project_dir is not None: - save_song_markers(self.project_dir, markers.times) selected = markers.selected_index if selected is not None: self._apply_focus_cursor( @@ -1075,8 +1073,6 @@ def _confirm_delete_song_marker(self, index: int) -> None: markers.selected_index = min(index, len(markers.times) - 1) elif markers.selected_index > index: markers.selected_index -= 1 - if self.project_dir is not None: - save_song_markers(self.project_dir, markers.times) self.show_notification( f"Song marker removed {format_marker_time(removed)}" ) diff --git a/docs/song-markers.md b/docs/song-markers.md index be0b7fc..615f2d1 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -10,7 +10,9 @@ Naming: use **song markers** everywhere in UI, docs, and code identifiers. Reser ## Scope and persistence -**Project-scoped, not viz config.** Song markers are definitive information about the song. They live with the project (alongside `project.yaml` and `signals.json`) and persist across many viz YAML snapshots. They are not duplicated or lost when saving `unnamed-N.yaml` or switching configs. +**Project-scoped, not viz config.** Song markers are definitive information about the song. On disk they live in `project.yaml` (alongside `signals.json`) and persist across many viz YAML snapshots. They are not written into viz YAML and are not lost when saving `unnamed-N.yaml` or switching configs. + +**Deferred write (session until Save).** Drop, replace, and delete update in-memory session state only. Markers are uncommitted until the user Saves (same Enter / config-row flow as viz config). A successful save writes the active viz YAML **and** flushes song markers to that project's `project.yaml`. Save-as-new still flushes markers to the same `project.yaml` (project-scoped). Marker edits mark the session dirty (config-row asterisk and quit-unsaved modal) the same way viz edits do. v1 is **manual only** — user-driven placement with streamlined UX. No auto-suggestion in the first release. diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 8d6f971..1e3bc41 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -25,6 +25,7 @@ scan_preset_playlist, ) from cleave.timeline import SlotCue, TimelineLane, canonicalize +from cleave.project import load_manifest, write_manifest from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.key_repeat import mod_shift from cleave.viz.playback import format_mmss @@ -114,6 +115,7 @@ def _make_controls( repo_root_example: Path = _REPO_ROOT_EXAMPLE, beat_times: tuple[float, ...] = (), bar_times: tuple[float, ...] = (), + project_dir: Path | None = None, ) -> TuningControls: preset_root = Path("/tmp/presets") cfg = make_test_cfg(slots, preset_root=preset_root, config_path=launch_config_path or _DEFAULT_ACTIVE_CONFIG) @@ -137,6 +139,7 @@ def _make_controls( preset_root=preset_root, playback=stub_playback_state(), duration_sec=120.0, + project_dir=project_dir, launch_config_path=launch_config_path, repo_root_example=repo_root_example, beat_times=beat_times, @@ -4400,4 +4403,154 @@ def test_up_down_syncs_song_marker_selected_index() -> None: RowKind.SONG_MARKER_ITEM, marker_index=1 ) assert controls.handle_keydown(_keydown(pygame.K_UP)) is True - assert markers.selected_index == 0 \ No newline at end of file + assert markers.selected_index == 0 + + +def _project_with_markers(tmp_path: Path, markers: tuple[float, ...] = ()) -> Path: + project = tmp_path / "song" + project.mkdir() + write_manifest( + project, + slug="song", + mix_filename="song.flac", + original_path=tmp_path / "source.flac", + demucs_model="htdemucs", + song_markers=markers, + ) + assert load_manifest(project).song_markers == markers + return project + + +def test_drop_song_marker_does_not_write_project_yaml(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, (10.0,)) + controls = _make_controls(("layer_1",), project_dir=project) + controls.session.song_markers.times = [10.0] + controls.clear_config_dirty() + controls.playback.player.seek(25.0) + controls.drop_song_marker() + assert controls.session.song_markers.times == [10.0, 25.0] + assert load_manifest(project).song_markers == (10.0,) + assert controls.config_dirty + + +def test_delete_song_marker_does_not_write_project_yaml(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, (5.0, 15.0, 25.0)) + controls = _make_controls(("layer_1",), project_dir=project) + markers = controls.session.song_markers + markers.times = [5.0, 15.0, 25.0] + markers.expanded = True + markers.selected_index = 1 + controls.clear_config_dirty() + controls.session.timeline.panel_open = True + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=1 + ) + assert controls.handle_keydown(_keydown(pygame.K_DELETE)) is True + controls.handle_modal_keydown(_keydown(pygame.K_RETURN)) + assert markers.times == [5.0, 25.0] + assert load_manifest(project).song_markers == (5.0, 15.0, 25.0) + assert controls.config_dirty + + +def test_marker_only_edit_marks_dirty_and_save_clears(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, ()) + saved_path = project / "unnamed-1.yaml" + controls = _make_controls(("layer_1",), project_dir=project) + controls._config_save._on_save_new_config = lambda: saved_path + assert not controls.config_dirty + + controls.playback.player.seek(12.5) + controls.drop_song_marker() + assert controls.config_dirty + assert load_manifest(project).song_markers == () + + view = controls.build_view_state(paused=False) + controls.focus_descriptor = _desc(view, _config_header_row(view)) + _choose_save_as_new(controls) + + assert not controls.config_dirty + assert load_manifest(project).song_markers == (12.5,) + + +def test_overwrite_save_flushes_song_markers(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, (8.0,)) + launch = project / "active.yaml" + launch.write_text("editor: {}\n", encoding="utf-8") + writes: list[Path] = [] + controls = _make_controls( + ("layer_1",), + project_dir=project, + launch_config_path=launch, + ) + controls.session.song_markers.times = [8.0] + controls.clear_config_dirty() + controls._config_save._on_overwrite_config = ( + lambda path: writes.append(path) or path.name + ) + + controls.playback.player.seek(40.0) + controls.drop_song_marker() + assert controls.config_dirty + assert load_manifest(project).song_markers == (8.0,) + + view = controls.build_view_state(paused=False) + controls.focus_descriptor = _desc(view, _config_header_row(view)) + _choose_overwrite(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + + assert writes == [launch] + assert load_manifest(project).song_markers == (8.0, 40.0) + assert not controls.config_dirty + + +def test_quit_discard_leaves_project_markers_unchanged(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, (10.0, 20.0)) + controls = _make_controls(("layer_1",), project_dir=project) + controls.session.song_markers.times = [10.0, 20.0] + controls.clear_config_dirty() + + controls.playback.player.seek(55.0) + controls.drop_song_marker() + assert controls.config_dirty + + assert controls.try_quit() is False + modal = controls.modal_host.view_state() + assert modal is not None + assert modal.kind == ModalKind.UNSAVED_QUIT + controls.handle_modal_keydown(_keydown(pygame.K_RIGHT)) + controls.handle_modal_keydown(_keydown(pygame.K_RETURN)) + assert controls.consume_pending_exit() is True + assert load_manifest(project).song_markers == (10.0, 20.0) + + +def test_save_as_new_flushes_markers_to_same_project_yaml(tmp_path: Path) -> None: + project = _project_with_markers(tmp_path, ()) + new_yaml = project / "unnamed-3.yaml" + controls = _make_controls(("layer_1",), project_dir=project) + controls._config_save._on_save_new_config = lambda: new_yaml + + controls.playback.player.seek(3.0) + controls.drop_song_marker() + view = controls.build_view_state(paused=False) + controls.focus_descriptor = _desc(view, _config_header_row(view)) + _choose_save_as_new(controls) + + assert load_manifest(project).song_markers == (3.0,) + assert controls._config_save.active_config_path == new_yaml + assert not controls.config_dirty + + +def test_song_markers_expanded_does_not_mark_dirty() -> None: + controls = _make_controls(("layer_1",)) + assert not controls.config_dirty + controls.session.song_markers.expanded = True + assert not controls.config_dirty + + +def test_no_project_dir_marker_edit_still_marks_dirty() -> None: + controls = _make_controls(("layer_1",), project_dir=None) + controls.playback.player.seek(1.0) + controls.drop_song_marker() + assert controls.config_dirty + controls.clear_config_dirty() + assert not controls.config_dirty \ No newline at end of file From bfabd3710494ac6fecfed21e0a894243fb83b495 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 20:48:08 +0100 Subject: [PATCH 04/12] Re-order UI rows --- cleave/viz/controls.py | 35 +++++++--- cleave/viz/row_sections.py | 10 +-- docs/song-markers.md | 4 +- tests/cleave/viz/test_controls.py | 68 +++++++++++++++---- tests/cleave/viz/test_input_dispatch.py | 7 +- tests/cleave/viz/test_view_state_structure.py | 2 +- 6 files changed, 89 insertions(+), 37 deletions(-) diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 2e508dd..b1d0371 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -14,7 +14,7 @@ from cleave.config_schema import PRESET_SWITCHING_MODES, PRESET_SWITCHING_SCOPES from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES -from cleave.song_markers import format_marker_time, nearest_index, place_marker +from cleave.song_markers import format_marker_time, place_marker from cleave.viz.config_save import ConfigSaveController from cleave.viz.preset_curation_controls import PresetCurationController from cleave.viz.key_repeat import KeyRepeatController, add_current_preset_key_pressed, delete_key_pressed, mod_ctrl, mod_shift @@ -1027,12 +1027,35 @@ def drop_song_marker(self) -> None: return t = current_sec(self.playback, self.duration_sec) markers = self.session.song_markers + prior_selected_time: float | None = None + if ( + markers.selected_index is not None + and 0 <= markers.selected_index < len(markers.times) + ): + prior_selected_time = markers.times[markers.selected_index] new_times, replaced_index, replaced_time = place_marker(markers.times, t) markers.times = list(new_times) markers.expanded = True self.session.timeline.panel_open = True - if replaced_index is not None: + # Never activate the newly placed marker; keep prior selection by time. + if prior_selected_time is None: + if markers.selected_index is not None and ( + markers.selected_index < 0 + or markers.selected_index >= len(markers.times) + ): + markers.selected_index = None + elif ( + replaced_time is not None + and replaced_time == prior_selected_time + and replaced_index is not None + ): markers.selected_index = replaced_index + else: + try: + markers.selected_index = new_times.index(prior_selected_time) + except ValueError: + markers.selected_index = None + if replaced_index is not None: assert replaced_time is not None self.show_notification( f"Song marker replaced " @@ -1040,15 +1063,7 @@ def drop_song_marker(self) -> None: f"{format_marker_time(new_times[replaced_index])}" ) else: - markers.selected_index = nearest_index(new_times, t) self.show_notification(f"Song marker {format_marker_time(t)}") - selected = markers.selected_index - if selected is not None: - self._apply_focus_cursor( - MainFocus( - RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=selected) - ) - ) def _delete_song_marker(self, index: int) -> None: markers = self.session.song_markers diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 0e9c1bd..edb4f34 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -666,13 +666,13 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RENDER_TIMELINE_SECTION_KINDS = frozenset( { RowKind.RENDER_TIMELINE_HEADER, + RowKind.SONG_MARKERS_HEADER, + RowKind.SONG_MARKER_ITEM, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_BAR_PHASE, RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.SONG_MARKERS_HEADER, - RowKind.SONG_MARKER_ITEM, } ) @@ -710,13 +710,13 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TRACK_EFFECT] = 2 depths[RowKind.TRACK_USER_PRESET_ITEM] = 7 depths[RowKind.TRACK_USER_PRESET_ADD] = 3 + depths[RowKind.SONG_MARKERS_HEADER] = 1 + depths[RowKind.SONG_MARKER_ITEM] = 2 depths[RowKind.TIMELINE_PRESETS] = 1 depths[RowKind.TIMELINE_BAR_PHASE] = 1 depths[RowKind.TIMELINE_BAR_GRID] = 1 depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 depths[RowKind.TIMELINE_SNAP_TO_BARS] = 1 - depths[RowKind.SONG_MARKERS_HEADER] = 1 - depths[RowKind.SONG_MARKER_ITEM] = 2 return depths @@ -830,12 +830,12 @@ def append_render_section_rows( node.panel_anchor.header_kind == RowKind.RENDER_TIMELINE_HEADER and state.render_timeline.expanded ): + append_expand_section_rows(row_list, SONG_MARKERS_SECTION, state) row_list.append(RowDescriptor(RowKind.TIMELINE_PRESETS)) row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_PHASE)) row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_GRID)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) - append_expand_section_rows(row_list, SONG_MARKERS_SECTION, state) def append_track_section_rows( diff --git a/docs/song-markers.md b/docs/song-markers.md index 615f2d1..56270b2 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -36,12 +36,12 @@ Build UI and editing only. No snap, preset generation, or beat-phase logic yet. ### Panel -- New expandable child under **Render: Timeline**: header label `song markers (N)` with expand arrow (e.g. `song markers (4)`). +- First expandable child under **Render: Timeline** (above apply a preset): header label `song markers (N)` with expand arrow (e.g. `song markers (4)`). - List of marker times in `[mm:ss.ms]` format. ### List interaction -- **Sticky selection:** the highlighted list row stays put until the user moves focus (Up/Down), deletes it, or drops a marker (select the new or replaced marker). Do **not** auto-follow the playhead. +- **Sticky selection:** the highlighted list row stays put until the user moves focus (Up/Down) or deletes it. Drop/replace does **not** select the new marker; prior selection is preserved (remapped by time if the list shifts). Do **not** auto-follow the playhead. - **Enter** on a highlighted song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. - **Delete** prompts a confirm modal, then removes the highlighted song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 1e3bc41..6192d00 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -643,13 +643,16 @@ def test_disabled_render_timeline_can_open_panel() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) presets_row = view.layout.find_by_kind(RowKind.TIMELINE_PRESETS) assert _row_text(view, header_row).endswith(" ▼") + assert markers_row in view.layout.visible_indices(view) + assert markers_row in view.layout.navigable_indices(view) assert presets_row in view.layout.visible_indices(view) assert presets_row in view.layout.navigable_indices(view) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_descriptor == _desc(view, presets_row) + assert controls.focus_descriptor == _desc(view, markers_row) controls.focus_descriptor = _desc(view, header_row) controls.handle_keydown(_keydown(pygame.K_LEFT)) @@ -2099,15 +2102,19 @@ def test_render_timeline_down_enters_submenu() -> None: controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) + markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) presets_row = view.layout.find_by_kind(RowKind.TIMELINE_PRESETS) phase_row = view.layout.find_by_kind(RowKind.TIMELINE_BAR_PHASE) grid_row = view.layout.find_by_kind(RowKind.TIMELINE_BAR_GRID) snap_beats_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) - markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, markers_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_descriptor == _desc(view, presets_row) assert not isinstance(controls.focus_cursor, TimelineFocus) @@ -2128,10 +2135,6 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, snap_bars_row) assert not isinstance(controls.focus_cursor, TimelineFocus) - controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_descriptor == _desc(view, markers_row) - assert not isinstance(controls.focus_cursor, TimelineFocus) - controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2280,7 +2283,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) - assert controls.focus_descriptor == RowDescriptor(RowKind.SONG_MARKERS_HEADER) + assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -2289,8 +2292,8 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) - controls.focus_descriptor = _desc(view, markers_row) + snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) + controls.focus_descriptor = _desc(view, snap_bars_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) @@ -2384,12 +2387,12 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: view = controls.build_view_state(paused=False) for kind in ( RowKind.RENDER_TIMELINE_HEADER, + RowKind.SONG_MARKERS_HEADER, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_BAR_PHASE, RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.SONG_MARKERS_HEADER, ): row = view.layout.find_by_kind(kind) assert _row_value_color(view, row) == DISABLED, kind @@ -4296,22 +4299,57 @@ def test_ctrl_f_b_ignored_on_non_preset_rows() -> None: mock_curation.prompt_blacklist.assert_not_called() -def test_drop_song_marker_opens_panel_focuses_item() -> None: +def test_drop_song_marker_does_not_select_or_steal_focus() -> None: controls = _make_controls(("layer_1",)) + prior_focus = RowDescriptor(RowKind.SETTINGS_HEADER) + controls.focus_descriptor = prior_focus controls.playback.player.seek(15.0) controls.drop_song_marker() markers = controls.session.song_markers assert markers.times == [15.0] - assert markers.selected_index == 0 + assert markers.selected_index is None assert markers.expanded is True assert controls.session.timeline.panel_open is True - assert controls.focus_descriptor == RowDescriptor( - RowKind.SONG_MARKER_ITEM, marker_index=0 - ) + assert controls.focus_descriptor == prior_focus view = controls.build_view_state(paused=False) assert RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=0) in view.layout.rows +def test_drop_song_marker_insert_preserves_prior_selection_by_time() -> None: + """Insert before the selected marker remaps selected_index by prior time.""" + controls = _make_controls(("layer_1",)) + markers = controls.session.song_markers + markers.times = [20.0, 40.0] + markers.selected_index = 1 + controls.playback.player.seek(10.0) + controls.drop_song_marker() + assert markers.times == [10.0, 20.0, 40.0] + # Prior selection was 40.0 at index 1; after insert it is index 2. + assert markers.selected_index == 2 + + +def test_drop_song_marker_replace_non_selected_does_not_select_replaced() -> None: + controls = _make_controls(("layer_1",)) + markers = controls.session.song_markers + markers.times = [10.0, 40.0] + markers.selected_index = 0 + controls.playback.player.seek(41.0) + controls.drop_song_marker() + assert markers.times == [10.0, 41.0] + assert markers.selected_index == 0 + + +def test_drop_song_marker_replace_selected_keeps_that_slot() -> None: + controls = _make_controls(("layer_1",)) + markers = controls.session.song_markers + markers.times = [10.0, 40.0] + markers.selected_index = 1 + controls.playback.player.seek(41.0) + controls.drop_song_marker() + assert markers.times == [10.0, 41.0] + assert markers.selected_index == 1 + + def test_enter_on_song_marker_seeks() -> None: controls = _make_controls(("layer_1",)) controls.session.timeline.panel_open = True diff --git a/tests/cleave/viz/test_input_dispatch.py b/tests/cleave/viz/test_input_dispatch.py index 375aa0a..a1a6d3a 100644 --- a/tests/cleave/viz/test_input_dispatch.py +++ b/tests/cleave/viz/test_input_dispatch.py @@ -127,6 +127,7 @@ def test_ctrl_s_opens_save_dialog_from_timeline_context() -> None: def test_ctrl_enter_drops_song_marker_from_timeline_context() -> None: runtime = _make_runtime() + prior_focus = runtime.controls.focus_cursor runtime.controls.playback.player.seek(12.5) assert dispatch_keydown( keydown(pygame.K_RETURN, mod=pygame.KMOD_CTRL), @@ -134,12 +135,10 @@ def test_ctrl_enter_drops_song_marker_from_timeline_context() -> None: ) is True markers = runtime.seed.session.song_markers assert markers.times == [12.5] - assert markers.selected_index == 0 + assert markers.selected_index is None assert markers.expanded is True assert runtime.seed.session.timeline.panel_open is True - assert runtime.controls.focus_descriptor == RowDescriptor( - RowKind.SONG_MARKER_ITEM, marker_index=0 - ) + assert runtime.controls.focus_cursor == prior_focus def test_ctrl_enter_refused_while_recording() -> None: diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 8a14aad..9931ae9 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -171,11 +171,11 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: snap_beats_idx = view_open.layout.rows.index(snap_beats) snap_bars_idx = view_open.layout.rows.index(snap_bars) markers_idx = view_open.layout.rows.index(markers_header) + assert presets_idx == markers_idx + 1 assert bar_phase_idx == presets_idx + 1 assert bar_grid_idx == bar_phase_idx + 1 assert snap_beats_idx == bar_grid_idx + 1 assert snap_bars_idx == snap_beats_idx + 1 - assert markers_idx == snap_bars_idx + 1 session.timeline.panel_open = False view_closed_again = builder.build(paused=False) From 6f5bac48cb80d0072efd65a7099a0110749e2514 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 20:58:16 +0100 Subject: [PATCH 05/12] Highlight timeline song markers only when the song marker row is highlighted. --- cleave/viz/help_content.py | 5 +++- cleave/viz/layer_visibility.py | 14 +++++++++++- docs/song-markers.md | 8 +++---- tests/cleave/viz/test_layer.py | 42 +++++++++++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index eda0e08..a0408cd 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -36,13 +36,16 @@ def description_section( KEYBOARD_CONTROLS_SECTION_TITLE = "Keyboard Controls" NAVIGATION_SECTION = HelpSection( - "Navigation", + "Navigation / Global", ( ("Up/Down", "move row"), ("Ctrl + Up/Down", "jump section"), ("ESC", "hide UI"), ("Ctrl + Q", "quit"), ("Ctrl + S", "save config"), + ("Ctrl + Enter", "place song marker"), + ("Ctrl + f", "favourite preset"), + ("Ctrl + b", "blacklist preset"), ), ) diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index db45d4a..9810cb5 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -18,9 +18,11 @@ ) from cleave.viz.focus_nav import ( FocusCursor, + MainFocus, cursor_timeline_row, cursor_timeline_submenu_focused, ) +from cleave.viz.row_semantics import RowKind from cleave.viz.session import TuningSession from cleave.viz.timeline_overlay import TimelineViewState, prune_expired_arm_flashes @@ -28,6 +30,16 @@ from cleave.viz.layer import StemLayer +def focused_song_marker_index(focus_cursor: FocusCursor | None) -> int | None: + """Strip highlight index only while a song-marker list row has main focus.""" + if not isinstance(focus_cursor, MainFocus): + return None + desc = focus_cursor.descriptor + if desc.kind != RowKind.SONG_MARKER_ITEM: + return None + return desc.marker_index + + def _lane_for_slot(session: TuningSession, slot: str) -> TimelineLane: return session.timeline.lanes.get(slot) or empty_lane() @@ -261,5 +273,5 @@ def build_timeline_view_state( bar_times, beat_times, tl.bar_phase_offset ), song_marker_times=tuple(session.song_markers.times), - selected_song_marker_index=session.song_markers.selected_index, + selected_song_marker_index=focused_song_marker_index(focus_cursor), ) diff --git a/docs/song-markers.md b/docs/song-markers.md index 56270b2..b7a5f11 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -41,15 +41,15 @@ Build UI and editing only. No snap, preset generation, or beat-phase logic yet. ### List interaction -- **Sticky selection:** the highlighted list row stays put until the user moves focus (Up/Down) or deletes it. Drop/replace does **not** select the new marker; prior selection is preserved (remapped by time if the list shifts). Do **not** auto-follow the playhead. -- **Enter** on a highlighted song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. -- **Delete** prompts a confirm modal, then removes the highlighted song marker. +- Focus on a song-marker list row highlights that row and the matching strip tick. Drop/replace does **not** move focus onto the new marker; if a marker row was already focused, that selection is remapped by time when the list shifts. Do **not** auto-follow the playhead. +- **Enter** on a focused song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. +- **Delete** prompts a confirm modal, then removes the focused song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. ### Timeline display - Song markers render as **red 2px vertical lines** spanning all lanes (global structure, not per-layer visibility cues). -- The highlighted song marker uses a **4px yellow/orange** line on the strip. +- The **4px yellow/orange** strip highlight appears only while focus is on that marker's `SONG_MARKER_ITEM` list row. When focus is elsewhere (other panel rows, timeline strip lanes, etc.), all song markers draw as the normal red 2px lines. ### Placement rules diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index da268dd..e5cb1c7 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -644,15 +644,55 @@ def test_build_timeline_view_state_empty_signals_empty_bar_grid() -> None: def test_build_timeline_view_state_includes_song_markers() -> None: + from cleave.viz.focus_nav import MainFocus + session = _session( layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, timeline_enabled=True, ) session.song_markers.times = [12.5, 64.0] session.song_markers.selected_index = 1 + # Sticky session selected_index alone must not highlight the strip. state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) assert state.song_marker_times == (12.5, 64.0) - assert state.selected_song_marker_index == 1 + assert state.selected_song_marker_index is None + + focus = MainFocus(RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=1)) + state_focused = build_timeline_view_state( + session, + position_sec=0.0, + duration_sec=60.0, + focus_cursor=focus, + ) + assert state_focused.selected_song_marker_index == 1 + + +def test_build_timeline_view_state_song_marker_highlight_requires_marker_focus() -> None: + from cleave.viz.focus_nav import MainFocus, TimelineFocus + + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + ) + session.song_markers.times = [12.5, 64.0] + session.song_markers.selected_index = 0 + + other_main = MainFocus(RowDescriptor(RowKind.SONG_MARKERS_HEADER)) + assert ( + build_timeline_view_state( + session, position_sec=0.0, duration_sec=60.0, focus_cursor=other_main + ).selected_song_marker_index + is None + ) + assert ( + build_timeline_view_state( + session, + position_sec=0.0, + duration_sec=60.0, + focus_cursor=TimelineFocus(0), + ).selected_song_marker_index + is None + ) def test_build_timeline_view_state_default_song_markers_empty() -> None: From f0bb7d1cfa16066dbba1c4e907cf24ad1faeb654 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 22:03:59 +0100 Subject: [PATCH 06/12] Update plan --- docs/song-markers.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/song-markers.md b/docs/song-markers.md index b7a5f11..f13fb5a 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -67,6 +67,14 @@ Song markers, list editing, seek-to-marker, and timeline drawing only. Value on Add a **snap to song markers** action (button or row under the timeline section). +To facilitate an easy workflow for the user... + +1) Apply a preset to the timeline +2) Snap to bars +3) Snap to song markers + +This gives a really simple and easy way for the user to setup a timeline that works with variation and has a good connection to the song. + ### Behavior For **each song marker**, pull the **closest** timeline lane cue on the selected layer(s) toward that song marker. @@ -82,7 +90,8 @@ Modal with layer scope: - All layers - Cancel -Include proximity (and any other snap options) in the modal or an adjacent control so users can tune behavior before applying. Destructive multi-lane moves should be explicit; no silent wide snaps. +Include proximity in adjacent control so users can tune behavior before applying. Destructive multi-lane moves should be explicit; no silent wide snaps. + --- From 0cc412af923f40bb4a4585571ee03d2aa153bf56 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 22:36:06 +0100 Subject: [PATCH 07/12] Phase 2: Implement snap to song markers feature --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/timeline.py | 95 +++++++ cleave/viz/controls.py | 3 + cleave/viz/row_fields.py | 39 +++ cleave/viz/row_sections.py | 6 + cleave/viz/row_semantics.py | 28 ++ cleave/viz/session.py | 1 + cleave/viz/timeline_snap_controls.py | 95 ++++++- cleave/viz/tuning_panel_draw.py | 3 + cleave/viz/tuning_view_state.py | 3 + docs/song-markers.md | 31 +-- .../cleave/test_timeline_song_marker_snap.py | 239 ++++++++++++++++++ tests/cleave/viz/test_controls.py | 191 +++++++++++++- tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_semantics.py | 3 + tests/cleave/viz/test_view_state_structure.py | 12 + 16 files changed, 727 insertions(+), 26 deletions(-) create mode 100644 tests/cleave/test_timeline_song_marker_snap.py diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 44cf2d2..365961d 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -48,7 +48,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p **Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). -Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; `panel_open` is UI-only. No sub-rows in v1. When the timeline is locked, its main-panel children (`TIMELINE_PRESETS`, bar phase, bar grid, snap to beats/bars) are blocked and skipped in navigation, and the strip stays openable and seekable (Seek Left/Right) but blocks arm, record, override, number-key visibility, Space preview, and Ctrl+Space record ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. +Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; `panel_open` is UI-only. No sub-rows in v1. When the timeline is locked, its main-panel children (`TIMELINE_PRESETS`, bar phase, bar grid, snap to beats/bars/song markers, marker proximity) are blocked and skipped in navigation, and the strip stays openable and seekable (Seek Left/Right) but blocks arm, record, override, number-key visibility, Space preview, and Ctrl+Space record ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. ## Timeline panel diff --git a/cleave/timeline.py b/cleave/timeline.py index df41abc..ffb6818 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -4,6 +4,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field +from typing import Literal import numpy as np @@ -305,3 +306,97 @@ def snap_t(t: float) -> float: baseline=lane.baseline, cues=canonicalize(lane.baseline, snapped), ) + + +SongMarkerSnapMode = Literal["each_layer", "closest_wins"] + + +def snap_lanes_to_song_markers( + lanes: Mapping[str, TimelineLane], + song_marker_times: Sequence[float], + *, + proximity: float, + layer_z_order: Sequence[str], + slots: Sequence[str], + mode: SongMarkerSnapMode = "each_layer", +) -> tuple[dict[str, TimelineLane], int]: + """Move closest cues within ``proximity`` onto song markers; exclusive claims. + + Markers are processed in ascending time. Each cue moves at most once. + ``each_layer`` claims per lane; ``closest_wins`` shares one claim set across + ``slots`` (tie: earlier cue time, then earlier ``layer_z_order`` index). + + Returns updated lanes for ``slots`` (other keys unchanged) and move count. + """ + result = {slot: copy_lane(lane) for slot, lane in lanes.items()} + if proximity <= 0 or not song_marker_times or not slots: + return result, 0 + + markers = sorted(float(t) for t in song_marker_times) + target_slots = [slot for slot in slots if slot] + if not target_slots: + return result, 0 + + for slot in target_slots: + if slot not in result: + result[slot] = empty_lane() + + working: dict[str, list[SlotCue]] = { + slot: list(result[slot].cues) for slot in target_slots + } + moved = 0 + + if mode == "each_layer": + for slot in target_slots: + claimed: set[int] = set() + cues = working[slot] + for marker in markers: + best_i: int | None = None + best_key: tuple[float, float] | None = None + for i, cue in enumerate(cues): + if i in claimed: + continue + dist = abs(cue.t - marker) + if dist > proximity: + continue + key = (dist, cue.t) + if best_key is None or key < best_key: + best_i = i + best_key = key + if best_i is None: + continue + old = cues[best_i] + cues[best_i] = SlotCue(t=marker, visible=old.visible) + claimed.add(best_i) + moved += 1 + else: + z_index = {slot: i for i, slot in enumerate(layer_z_order)} + claimed_pairs: set[tuple[str, int]] = set() + for marker in markers: + best: tuple[float, float, int, str, int] | None = None + for slot in target_slots: + cues = working[slot] + for i, cue in enumerate(cues): + if (slot, i) in claimed_pairs: + continue + dist = abs(cue.t - marker) + if dist > proximity: + continue + key = (dist, cue.t, z_index.get(slot, len(layer_z_order)), slot, i) + if best is None or key < best: + best = key + if best is None: + continue + _dist, _t, _z, slot, cue_i = best + old = working[slot][cue_i] + working[slot][cue_i] = SlotCue(t=marker, visible=old.visible) + claimed_pairs.add((slot, cue_i)) + moved += 1 + + for slot in target_slots: + baseline = result[slot].baseline + result[slot] = TimelineLane( + baseline=baseline, + cues=canonicalize(baseline, working[slot]), + ) + return result, moved diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index b1d0371..e6cb85a 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -434,6 +434,9 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if kind == RowKind.TIMELINE_SNAP_TO_BARS: self._timeline_snap.prompt_bars() return True + if kind == RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: + self._timeline_snap.prompt_song_markers() + return True if kind == RowKind.TRACK_PRESET_DIR: slot = self.focus_descriptor.slot if slot is not None: diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 9d9dfba..d004895 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -123,6 +123,35 @@ def _apply_timeline_bar_grid( controls.session.timeline.show_bar_grid = forward +_SONG_MARKER_SNAP_PROXIMITY_MIN = 0.5 +_SONG_MARKER_SNAP_PROXIMITY_MAX = 30.0 +_SONG_MARKER_SNAP_PROXIMITY_STEP = 0.5 + + +def _format_timeline_snap_marker_proximity( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return f"{state.render_timeline.song_marker_snap_proximity:.1f}s" + + +def _apply_timeline_snap_marker_proximity( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + tl = controls.session.timeline + delta = _SONG_MARKER_SNAP_PROXIMITY_STEP if forward else -_SONG_MARKER_SNAP_PROXIMITY_STEP + tl.song_marker_snap_proximity = max( + _SONG_MARKER_SNAP_PROXIMITY_MIN, + min( + _SONG_MARKER_SNAP_PROXIMITY_MAX, + round(tl.song_marker_snap_proximity + delta, 1), + ), + ) + + def _apply_settings_preview_quality( controls: TuningControls, _desc: RowDescriptor, @@ -1346,6 +1375,16 @@ def _apply_transport( panel_label="snap to bars", present_style=RowPresentStyle.FULL_LINE, ), + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY: RowFieldDef( + panel_label="snap marker proximity", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_timeline_snap_marker_proximity, + apply_horizontal=_apply_timeline_snap_marker_proximity, + ), + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowFieldDef( + panel_label="snap to song markers", + present_style=RowPresentStyle.FULL_LINE, + ), RowKind.SONG_MARKERS_HEADER: RowFieldDef( panel_label="song markers", present_style=RowPresentStyle.EXPAND_SUBHEADER, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index edb4f34..2242e97 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -673,6 +673,8 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, } ) @@ -717,6 +719,8 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TIMELINE_BAR_GRID] = 1 depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 depths[RowKind.TIMELINE_SNAP_TO_BARS] = 1 + depths[RowKind.TIMELINE_SNAP_MARKER_PROXIMITY] = 1 + depths[RowKind.TIMELINE_SNAP_TO_SONG_MARKERS] = 1 return depths @@ -836,6 +840,8 @@ def append_render_section_rows( row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_GRID)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS)) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 03249a8..cdaa072 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -79,6 +79,8 @@ class RowKind(Enum): TIMELINE_BAR_GRID = auto() TIMELINE_SNAP_TO_BEATS = auto() TIMELINE_SNAP_TO_BARS = auto() + TIMELINE_SNAP_MARKER_PROXIMITY = auto() + TIMELINE_SNAP_TO_SONG_MARKERS = auto() SONG_MARKERS_HEADER = auto() SONG_MARKER_ITEM = auto() SETTINGS_HEADER = auto() @@ -751,6 +753,32 @@ class RowBehavior: "(irreversible).", ), ), + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY: RowBehavior( + RowAffordance.VALUE_STEP, + navigable=True, + repeatable=True, + blocked_by_section_lock=True, + help_title="Song marker snap proximity", + help_entries=( + ("Left", "decrease proximity"), + ("Right", "increase proximity"), + ), + help_description=( + "Maximum distance for snap to song markers.", + "Tune before Enter on snap to song markers.", + ), + ), + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowBehavior( + RowAffordance.ACTION, + navigable=True, + blocked_by_section_lock=True, + help_title="Snap to song markers", + help_entries=(("Enter", "snap cues to song markers"),), + help_description=( + "Pull closest cues within proximity onto song markers", + "(irreversible; choose layer scope in the modal).", + ), + ), RowKind.SONG_MARKERS_HEADER: RowBehavior( RowAffordance.EXPAND, is_sub_header=True, diff --git a/cleave/viz/session.py b/cleave/viz/session.py index ee0f728..d3d5dbe 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -148,6 +148,7 @@ class TimelineRuntime: arm_flash_start_ms: dict[str, int] = field(default_factory=dict) bar_phase_offset: int = 0 show_bar_grid: bool = False + song_marker_snap_proximity: float = 5.0 def default_timeline_runtime() -> TimelineRuntime: diff --git a/cleave/viz/timeline_snap_controls.py b/cleave/viz/timeline_snap_controls.py index 5f2519e..3e02cad 100644 --- a/cleave/viz/timeline_snap_controls.py +++ b/cleave/viz/timeline_snap_controls.py @@ -1,16 +1,25 @@ -"""Timeline beat/bar-snap confirm modal and cue rewrite.""" +"""Timeline beat/bar/song-marker snap confirm modal and cue rewrite.""" from __future__ import annotations from collections.abc import Callable, Sequence -from cleave.timeline import empty_lane, snap_lane_to_beats -from cleave.viz.modal import ModalHost +from cleave.timeline import ( + SongMarkerSnapMode, + empty_lane, + snap_lane_to_beats, + snap_lanes_to_song_markers, +) +from cleave.viz.modal import ModalHost, ModalOption from cleave.viz.session import TuningSession +_CANCEL_LABEL = "Cancel" +_ALL_CLOSEST_LABEL = "All layers (closest wins)" +_EACH_LAYER_LABEL = "Each layer" + class TimelineSnapController: - """Prompt for and apply beat/bar snapping to committed timeline cues.""" + """Prompt for and apply beat/bar/song-marker snapping to committed cues.""" def __init__( self, @@ -43,6 +52,84 @@ def prompt_bars(self) -> None: done_msg="Snapped timeline cues to bars", ) + def prompt_song_markers(self) -> None: + tl = self.session.timeline + if tl.locked: + return + if tl.recording: + return + markers = tuple(self.session.song_markers.times) + if not markers: + self._notify("No song markers to snap to") + return + if not any(lane.cues for lane in tl.lanes.values()): + self._notify("No timeline cues to snap") + return + proximity = tl.song_marker_snap_proximity + message = ( + f"Snap closest cues within {proximity:.1f}s to song markers?" + ) + dismiss = lambda: None + options: list[ModalOption] = [] + for i, slot in enumerate(self.session.layer_z_order): + label = f"Layer {i + 1}" + options.append( + ModalOption( + label, + lambda s=slot: self._snap_song_markers( + slots=(s,), + mode="each_layer", + ), + ) + ) + options.append( + ModalOption( + _ALL_CLOSEST_LABEL, + lambda: self._snap_song_markers( + slots=tuple(self.session.layer_z_order), + mode="closest_wins", + ), + ) + ) + options.append( + ModalOption( + _EACH_LAYER_LABEL, + lambda: self._snap_song_markers( + slots=tuple(self.session.layer_z_order), + mode="each_layer", + ), + ) + ) + options.append(ModalOption(_CANCEL_LABEL, dismiss)) + self._modal.prompt_choice(message, options, on_dismiss=dismiss) + + def _snap_song_markers( + self, + *, + slots: Sequence[str], + mode: SongMarkerSnapMode, + ) -> None: + tl = self.session.timeline + if not any((tl.lanes.get(slot) or empty_lane()).cues for slot in slots): + self._notify("No timeline cues to snap") + return + updated, moved = snap_lanes_to_song_markers( + tl.lanes, + self.session.song_markers.times, + proximity=tl.song_marker_snap_proximity, + layer_z_order=tuple(self.session.layer_z_order), + slots=slots, + mode=mode, + ) + for slot in slots: + if slot in updated: + tl.lanes[slot] = updated[slot] + if moved == 0: + self._notify("No cues within snap proximity") + return + noun = "cue" if moved == 1 else "cues" + self._notify(f"Snapped {moved} {noun} to song markers") + def _prompt( self, grid: Sequence[float], diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index 17306ed..19853fc 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -549,6 +549,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, }: if kind == RowKind.CONFIG_HEADER and state.solo_active: return DISABLED @@ -913,6 +914,7 @@ def _estimate_row_content_width( RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, } ): label = _row_text(state, index) @@ -1403,6 +1405,7 @@ def _build_row_at_index( RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, } ): label = _row_text(state, index) diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index d2e0f36..81400d9 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -132,6 +132,7 @@ class RenderTimelineBlock: expanded: bool = False bar_phase_offset: int = 0 show_bar_grid: bool = False + song_marker_snap_proximity: float = 5.0 locked: bool = False song_markers_expanded: bool = False song_marker_times: tuple[float, ...] = () @@ -431,6 +432,7 @@ def _build_structure( expanded=tl.panel_open, bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, + song_marker_snap_proximity=tl.song_marker_snap_proximity, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), ) @@ -580,6 +582,7 @@ def build( expanded=tl.panel_open, bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, + song_marker_snap_proximity=tl.song_marker_snap_proximity, locked=tl.locked, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), diff --git a/docs/song-markers.md b/docs/song-markers.md index f13fb5a..0718735 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -65,33 +65,30 @@ Song markers, list editing, seek-to-marker, and timeline drawing only. Value on ## Phase 2 — Snap to song markers -Add a **snap to song markers** action (button or row under the timeline section). +Add a **snap to song markers** action under the timeline section (after snap to bars), with an adjacent proximity control. -To facilitate an easy workflow for the user... +Workflow: -1) Apply a preset to the timeline -2) Snap to bars -3) Snap to song markers - -This gives a really simple and easy way for the user to setup a timeline that works with variation and has a good connection to the song. +1. Apply a preset to the timeline +2. Snap to bars +3. Snap to song markers ### Behavior -For **each song marker**, pull the **closest** timeline lane cue on the selected layer(s) toward that song marker. - -- If no lane cue lies within the proximity window, **no-op** for that song marker. -- Default proximity window: **5 seconds** (configurable). +For each song marker (ascending time), pull the **closest** unclaimed timeline cue within proximity onto that marker. Each cue moves at most once (**exclusive assignment**). If no unclaimed cue lies within proximity, that marker is a no-op. Visibility flags are preserved; only cue times change. Lanes are canonicalized after moves. -### UI +Default proximity: **5.0s** (session-only; Left/Right on the proximity row, clamp 0.5..30.0, step 0.5). -Modal with layer scope: +### Layer scope modal -- Layer 1, Layer 2, … (per active layer) -- All layers -- Cancel +Enter on **snap to song markers** opens a choice modal: -Include proximity in adjacent control so users can tune behavior before applying. Destructive multi-lane moves should be explicit; no silent wide snaps. +- **Layer 1** … **Layer N** — only that lane (one option per `layer_z_order` entry) +- **All layers (closest wins)** — for each marker, among cues in all lanes within proximity, move the single closest cue (tie: earlier cue time, then earlier layer in z-order) +- **Each layer** — for each lane independently, each marker pulls that lane's closest unclaimed cue within proximity (claims are per-lane) +- **Cancel** +Destructive multi-lane moves stay explicit via the modal; no silent wide snaps. --- diff --git a/tests/cleave/test_timeline_song_marker_snap.py b/tests/cleave/test_timeline_song_marker_snap.py new file mode 100644 index 0000000..a70a071 --- /dev/null +++ b/tests/cleave/test_timeline_song_marker_snap.py @@ -0,0 +1,239 @@ +"""Tests for exclusive snap of timeline cues to song markers.""" + +from __future__ import annotations + +from cleave.timeline import ( + SlotCue, + TimelineLane, + snap_lanes_to_song_markers, +) + + +def _lane( + baseline: bool | None, + *transitions: tuple[float, bool], +) -> TimelineLane: + return TimelineLane( + baseline=baseline, + cues=[SlotCue(t=t, visible=v) for t, v in transitions], + ) + + +def test_proximity_no_op_when_outside_window() -> None: + lanes = {"layer_1": _lane(None, (10.0, True))} + result, moved = snap_lanes_to_song_markers( + lanes, + (0.0,), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 0 + assert result["layer_1"].cues == [SlotCue(t=10.0, visible=True)] + + +def test_proximity_zero_no_op() -> None: + lanes = {"layer_1": _lane(None, (1.0, True))} + result, moved = snap_lanes_to_song_markers( + lanes, + (1.0,), + proximity=0.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 0 + assert result["layer_1"].cues == [SlotCue(t=1.0, visible=True)] + + +def test_empty_markers_no_op() -> None: + lanes = {"layer_1": _lane(None, (1.0, True))} + result, moved = snap_lanes_to_song_markers( + lanes, + (), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 0 + assert result["layer_1"].cues == [SlotCue(t=1.0, visible=True)] + + +def test_closest_pick_within_proximity() -> None: + lanes = {"layer_1": _lane(None, (3.0, True), (8.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 1 + assert result["layer_1"].cues == [ + SlotCue(t=5.0, visible=True), + SlotCue(t=8.0, visible=False), + ] + + +def test_distance_tie_prefers_earlier_cue_time() -> None: + lanes = {"layer_1": _lane(None, (4.0, True), (6.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 1 + assert result["layer_1"].cues == [ + SlotCue(t=5.0, visible=True), + SlotCue(t=6.0, visible=False), + ] + + +def test_exclusive_claim_later_marker_skips_claimed_cue() -> None: + lanes = {"layer_1": _lane(None, (5.0, True), (12.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.5, 6.0), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + # First marker at 5.5 claims cue at 5.0; second marker at 6.0 has no + # remaining cue within 5s of an unclaimed cue (12.0 is 6s away). + assert moved == 1 + assert result["layer_1"].cues == [ + SlotCue(t=5.5, visible=True), + SlotCue(t=12.0, visible=False), + ] + + +def test_exclusive_claim_second_marker_takes_next_cue() -> None: + lanes = {"layer_1": _lane(None, (5.0, True), (11.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.5, 10.0), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 2 + assert result["layer_1"].cues == [ + SlotCue(t=5.5, visible=True), + SlotCue(t=10.0, visible=False), + ] + + +def test_each_layer_vs_closest_wins() -> None: + lanes = { + "layer_1": _lane(None, (4.5, True)), + "layer_2": _lane(None, (4.8, False)), + } + z_order = ("layer_1", "layer_2") + markers = (5.0,) + + each, each_moved = snap_lanes_to_song_markers( + lanes, + markers, + proximity=5.0, + layer_z_order=z_order, + slots=z_order, + mode="each_layer", + ) + assert each_moved == 2 + assert each["layer_1"].cues == [SlotCue(t=5.0, visible=True)] + assert each["layer_2"].cues == [SlotCue(t=5.0, visible=False)] + + closest, closest_moved = snap_lanes_to_song_markers( + lanes, + markers, + proximity=5.0, + layer_z_order=z_order, + slots=z_order, + mode="closest_wins", + ) + assert closest_moved == 1 + assert closest["layer_1"].cues == [SlotCue(t=4.5, visible=True)] + assert closest["layer_2"].cues == [SlotCue(t=5.0, visible=False)] + + +def test_closest_wins_tie_prefers_earlier_layer_in_z_order() -> None: + lanes = { + "layer_1": _lane(None, (4.0, True)), + "layer_2": _lane(None, (6.0, False)), + } + # Equal distance 1.0 from marker 5.0; earlier time wins before z-order. + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1", "layer_2"), + slots=("layer_1", "layer_2"), + mode="closest_wins", + ) + assert moved == 1 + assert result["layer_1"].cues == [SlotCue(t=5.0, visible=True)] + assert result["layer_2"].cues == [SlotCue(t=6.0, visible=False)] + + # Equal distance and equal times: earlier z-order wins. + lanes_eq = { + "layer_2": _lane(None, (4.5, False)), + "layer_1": _lane(None, (4.5, True)), + } + result_eq, moved_eq = snap_lanes_to_song_markers( + lanes_eq, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1", "layer_2"), + slots=("layer_1", "layer_2"), + mode="closest_wins", + ) + assert moved_eq == 1 + assert result_eq["layer_1"].cues == [SlotCue(t=5.0, visible=True)] + assert result_eq["layer_2"].cues == [SlotCue(t=4.5, visible=False)] + + +def test_canonicalize_after_collision() -> None: + # Two markers at the same time claim two cues; last-wins at equal t, then + # redundant False vs baseline False is dropped. + lanes = {"layer_1": _lane(False, (4.0, True), (6.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0, 5.0), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 2 + assert result["layer_1"].cues == [] + + +def test_preserves_visibility_and_baseline() -> None: + lanes = {"layer_1": _lane(True, (3.0, False))} + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1",), + slots=("layer_1",), + ) + assert moved == 1 + assert result["layer_1"].baseline is True + assert result["layer_1"].cues == [SlotCue(t=5.0, visible=False)] + + +def test_single_slot_leaves_other_lanes_unchanged() -> None: + lanes = { + "layer_1": _lane(None, (4.0, True)), + "layer_2": _lane(None, (4.0, False)), + } + result, moved = snap_lanes_to_song_markers( + lanes, + (5.0,), + proximity=5.0, + layer_z_order=("layer_1", "layer_2"), + slots=("layer_1",), + ) + assert moved == 1 + assert result["layer_1"].cues == [SlotCue(t=5.0, visible=True)] + assert result["layer_2"].cues == [SlotCue(t=4.0, visible=False)] diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 6192d00..34886c4 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1550,6 +1550,20 @@ def _focus_timeline_snap_bars(controls: TuningControls) -> None: controls.focus_descriptor = _desc(view, snap_row) +def _focus_timeline_snap_song_markers(controls: TuningControls) -> None: + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + controls.focus_descriptor = _desc(view, snap_row) + + +def _focus_timeline_snap_marker_proximity(controls: TuningControls) -> None: + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + controls.focus_descriptor = _desc(view, row) + + def _focus_timeline_bar_phase(controls: TuningControls) -> None: controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) @@ -1974,6 +1988,163 @@ def test_timeline_snap_bars_no_bars_notifies() -> None: assert view.notification_message == "No bars available; re-run separate" +def test_timeline_snap_song_markers_enter_opens_choice_modal() -> None: + controls = _make_controls(("layer_1", "layer_2")) + controls.session.song_markers.times = [10.0, 40.0] + controls.session.timeline.lanes = { + "layer_1": _lane(None, (9.0, True)), + } + _focus_timeline_snap_song_markers(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + modal_view = controls.modal_host.view_state() + assert modal_view is not None + assert modal_view.kind == ModalKind.CHOICE + assert modal_view.options == ( + "Layer 1", + "Layer 2", + "All layers (closest wins)", + "Each layer", + "Cancel", + ) + assert "within 5.0s" in modal_view.message + + +def test_timeline_snap_song_markers_layer_mutates_only_that_lane() -> None: + controls = _make_controls(("layer_1", "layer_2")) + controls.session.song_markers.times = [5.0] + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.0, True)), + "layer_2": _lane(None, (4.2, False)), + } + _focus_timeline_snap_song_markers(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "Layer 1") + assert not controls.modal_host.active + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=5.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=4.2, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped 1 cue to song markers" + + +def test_timeline_snap_song_markers_each_layer_vs_closest_wins() -> None: + controls = _make_controls(("layer_1", "layer_2")) + controls.session.song_markers.times = [5.0] + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.5, True)), + "layer_2": _lane(None, (4.8, False)), + } + _focus_timeline_snap_song_markers(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "Each layer") + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=5.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=5.0, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped 2 cues to song markers" + + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.5, True)), + "layer_2": _lane(None, (4.8, False)), + } + _focus_timeline_snap_song_markers(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "All layers (closest wins)") + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=4.5, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=5.0, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped 1 cue to song markers" + + +def test_timeline_snap_song_markers_recording_blocks() -> None: + controls = _make_controls(("layer_1",)) + controls.session.song_markers.times = [5.0] + prior = {"layer_1": _lane(None, (4.0, True))} + controls.session.timeline.lanes = dict(prior) + controls.session.timeline.recording = True + _focus_timeline_snap_song_markers(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + assert controls.session.timeline.lanes == prior + view = controls.build_view_state(paused=False) + assert view.notification_message is None + + +def test_timeline_snap_song_markers_no_markers_notifies() -> None: + controls = _make_controls(("layer_1",)) + controls.session.song_markers.times = [] + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.0, True)), + } + _focus_timeline_snap_song_markers(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + view = controls.build_view_state(paused=False) + assert view.notification_message == "No song markers to snap to" + + +def test_timeline_snap_song_markers_no_cues_notifies() -> None: + controls = _make_controls(("layer_1",)) + controls.session.song_markers.times = [5.0] + controls.session.timeline.lanes = {} + _focus_timeline_snap_song_markers(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + view = controls.build_view_state(paused=False) + assert view.notification_message == "No timeline cues to snap" + + +def test_timeline_snap_marker_proximity_left_right() -> None: + controls = _make_controls(("layer_1",)) + assert controls.session.timeline.song_marker_snap_proximity == 5.0 + _focus_timeline_snap_marker_proximity(controls) + view = controls.build_view_state(paused=False) + prox_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + assert "5.0s" in _row_text(view, prox_row) + + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert controls.session.timeline.song_marker_snap_proximity == 5.5 + assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True + assert controls.session.timeline.song_marker_snap_proximity == 5.0 + + controls.session.timeline.song_marker_snap_proximity = 0.5 + assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True + assert controls.session.timeline.song_marker_snap_proximity == 0.5 + controls.session.timeline.song_marker_snap_proximity = 30.0 + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert controls.session.timeline.song_marker_snap_proximity == 30.0 + + +def test_timeline_snap_song_markers_uses_proximity() -> None: + controls = _make_controls(("layer_1",)) + controls.session.song_markers.times = [5.0] + controls.session.timeline.song_marker_snap_proximity = 1.0 + controls.session.timeline.lanes = { + "layer_1": _lane(None, (3.0, True)), + } + _focus_timeline_snap_song_markers(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + modal_view = controls.modal_host.view_state() + assert modal_view is not None + assert "within 1.0s" in modal_view.message + _choose_modal_option(controls, "Layer 1") + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=3.0, visible=True), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "No cues within snap proximity" + + def test_render_timeline_header_label_spacing() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) @@ -2108,6 +2279,8 @@ def test_render_timeline_down_enters_submenu() -> None: grid_row = view.layout.find_by_kind(RowKind.TIMELINE_BAR_GRID) snap_beats_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) + snap_prox_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + snap_markers_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -2135,6 +2308,14 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, snap_bars_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, snap_prox_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, snap_markers_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2156,6 +2337,8 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2283,7 +2466,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) - assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) + assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -2292,8 +2475,8 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) - controls.focus_descriptor = _desc(view, snap_bars_row) + snap_markers_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + controls.focus_descriptor = _desc(view, snap_markers_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) @@ -2393,6 +2576,8 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, + RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, ): row = view.layout.find_by_kind(kind) assert _row_value_color(view, row) == DISABLED, kind diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 44b8f62..d52d4ec 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -290,7 +290,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 70 + assert len(ROW_FIELDS) == 72 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index bf61760..e9b2861 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -73,6 +73,7 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.SETTINGS_UI_WIDTH, RowKind.SETTINGS_UI_FADE, RowKind.TIMELINE_BAR_PHASE, + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, } ) @@ -262,6 +263,8 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESETS) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_BAR_PHASE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_BARS) is True + assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) is True + assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) is True assert row_blocked_by_section_lock(RowKind.SONG_MARKER_ITEM) is True assert row_blocked_by_section_lock(RowKind.SONG_MARKERS_HEADER) is False diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 9931ae9..62b5661 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -148,12 +148,16 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: bar_grid = RowDescriptor(RowKind.TIMELINE_BAR_GRID) snap_beats = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) + snap_prox = RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + snap_markers = RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) markers_header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) assert presets not in view_closed.layout.rows assert bar_phase not in view_closed.layout.rows assert bar_grid not in view_closed.layout.rows assert snap_beats not in view_closed.layout.rows assert snap_bars not in view_closed.layout.rows + assert snap_prox not in view_closed.layout.rows + assert snap_markers not in view_closed.layout.rows assert markers_header not in view_closed.layout.rows session.timeline.panel_open = True @@ -164,18 +168,24 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid in view_open.layout.rows assert snap_beats in view_open.layout.rows assert snap_bars in view_open.layout.rows + assert snap_prox in view_open.layout.rows + assert snap_markers in view_open.layout.rows assert markers_header in view_open.layout.rows presets_idx = view_open.layout.rows.index(presets) bar_phase_idx = view_open.layout.rows.index(bar_phase) bar_grid_idx = view_open.layout.rows.index(bar_grid) snap_beats_idx = view_open.layout.rows.index(snap_beats) snap_bars_idx = view_open.layout.rows.index(snap_bars) + snap_prox_idx = view_open.layout.rows.index(snap_prox) + snap_markers_idx = view_open.layout.rows.index(snap_markers) markers_idx = view_open.layout.rows.index(markers_header) assert presets_idx == markers_idx + 1 assert bar_phase_idx == presets_idx + 1 assert bar_grid_idx == bar_phase_idx + 1 assert snap_beats_idx == bar_grid_idx + 1 assert snap_bars_idx == snap_beats_idx + 1 + assert snap_prox_idx == snap_bars_idx + 1 + assert snap_markers_idx == snap_prox_idx + 1 session.timeline.panel_open = False view_closed_again = builder.build(paused=False) @@ -185,6 +195,8 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid not in view_closed_again.layout.rows assert snap_beats not in view_closed_again.layout.rows assert snap_bars not in view_closed_again.layout.rows + assert snap_prox not in view_closed_again.layout.rows + assert snap_markers not in view_closed_again.layout.rows assert markers_header not in view_closed_again.layout.rows From 579400ba3468bcdcf548bd5c75e413fca31870fe Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 22:43:03 +0100 Subject: [PATCH 08/12] Update plan --- docs/song-markers.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/song-markers.md b/docs/song-markers.md index 0718735..7191563 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -18,9 +18,9 @@ v1 is **manual only** — user-driven placement with streamlined UX. No auto-sug --- -## Phase 1 — UI / UX +## Phase 1 — UI / UX (done) -Build UI and editing only. No snap, preset generation, or beat-phase logic yet. +Build UI and editing only. No preset generation or beat-phase logic yet. ### Add a song marker @@ -63,7 +63,7 @@ Song markers, list editing, seek-to-marker, and timeline drawing only. Value on --- -## Phase 2 — Snap to song markers +## Phase 2 — Snap to song markers (done) Add a **snap to song markers** action under the timeline section (after snap to bars), with an adjacent proximity control. @@ -94,7 +94,7 @@ Destructive multi-lane moves stay explicit via the modal; no silent wide snaps. ## Phase 3 — Timeline preset generation -Update preset generation so new timeline presets can **latch onto** song markers where appropriate. Details TBD when Phase 1 song markers exist in projects. +Update preset generation so new timeline presets can **latch onto** song markers where appropriate. Details TBD. --- From afdbcd5ed99a25bbe677cfbad4e1a7210fdad0bc Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 12 Jul 2026 23:03:46 +0100 Subject: [PATCH 09/12] Phase 3: use song markers in preset generation --- cleave/timeline_presets/__init__.py | 47 ++++- cleave/timeline_presets/arrange.py | 89 ++++++++- cleave/timeline_presets/motifs.py | 254 ++++++++++++++++++++++--- cleave/viz/timeline_preset_controls.py | 1 + docs/song-markers.md | 11 +- tests/cleave/test_timeline_presets.py | 126 ++++++++++++ 6 files changed, 493 insertions(+), 35 deletions(-) diff --git a/cleave/timeline_presets/__init__.py b/cleave/timeline_presets/__init__.py index f6be876..456be45 100644 --- a/cleave/timeline_presets/__init__.py +++ b/cleave/timeline_presets/__init__.py @@ -14,7 +14,11 @@ DIALOGUE, PULSE, ) -from cleave.timeline_presets.motifs import MIN_SWITCH_GAP_BARS, MIN_SWITCH_GAP_SEC +from cleave.timeline_presets.motifs import ( + MIN_SWITCH_GAP_BARS, + MIN_SWITCH_GAP_SEC, + SOFT_LATCH_PROXIMITY_SEC, +) _RESET_HELP_ENTRIES: tuple[tuple[str, str], ...] = ( ("Reset (All Off)", "clear cues; every layer off for the whole track."), @@ -30,6 +34,7 @@ "MIN_SWITCH_GAP_BARS", "MIN_SWITCH_GAP_SEC", "PHRASE_SEC_MIN", + "SOFT_LATCH_PROXIMITY_SEC", "TIMELINE_PRESET_HELP_ENTRIES", "build_arc_cues", "build_breathing_cues", @@ -47,8 +52,16 @@ def build_breathing_cues( duration_sec: float, rng: random.Random | None = None, bar_times: Sequence[float] = (), + song_marker_times: Sequence[float] = (), ) -> dict[str, TimelineLane]: - return compose_timeline(slots, duration_sec, BREATHING, _rng(rng), bar_times) + return compose_timeline( + slots, + duration_sec, + BREATHING, + _rng(rng), + bar_times, + song_marker_times=song_marker_times, + ) def build_dialogue_cues( @@ -56,8 +69,16 @@ def build_dialogue_cues( duration_sec: float, rng: random.Random | None = None, bar_times: Sequence[float] = (), + song_marker_times: Sequence[float] = (), ) -> dict[str, TimelineLane]: - return compose_timeline(slots, duration_sec, DIALOGUE, _rng(rng), bar_times) + return compose_timeline( + slots, + duration_sec, + DIALOGUE, + _rng(rng), + bar_times, + song_marker_times=song_marker_times, + ) def build_arc_cues( @@ -65,8 +86,16 @@ def build_arc_cues( duration_sec: float, rng: random.Random | None = None, bar_times: Sequence[float] = (), + song_marker_times: Sequence[float] = (), ) -> dict[str, TimelineLane]: - return compose_timeline(slots, duration_sec, ARC, _rng(rng), bar_times) + return compose_timeline( + slots, + duration_sec, + ARC, + _rng(rng), + bar_times, + song_marker_times=song_marker_times, + ) def build_pulse_cues( @@ -74,8 +103,16 @@ def build_pulse_cues( duration_sec: float, rng: random.Random | None = None, bar_times: Sequence[float] = (), + song_marker_times: Sequence[float] = (), ) -> dict[str, TimelineLane]: - return compose_timeline(slots, duration_sec, PULSE, _rng(rng), bar_times) + return compose_timeline( + slots, + duration_sec, + PULSE, + _rng(rng), + bar_times, + song_marker_times=song_marker_times, + ) ALL_BUILDERS = ( diff --git a/cleave/timeline_presets/arrange.py b/cleave/timeline_presets/arrange.py index 517f4cb..f22583c 100644 --- a/cleave/timeline_presets/arrange.py +++ b/cleave/timeline_presets/arrange.py @@ -13,11 +13,13 @@ from cleave.timeline_presets.motifs import ( MIN_SWITCH_GAP_BARS, MIN_SWITCH_GAP_SEC, + SOFT_LATCH_PROXIMITY_SEC, MotifDef, expand_motif, hamming_distance, motif_max_cost_resolved, motifs_for_profile, + switch_gap_ok, ) # Phrase length bounds for the arranger (bar counts and wall-clock seconds). @@ -32,6 +34,7 @@ def compose_timeline( profile: CharacterProfile, rng: random.Random, bar_times: Sequence[float], + song_marker_times: Sequence[float] = (), ) -> dict[str, TimelineLane]: slot_list = list(slots) if not slot_list or duration_sec <= 0.0: @@ -45,11 +48,12 @@ def compose_timeline( motifs = motifs_for_profile(profile.motif_ids) bars = thin_bar_times_for_arrange(bar_times, duration_sec) - if len(bars) < PHRASE_BARS_MIN: + markers = _normalize_song_markers(song_marker_times, duration_sec) + if len(bars) < PHRASE_BARS_MIN and not markers: opening = frozenset({order[0]}) return cues_from_states(slot_list, [(0.0, opening)]) - phrases = _partition_phrases(bars, duration_sec, rng) + phrases = _partition_phrases(bars, duration_sec, rng, markers) if not phrases: opening = frozenset({order[0]}) return cues_from_states(slot_list, [(0.0, opening)]) @@ -61,6 +65,7 @@ def compose_timeline( climax_used = False solo_rotation = 0 layer_airtime = {slot: 0.0 for slot in order} + claimed_markers: set[float] = set() climax_phrase_index = _climax_phrase_index(phrases, duration_sec) @@ -112,6 +117,9 @@ def compose_timeline( prev_time=states[-1][0] if states else None, min_gap_bars=MIN_SWITCH_GAP_BARS, min_gap_sec=MIN_SWITCH_GAP_SEC, + song_marker_times=markers, + claimed_markers=claimed_markers, + soft_latch_proximity=SOFT_LATCH_PROXIMITY_SEC, ) solo_rotation += len(phrase_states) @@ -148,10 +156,81 @@ def compose_timeline( return cues_from_states(slot_list, states) +def _normalize_song_markers( + song_marker_times: Sequence[float], + duration_sec: float, +) -> list[float]: + """Sorted unique markers strictly inside ``(0, duration_sec)``.""" + cleaned = sorted( + { + float(t) + for t in song_marker_times + if 0.0 < float(t) < duration_sec + } + ) + return cleaned + + +def _section_bounds( + markers: Sequence[float], + duration_sec: float, +) -> list[tuple[float, float]]: + cuts = [0.0, *markers, duration_sec] + sections: list[tuple[float, float]] = [] + for start, end in zip(cuts, cuts[1:]): + if end - start > 1e-6: + sections.append((start, end)) + return sections + + def _partition_phrases( bars: Sequence[float], duration_sec: float, rng: random.Random, + song_marker_times: Sequence[float] = (), +) -> list[tuple[float, float]]: + markers = list(song_marker_times) + if not markers: + return _partition_phrases_on_bars(bars, duration_sec, rng) + + phrases: list[tuple[float, float]] = [] + for sec_start, sec_end in _section_bounds(markers, duration_sec): + section_bars = [t for t in bars if sec_start <= t < sec_end] + section_phrases = _partition_phrases_in_section( + section_bars, sec_start, sec_end, rng + ) + phrases.extend(section_phrases) + return phrases + + +def _partition_phrases_in_section( + section_bars: Sequence[float], + sec_start: float, + sec_end: float, + rng: random.Random, +) -> list[tuple[float, float]]: + if sec_end - sec_start < 1e-6: + return [] + if not section_bars: + return [(sec_start, sec_end)] + + raw = _partition_phrases_on_bars(section_bars, sec_end, rng) + if not raw: + return [(sec_start, sec_end)] + + adjusted: list[tuple[float, float]] = [] + for i, (ps, pe) in enumerate(raw): + start = sec_start if i == 0 else ps + end = sec_end if i == len(raw) - 1 else pe + if end - start > 1e-6: + adjusted.append((start, end)) + return adjusted if adjusted else [(sec_start, sec_end)] + + +def _partition_phrases_on_bars( + bars: Sequence[float], + duration_sec: float, + rng: random.Random, ) -> list[tuple[float, float]]: phrases: list[tuple[float, float]] = [] i = 0 @@ -196,6 +275,12 @@ def _enforce_min_bar_gap( min_gap_bars: int, min_gap_sec: float = MIN_SWITCH_GAP_SEC, ) -> float | None: + """Keep ``t`` when gaps are already met (incl. soft-latched markers). + + Otherwise snap forward onto the bar grid. + """ + if switch_gap_ok(prev_t, t, bars, min_gap_bars, min_gap_sec): + return t if not bars: return None prev_idx = _nearest_bar_index(prev_t, bars) diff --git a/cleave/timeline_presets/motifs.py b/cleave/timeline_presets/motifs.py index 210aeac..e5afdc8 100644 --- a/cleave/timeline_presets/motifs.py +++ b/cleave/timeline_presets/motifs.py @@ -11,6 +11,8 @@ # Minimum spacing between layer visibility transitions. MIN_SWITCH_GAP_BARS = 2 MIN_SWITCH_GAP_SEC = 6.0 +# Prefer planned switches onto nearby song markers (generation soft latch). +SOFT_LATCH_PROXIMITY_SEC = 5.0 @dataclass(frozen=True) @@ -203,6 +205,131 @@ def _effective_step_bars( return step +def switch_gap_ok( + prev_t: float, + t: float, + bars: Sequence[float], + min_gap_bars: int, + min_gap_sec: float, +) -> bool: + """True when ``t`` meets second and bar-index floors relative to ``prev_t``.""" + if t - prev_t < min_gap_sec - 1e-9: + return False + if not bars: + return True + prev_idx = _nearest_bar_index(prev_t, bars) + t_idx = _nearest_bar_index(t, bars) + return t_idx - prev_idx >= min_gap_bars + + +def _marker_claimed(marker: float, claimed: set[float]) -> bool: + return any(abs(marker - c) < 1e-9 for c in claimed) + + +def _claim_marker(marker: float, claimed: set[float]) -> None: + if not _marker_claimed(marker, claimed): + claimed.add(marker) + + +def claim_marker_at_time( + t: float, + markers: Sequence[float], + claimed: set[float], +) -> None: + """If ``t`` coincides with a song marker, mark that marker claimed.""" + for marker in markers: + if abs(t - marker) < 1e-9: + _claim_marker(marker, claimed) + return + + +def soft_latch_time( + planned: float, + markers: Sequence[float], + claimed: set[float], + *, + bars: Sequence[float], + prev_time: float | None, + proximity: float = SOFT_LATCH_PROXIMITY_SEC, + min_gap_bars: int = MIN_SWITCH_GAP_BARS, + min_gap_sec: float = MIN_SWITCH_GAP_SEC, +) -> float: + """Nudge ``planned`` onto the nearest unclaimed marker within proximity. + + Skips markers that would violate min switch gaps vs ``prev_time``. + """ + best: tuple[float, float] | None = None + for marker in markers: + if _marker_claimed(marker, claimed): + continue + dist = abs(marker - planned) + if dist > proximity + 1e-9: + continue + if prev_time is not None and not switch_gap_ok( + prev_time, marker, bars, min_gap_bars, min_gap_sec + ): + continue + if ( + best is None + or dist < best[0] - 1e-12 + or (abs(dist - best[0]) < 1e-12 and marker < best[1]) + ): + best = (dist, marker) + if best is None: + claim_marker_at_time(planned, markers, claimed) + return planned + _claim_marker(best[1], claimed) + return best[1] + + +def _phrase_step_times( + phrase_start: float, + phrase_end: float, + bar_times: Sequence[float], +) -> list[float]: + """Bar times in the phrase, always including ``phrase_start`` when off-grid.""" + times = [t for t in bar_times if phrase_start <= t < phrase_end] + if not times: + return [phrase_start] + if abs(times[0] - phrase_start) > 1e-9: + return [phrase_start, *times] + return times + + +def _soft_latch_states( + states: list[tuple[float, frozenset[str]]], + markers: Sequence[float], + claimed: set[float], + *, + bars: Sequence[float], + phrase_start: float, + phrase_end: float, + prev_time: float | None, + proximity: float, + min_gap_bars: int, + min_gap_sec: float, +) -> list[tuple[float, frozenset[str]]]: + in_phrase = [m for m in markers if phrase_start <= m < phrase_end] + if not in_phrase: + return states + latched: list[tuple[float, frozenset[str]]] = [] + local_prev = prev_time + for planned, active in states: + t = soft_latch_time( + planned, + in_phrase, + claimed, + bars=bars, + prev_time=local_prev, + proximity=proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) + latched.append((t, active)) + local_prev = t + return latched + + def _expand_flash_tutti( vocab: ChordVocab, rotation: int, @@ -213,12 +340,29 @@ def _expand_flash_tutti( min_gap_bars: int, min_gap_sec: float, prev_time: float | None = None, + *, + song_marker_times: Sequence[float] = (), + claimed_markers: set[float] | None = None, + soft_latch_proximity: float = SOFT_LATCH_PROXIMITY_SEC, ) -> list[tuple[float, frozenset[str]]]: + claimed = claimed_markers if claimed_markers is not None else set() if vocab.tutti_id is None: - phrase_bars = [t for t in bar_times if phrase_start <= t < phrase_end] + phrase_bars = _phrase_step_times(phrase_start, phrase_end, bar_times) solo = vocab.active_for(_pick_single(vocab, rotation)) - t0 = phrase_bars[0] if phrase_bars else phrase_start - return [(t0, solo)] + t0 = phrase_bars[0] + states = [(t0, solo)] + return _soft_latch_states( + states, + song_marker_times, + claimed, + bars=bar_times, + phrase_start=phrase_start, + phrase_end=phrase_end, + prev_time=prev_time, + proximity=soft_latch_proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) duo = vocab.active_for(_pick_duo(vocab, rotation)) tutti = vocab.active_for(_pick_tutti(vocab)) @@ -226,7 +370,19 @@ def _expand_flash_tutti( bars = list(bar_times) if not bars: - return [(phrase_start, tutti)] + states = [(phrase_start, tutti)] + return _soft_latch_states( + states, + song_marker_times, + claimed, + bars=bars, + phrase_start=phrase_start, + phrase_end=phrase_end, + prev_time=prev_time, + proximity=soft_latch_proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) window_start = 0.70 * duration_sec window_end = 0.78 * duration_sec @@ -248,7 +404,19 @@ def _expand_flash_tutti( prev_idx = _nearest_bar_index(prev_time, bars) earliest_idx = _index_after_gap(bars, prev_idx, min_gap_bars, min_gap_sec) if earliest_idx is None: - return [(phrase_start, duo)] + states = [(phrase_start, duo)] + return _soft_latch_states( + states, + song_marker_times, + claimed, + bars=bars, + phrase_start=phrase_start, + phrase_end=phrase_end, + prev_time=prev_time, + proximity=soft_latch_proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) earliest = bars[earliest_idx] tutti_idx = _nearest_bar_index(tutti_t, bars) @@ -270,7 +438,7 @@ def _expand_flash_tutti( duo_t = bars[duo_idx] solo_t = bars[solo_idx] if solo_idx is not None else bars[-1] - states: list[tuple[float, frozenset[str]]] = [] + states = [] if ( duo_t >= earliest - 1e-9 and duo_t >= phrase_start - 1e-9 @@ -284,7 +452,18 @@ def _expand_flash_tutti( and solo_t > tutti_t + 1e-9 ): states.append((solo_t, solo)) - return states + return _soft_latch_states( + states, + song_marker_times, + claimed, + bars=bars, + phrase_start=phrase_start, + phrase_end=phrase_end, + prev_time=prev_time, + proximity=soft_latch_proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) def expand_motif( @@ -300,8 +479,16 @@ def expand_motif( prev_time: float | None = None, min_gap_bars: int = MIN_SWITCH_GAP_BARS, min_gap_sec: float = MIN_SWITCH_GAP_SEC, + song_marker_times: Sequence[float] = (), + claimed_markers: set[float] | None = None, + soft_latch_proximity: float = SOFT_LATCH_PROXIMITY_SEC, ) -> list[tuple[float, frozenset[str]]]: - """Expand a motif into timed active sets on bar boundaries within a phrase.""" + """Expand a motif into timed active sets within a phrase. + + Steps land on the bar grid (plus phrase start when off-grid). Planned + times soft-latch onto nearby unclaimed song markers when provided. + """ + claimed = claimed_markers if claimed_markers is not None else set() if motif.id == "flash_tutti": return _expand_flash_tutti( vocab, @@ -313,32 +500,45 @@ def expand_motif( min_gap_bars, min_gap_sec, prev_time, + song_marker_times=song_marker_times, + claimed_markers=claimed, + soft_latch_proximity=soft_latch_proximity, ) chord_ids = motif.resolve_steps(vocab, rng, rotation) - phrase_bars = [t for t in bar_times if phrase_start <= t < phrase_end] - if not phrase_bars: - phrase_bars = [phrase_start] + phrase_bars = _phrase_step_times(phrase_start, phrase_end, bar_times) n = len(chord_ids) step = _effective_step_bars(phrase_bars, min_gap_bars, min_gap_sec) if n == 1 or len(phrase_bars) < 1 + (n - 1) * step: - return [(phrase_bars[0], vocab.active_for(chord_ids[0]))] - - span = len(phrase_bars) - 1 - min_needed = (n - 1) * step - if span < min_needed: - return [(phrase_bars[0], vocab.active_for(chord_ids[0]))] - - step = max(step, span // n) - if step * (n - 1) > span: - step = _effective_step_bars(phrase_bars, min_gap_bars, min_gap_sec) - - states: list[tuple[float, frozenset[str]]] = [] - for i, chord_id in enumerate(chord_ids): - idx = min(i * step, len(phrase_bars) - 1) - states.append((phrase_bars[idx], vocab.active_for(chord_id))) - return states + states = [(phrase_bars[0], vocab.active_for(chord_ids[0]))] + else: + span = len(phrase_bars) - 1 + min_needed = (n - 1) * step + if span < min_needed: + states = [(phrase_bars[0], vocab.active_for(chord_ids[0]))] + else: + step = max(step, span // n) + if step * (n - 1) > span: + step = _effective_step_bars(phrase_bars, min_gap_bars, min_gap_sec) + + states = [] + for i, chord_id in enumerate(chord_ids): + idx = min(i * step, len(phrase_bars) - 1) + states.append((phrase_bars[idx], vocab.active_for(chord_id))) + + return _soft_latch_states( + states, + song_marker_times, + claimed, + bars=bar_times, + phrase_start=phrase_start, + phrase_end=phrase_end, + prev_time=prev_time, + proximity=soft_latch_proximity, + min_gap_bars=min_gap_bars, + min_gap_sec=min_gap_sec, + ) def hamming_distance(a: frozenset[str], b: frozenset[str]) -> int: diff --git a/cleave/viz/timeline_preset_controls.py b/cleave/viz/timeline_preset_controls.py index 246b433..9ffc18e 100644 --- a/cleave/viz/timeline_preset_controls.py +++ b/cleave/viz/timeline_preset_controls.py @@ -101,6 +101,7 @@ def _apply(self, kind: str, duration_sec: float) -> None: duration_sec, random.Random(), bar_times=grid, + song_marker_times=list(self.session.song_markers.times), ) for slot in self.session.layer_z_order: tl.lanes[slot] = copy_lane(built.get(slot, empty_lane())) diff --git a/docs/song-markers.md b/docs/song-markers.md index 7191563..2549c02 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -94,7 +94,16 @@ Destructive multi-lane moves stay explicit via the modal; no silent wide snaps. ## Phase 3 — Timeline preset generation -Update preset generation so new timeline presets can **latch onto** song markers where appropriate. Details TBD. +When song markers exist, applying a timeline preset (Breathing / Dialogue / Arc / Pulse) uses them at generation time: + +1. **Section-driven phrases.** Markers are hard section walls. Phrases never cross a marker; each marker starts a new phrase (then the usual 4–8 bar / minimum-duration partitioning fills each section). Empty or out-of-range markers leave bar-only partitioning unchanged. +2. **Soft latch.** Planned motif switches still prefer the bar grid. If an unclaimed marker lies within **5.0s** of a planned switch and min switch gaps still hold, that switch moves onto the marker (exclusive: each marker claimed at most once). Soft latch does **not** invent extra transitions solely to hit a marker. + +No new panel knobs. Phase 2 **snap to song markers** remains a separate manual polish step and is not run automatically after apply. + +### Phase 3 deliverable + +Preset apply reads `session.song_markers.times` and threads them into `compose_timeline`. Tests cover section walls, exclusive soft latch, and gap veto. --- diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index 27faef5..976cda0 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -376,3 +376,129 @@ def test_empty_bar_times_returns_opening_only() -> None: lanes = builder(slots, 60.0, random.Random(1), bar_times=()) assert all(not lane.cues for lane in lanes.values()) _assert_never_zero(lanes, slots, 60.0) + + +def test_partition_phrases_section_walls() -> None: + from cleave.timeline_presets.arrange import _partition_phrases + + duration_sec = 120.0 + bars = _bar_times_for(duration_sec) + markers = [30.5, 70.0] + phrases = _partition_phrases(bars, duration_sec, random.Random(1), markers) + assert phrases + starts = [start for start, _end in phrases] + assert 30.5 in starts + assert 70.0 in starts + for start, end in phrases: + for marker in markers: + assert not (start + 1e-9 < marker < end - 1e-9) + + +def test_partition_phrases_without_markers_matches_bar_only() -> None: + from cleave.timeline_presets.arrange import _partition_phrases + + duration_sec = 90.0 + bars = thin_bar_times_for_arrange(_bar_times_for(duration_sec), duration_sec) + rng_seed = 17 + a = _partition_phrases(bars, duration_sec, random.Random(rng_seed), ()) + b = _partition_phrases(bars, duration_sec, random.Random(rng_seed)) + assert a == b + + +def test_soft_latch_time_prefers_nearby_marker() -> None: + from cleave.timeline_presets.motifs import soft_latch_time + + bars = _bar_times_for(60.0) + claimed: set[float] = set() + latched = soft_latch_time( + 20.0, + [21.5], + claimed, + bars=bars, + prev_time=10.0, + proximity=5.0, + ) + assert latched == 21.5 + assert 21.5 in claimed + + +def test_soft_latch_time_exclusive_claim() -> None: + from cleave.timeline_presets.motifs import soft_latch_time + + bars = _bar_times_for(60.0) + claimed: set[float] = set() + first = soft_latch_time( + 20.0, + [21.0], + claimed, + bars=bars, + prev_time=10.0, + proximity=5.0, + ) + second = soft_latch_time( + 22.0, + [21.0], + claimed, + bars=bars, + prev_time=first, + proximity=5.0, + ) + assert first == 21.0 + assert second == 22.0 + + +def test_soft_latch_time_gap_veto() -> None: + from cleave.timeline_presets.motifs import soft_latch_time + + bars = _bar_times_for(60.0) + claimed: set[float] = set() + # Marker is within proximity of planned 14.0, but only 2s after prev. + latched = soft_latch_time( + 14.0, + [13.5], + claimed, + bars=bars, + prev_time=12.0, + proximity=5.0, + ) + assert latched == 14.0 + assert not claimed + + +def test_compose_with_markers_allows_off_bar_cues() -> None: + slots = _slots(4) + duration_sec = 90.0 + bars = _bar_times_for(duration_sec) + markers = [20.5, 50.25] + lanes = build_dialogue_cues( + slots, + duration_sec, + random.Random(3), + bar_times=bars, + song_marker_times=markers, + ) + _assert_lanes_cover_slots(lanes, slots) + _assert_never_zero(lanes, slots, duration_sec) + times = _all_transition_times(lanes) + for prev, cur in zip(times, times[1:]): + assert cur - prev >= MIN_SWITCH_GAP_SEC - 1e-6 + # At least one cue should soft-latch or land on a section-wall marker. + assert any( + any(abs(t - marker) < 1e-9 for marker in markers) for t in times + ) + + +def test_compose_ignores_out_of_range_markers() -> None: + slots = _slots(4) + duration_sec = 60.0 + bars = _bar_times_for(duration_sec) + thinned = thin_bar_times_for_arrange(bars, duration_sec) + lanes = build_pulse_cues( + slots, + duration_sec, + random.Random(5), + bar_times=bars, + song_marker_times=[-1.0, 0.0, duration_sec, duration_sec + 5.0], + ) + _assert_cues_on_bars(lanes, thinned) + _assert_min_gaps(lanes, bars, duration_sec) From 7bd78c6cca471d67348da8904a83be21d17055b8 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Mon, 13 Jul 2026 00:30:15 +0100 Subject: [PATCH 10/12] Add action parameter button row UI + UX --- .cursor/rules/live-tuning-ui.mdc | 1 + cleave/viz/help_content.py | 2 + cleave/viz/row_fields.py | 45 ++++++- cleave/viz/row_sections.py | 9 +- cleave/viz/row_semantics.py | 27 +++- cleave/viz/session.py | 1 + cleave/viz/theme.py | 2 +- cleave/viz/timeline_snap_controls.py | 120 ++++++++++++------ cleave/viz/tuning_panel_draw.py | 84 ++++++++++++ cleave/viz/tuning_view_state.py | 3 + docs/song-markers.md | 29 +++-- tests/cleave/viz/test_controls.py | 67 +++++++--- tests/cleave/viz/test_overlay.py | 25 ++++ tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_semantics.py | 2 + tests/cleave/viz/test_view_state_structure.py | 12 +- 16 files changed, 352 insertions(+), 79 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 365961d..ea102e5 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -78,6 +78,7 @@ Colors live in [cleave/viz/theme.py](cleave/viz/theme.py). Label truncation uses | Label | `LABEL` | `(170, 210, 255)` | Light blue prefixes and section titles | | Value | `VALUE` | `(255, 255, 255)` | Default white for values and state indicators | | Action | `ACTION` | `(80, 190, 125)` | Modal confirm rows (save config, add/delete layer) | +| Action parameter | `ACTION` / `VALUE` | `(80, 190, 125)` / `(255, 255, 255)` | Label prefix in ACTION green; value in white VALUE | | Disabled | `DISABLED` | `(140, 140, 140)` | Inactive rows and controls | | Locked | `LOCKED` | `(235, 150, 150)` | Locked sub-rows that cannot be edited | diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index a0408cd..a617fa4 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -298,6 +298,8 @@ def sections_for( ) elif behavior.affordance == RowAffordance.VALUE_STEP: primary = _value_step_section(row_kind) + elif behavior.affordance == RowAffordance.ACTION_PARAMETER: + primary = _value_step_section(row_kind) elif row_kind == RowKind.TRACK_USER_PRESET_ITEM: primary = _USER_PRESET_ITEM_SECTION elif row_kind == RowKind.TRACK_USER_PRESET_ADD: diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index d004895..0b31103 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -33,6 +33,10 @@ from cleave.viz.row_semantics import RowDescriptor, RowKind, row_behavior from cleave.viz.tuning_view_state import TrackBlock, TuningViewState from cleave.viz.user_presets import user_preset_item_display_name +from cleave.viz.timeline_snap_controls import ( + cycle_song_marker_snap_scope, + song_marker_snap_scope_label, +) if TYPE_CHECKING: from cleave.viz.controls import TuningControls @@ -40,6 +44,7 @@ class RowPresentStyle(Enum): LABELED_VALUE = auto() + ACTION_PARAMETER = auto() EXPAND_SUBHEADER = auto() COMPOSITE_HEADER = auto() PATH_ICON = auto() @@ -152,6 +157,30 @@ def _apply_timeline_snap_marker_proximity( ) +def _format_timeline_snap_marker_scope( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return song_marker_snap_scope_label( + state.render_timeline.song_marker_snap_scope, + state.layer_z_order, + ) + + +def _apply_timeline_snap_marker_scope( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + tl = controls.session.timeline + tl.song_marker_snap_scope = cycle_song_marker_snap_scope( + tl.song_marker_snap_scope, + controls.session.layer_z_order, + forward=forward, + ) + + def _apply_settings_preview_quality( controls: TuningControls, _desc: RowDescriptor, @@ -1376,11 +1405,17 @@ def _apply_transport( present_style=RowPresentStyle.FULL_LINE, ), RowKind.TIMELINE_SNAP_MARKER_PROXIMITY: RowFieldDef( - panel_label="snap marker proximity", - present_style=RowPresentStyle.LABELED_VALUE, + panel_label="proximity", + present_style=RowPresentStyle.ACTION_PARAMETER, format_value=_format_timeline_snap_marker_proximity, apply_horizontal=_apply_timeline_snap_marker_proximity, ), + RowKind.TIMELINE_SNAP_MARKER_SCOPE: RowFieldDef( + panel_label="layer scope", + present_style=RowPresentStyle.ACTION_PARAMETER, + format_value=_format_timeline_snap_marker_scope, + apply_horizontal=_apply_timeline_snap_marker_scope, + ), RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowFieldDef( panel_label="snap to song markers", present_style=RowPresentStyle.FULL_LINE, @@ -1435,6 +1470,12 @@ def row_labeled_display_text(state: TuningViewState, desc: RowDescriptor) -> str return labeled_row_prefix(desc.kind) + format_row_value(state, desc) +def row_action_parameter_display_text( + state: TuningViewState, desc: RowDescriptor +) -> str: + return labeled_row_prefix(desc.kind) + format_row_value(state, desc) + + def expand_subheader_prefix(kind: RowKind) -> str: depth = row_tree_indent_depth(kind) return tree_branch_prefix(depth) + row_panel_label(kind) + " " diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 2242e97..8296859 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -673,8 +673,9 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, + RowKind.TIMELINE_SNAP_MARKER_SCOPE, } ) @@ -719,8 +720,9 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TIMELINE_BAR_GRID] = 1 depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 depths[RowKind.TIMELINE_SNAP_TO_BARS] = 1 - depths[RowKind.TIMELINE_SNAP_MARKER_PROXIMITY] = 1 depths[RowKind.TIMELINE_SNAP_TO_SONG_MARKERS] = 1 + depths[RowKind.TIMELINE_SNAP_MARKER_PROXIMITY] = 2 + depths[RowKind.TIMELINE_SNAP_MARKER_SCOPE] = 2 return depths @@ -840,8 +842,9 @@ def append_render_section_rows( row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_GRID)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) - row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_SCOPE)) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index cdaa072..6dfadad 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -80,6 +80,7 @@ class RowKind(Enum): TIMELINE_SNAP_TO_BEATS = auto() TIMELINE_SNAP_TO_BARS = auto() TIMELINE_SNAP_MARKER_PROXIMITY = auto() + TIMELINE_SNAP_MARKER_SCOPE = auto() TIMELINE_SNAP_TO_SONG_MARKERS = auto() SONG_MARKERS_HEADER = auto() SONG_MARKER_ITEM = auto() @@ -106,6 +107,7 @@ class RowDescriptor: class RowAffordance(Enum): EXPAND = auto() VALUE_STEP = auto() + ACTION_PARAMETER = auto() PATH_DIR = auto() PATH_PRESET = auto() SEEK = auto() @@ -754,18 +756,29 @@ class RowBehavior: ), ), RowKind.TIMELINE_SNAP_MARKER_PROXIMITY: RowBehavior( - RowAffordance.VALUE_STEP, + RowAffordance.ACTION_PARAMETER, navigable=True, repeatable=True, blocked_by_section_lock=True, - help_title="Song marker snap proximity", + help_title="Snap proximity", help_entries=( ("Left", "decrease proximity"), ("Right", "increase proximity"), ), help_description=( "Maximum distance for snap to song markers.", - "Tune before Enter on snap to song markers.", + ), + ), + RowKind.TIMELINE_SNAP_MARKER_SCOPE: RowBehavior( + RowAffordance.ACTION_PARAMETER, + navigable=True, + repeatable=True, + blocked_by_section_lock=True, + help_title="Snap layer scope", + help_entries=(("Left/Right", "cycle layer scope"),), + help_description=( + "Which tracks snap to song markers.", + "Per-layer, closest wins, or all layers independently.", ), ), RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowBehavior( @@ -776,7 +789,7 @@ class RowBehavior: help_entries=(("Enter", "snap cues to song markers"),), help_description=( "Pull closest cues within proximity onto song markers", - "(irreversible; choose layer scope in the modal).", + "(irreversible; confirm uses proximity and layer scope).", ), ), RowKind.SONG_MARKERS_HEADER: RowBehavior( @@ -876,6 +889,11 @@ class RowBehavior: HEADER_ROW_KINDS = frozenset(k for k, b in ROW_BEHAVIORS.items() if b.is_header) REPEAT_ROW_KINDS = frozenset(k for k, b in ROW_BEHAVIORS.items() if b.repeatable) +ACTION_PARAMETER_SUB_ROW_KINDS = frozenset( + k + for k, b in ROW_BEHAVIORS.items() + if b.affordance == RowAffordance.ACTION_PARAMETER and not b.is_header +) LABELED_SUB_ROW_KINDS = frozenset( k for k, b in ROW_BEHAVIORS.items() @@ -902,6 +920,7 @@ class RowBehavior: _SECTION_LOCK_BLOCKING_AFFORDANCES = frozenset( { RowAffordance.VALUE_STEP, + RowAffordance.ACTION_PARAMETER, RowAffordance.PATH_DIR, RowAffordance.PATH_PRESET, } diff --git a/cleave/viz/session.py b/cleave/viz/session.py index d3d5dbe..a20ff91 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -149,6 +149,7 @@ class TimelineRuntime: bar_phase_offset: int = 0 show_bar_grid: bool = False song_marker_snap_proximity: float = 5.0 + song_marker_snap_scope: str = "each_layer" def default_timeline_runtime() -> TimelineRuntime: diff --git a/cleave/viz/theme.py b/cleave/viz/theme.py index 7c7e7e5..de01947 100644 --- a/cleave/viz/theme.py +++ b/cleave/viz/theme.py @@ -3,7 +3,7 @@ Typography roles for the live tuning overlay: LABEL — light blue text for row labels and prefixes VALUE — white text for values and state indicators (default) - ACTION — dark mint text for rows that open a confirm modal on Enter + ACTION — dark mint text for action rows and action-parameter label prefixes DISABLED — dimmed text when a row or control is inactive LOCKED — tinted text for locked sub-rows that cannot be edited diff --git a/cleave/viz/timeline_snap_controls.py b/cleave/viz/timeline_snap_controls.py index 3e02cad..9f9ef68 100644 --- a/cleave/viz/timeline_snap_controls.py +++ b/cleave/viz/timeline_snap_controls.py @@ -10,12 +10,75 @@ snap_lane_to_beats, snap_lanes_to_song_markers, ) -from cleave.viz.modal import ModalHost, ModalOption +from cleave.viz.modal import ModalHost from cleave.viz.session import TuningSession -_CANCEL_LABEL = "Cancel" -_ALL_CLOSEST_LABEL = "All layers (closest wins)" -_EACH_LAYER_LABEL = "Each layer" +SONG_MARKER_SNAP_SCOPE_EACH_LAYER = "each_layer" +SONG_MARKER_SNAP_SCOPE_CLOSEST_WINS = "closest_wins" +_ALL_CLOSEST_LABEL = "closest wins" +_EACH_LAYER_LABEL = "all layers" + + +def song_marker_snap_scope_options(layer_z_order: Sequence[str]) -> tuple[str, ...]: + return tuple(layer_z_order) + ( + SONG_MARKER_SNAP_SCOPE_CLOSEST_WINS, + SONG_MARKER_SNAP_SCOPE_EACH_LAYER, + ) + + +def song_marker_snap_scope_label( + scope: str, + layer_z_order: Sequence[str], +) -> str: + if scope == SONG_MARKER_SNAP_SCOPE_EACH_LAYER: + return _EACH_LAYER_LABEL + if scope == SONG_MARKER_SNAP_SCOPE_CLOSEST_WINS: + return _ALL_CLOSEST_LABEL + if scope in layer_z_order: + return f"layer {layer_z_order.index(scope) + 1}" + return _EACH_LAYER_LABEL + + +def cycle_song_marker_snap_scope( + current: str, + layer_z_order: Sequence[str], + *, + forward: bool, +) -> str: + options = song_marker_snap_scope_options(layer_z_order) + if not options: + return SONG_MARKER_SNAP_SCOPE_EACH_LAYER + try: + index = options.index(current) + except ValueError: + index = len(options) - 1 + delta = 1 if forward else -1 + return options[(index + delta) % len(options)] + + +def resolve_song_marker_snap( + scope: str, + layer_z_order: Sequence[str], +) -> tuple[tuple[str, ...], SongMarkerSnapMode]: + if scope == SONG_MARKER_SNAP_SCOPE_EACH_LAYER: + return tuple(layer_z_order), "each_layer" + if scope == SONG_MARKER_SNAP_SCOPE_CLOSEST_WINS: + return tuple(layer_z_order), "closest_wins" + if scope in layer_z_order: + return (scope,), "each_layer" + return tuple(layer_z_order), "each_layer" + + +def song_marker_snap_confirm_message( + proximity: float, + scope: str, + layer_z_order: Sequence[str], +) -> str: + scope_label = song_marker_snap_scope_label(scope, layer_z_order) + return ( + f"Snap closest cues within {proximity:.1f}s to song markers " + f"({scope_label})?" + ) class TimelineSnapController: @@ -65,43 +128,24 @@ def prompt_song_markers(self) -> None: if not any(lane.cues for lane in tl.lanes.values()): self._notify("No timeline cues to snap") return + layer_z_order = tuple(self.session.layer_z_order) proximity = tl.song_marker_snap_proximity - message = ( - f"Snap closest cues within {proximity:.1f}s to song markers?" + scope = tl.song_marker_snap_scope + message = song_marker_snap_confirm_message( + proximity, + scope, + layer_z_order, ) - dismiss = lambda: None - options: list[ModalOption] = [] - for i, slot in enumerate(self.session.layer_z_order): - label = f"Layer {i + 1}" - options.append( - ModalOption( - label, - lambda s=slot: self._snap_song_markers( - slots=(s,), - mode="each_layer", - ), - ) - ) - options.append( - ModalOption( - _ALL_CLOSEST_LABEL, - lambda: self._snap_song_markers( - slots=tuple(self.session.layer_z_order), - mode="closest_wins", - ), - ) - ) - options.append( - ModalOption( - _EACH_LAYER_LABEL, - lambda: self._snap_song_markers( - slots=tuple(self.session.layer_z_order), - mode="each_layer", - ), - ) + slots, mode = resolve_song_marker_snap(scope, layer_z_order) + + def on_confirm() -> None: + self._snap_song_markers(slots=slots, mode=mode) + + self._modal.prompt_yes_no( + message, + on_confirm=on_confirm, + cancel_label="CANCEL", ) - options.append(ModalOption(_CANCEL_LABEL, dismiss)) - self._modal.prompt_choice(message, options, on_dismiss=dismiss) def _snap_song_markers( self, diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index 19853fc..1281623 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -22,6 +22,7 @@ format_expand_subheader_value, format_row_value, labeled_row_prefix, + row_action_parameter_display_text, row_composite_header_display_text, row_dynamic_labeled_display_text, row_dynamic_labeled_prefix, @@ -38,6 +39,7 @@ row_tree_indent_depth, ) from cleave.viz.row_semantics import ( + ACTION_PARAMETER_SUB_ROW_KINDS, LABELED_SUB_ROW_KINDS, RowDescriptor, RowKind, @@ -167,6 +169,8 @@ def _row_text(state: TuningViewState, index: int) -> str: if field is not None: if field.present_style == RowPresentStyle.LABELED_VALUE: return row_labeled_display_text(state, desc) + if field.present_style == RowPresentStyle.ACTION_PARAMETER: + return row_action_parameter_display_text(state, desc) if field.present_style == RowPresentStyle.EXPAND_SUBHEADER: return row_expand_subheader_display_text(state, desc) if field.present_style == RowPresentStyle.COMPOSITE_HEADER: @@ -180,6 +184,48 @@ def _row_text(state: TuningViewState, index: int) -> str: return "" +def _action_parameter_row_prefix(kind: RowKind) -> str: + return labeled_row_prefix(kind) + + +def _action_parameter_row_value(state: TuningViewState, index: int) -> str: + return format_row_value(state, state.layout.descriptor(index)) + + +def _action_parameter_label_color( + state: TuningViewState, index: int +) -> tuple[int, int, int]: + """ACTION green label prefix matching the parent action row.""" + kind = state.layout.kind(index) + desc = state.layout.descriptor(index) + locked_blocked = section_locked(state, desc) and row_blocked_by_section_lock(kind) + + if kind in RENDER_TIMELINE_SECTION_KINDS: + if not state.render_timeline.enabled: + return DISABLED + + if locked_blocked: + return LOCKED + + return ACTION + + +def _fit_action_parameter_row_value( + font: pygame.font.Font, + state: TuningViewState, + index: int, + *, + max_content_width: int = PANEL_CONTENT_MAX_WIDTH, + cache: TuningPanelCache | None = None, +) -> str: + budget = max_content_width - _row_indent(state, index) + budget -= font.size(_action_parameter_row_prefix(state.layout.kind(index)))[0] + value = _action_parameter_row_value(state, index) + if cache is None: + return fit_text_to_width(font, value, budget) + return cache.fit_text_cached("text", fit_text_to_width, font, value, budget) + + def _labeled_sub_row_prefix(state: TuningViewState, index: int) -> str: kind = state.layout.kind(index) field = ROW_FIELDS.get(kind) @@ -482,6 +528,14 @@ def _fit( return row_composite_header_display_text(state, state.layout.descriptor(index)) if field is not None and field.present_style == RowPresentStyle.EXPAND_SUBHEADER: return row_expand_subheader_display_text(state, state.layout.descriptor(index)) + if kind in ACTION_PARAMETER_SUB_ROW_KINDS: + return _action_parameter_row_prefix(kind) + _fit_action_parameter_row_value( + font, + state, + index, + max_content_width=max_content_width, + cache=cache, + ) if kind in LABELED_SUB_ROW_KINDS: return _labeled_sub_row_prefix(state, index) + _fit_labeled_sub_row_value( font, @@ -896,6 +950,16 @@ def _estimate_row_content_width( value = format_expand_subheader_value(state, desc) return indent + font.size(prefix)[0] + font.size(value)[0] + if kind in ACTION_PARAMETER_SUB_ROW_KINDS: + prefix = _action_parameter_row_prefix(kind) + value = _fit_action_parameter_row_value( + font, + state, + index, + max_content_width=max_content_width, + ) + return indent + font.size(prefix)[0] + font.size(value)[0] + if kind in LABELED_SUB_ROW_KINDS: prefix = _labeled_sub_row_prefix(state, index) value = _fit_labeled_sub_row_value( @@ -1375,6 +1439,26 @@ def _build_row_at_index( ) return surf, None, indent + surf.get_width() + if kind in ACTION_PARAMETER_SUB_ROW_KINDS: + prefix = _action_parameter_row_prefix(kind) + value = _fit_action_parameter_row_value( + font, + state, + index, + max_content_width=max_content_width, + cache=cache, + ) + surf = _render_label_value_row( + font, + prefix=prefix, + value=value, + value_color=_row_value_color(state, index), + prefix_color=_action_parameter_label_color(state, index), + line_height=line_h, + counters=counters, + ) + return surf, None, indent + surf.get_width() + if kind in LABELED_SUB_ROW_KINDS: prefix = _labeled_sub_row_prefix(state, index) value = _fit_labeled_sub_row_value( diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 81400d9..d387577 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -133,6 +133,7 @@ class RenderTimelineBlock: bar_phase_offset: int = 0 show_bar_grid: bool = False song_marker_snap_proximity: float = 5.0 + song_marker_snap_scope: str = "each_layer" locked: bool = False song_markers_expanded: bool = False song_marker_times: tuple[float, ...] = () @@ -433,6 +434,7 @@ def _build_structure( bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, song_marker_snap_proximity=tl.song_marker_snap_proximity, + song_marker_snap_scope=tl.song_marker_snap_scope, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), ) @@ -583,6 +585,7 @@ def build( bar_phase_offset=tl.bar_phase_offset, show_bar_grid=tl.show_bar_grid, song_marker_snap_proximity=tl.song_marker_snap_proximity, + song_marker_snap_scope=tl.song_marker_snap_scope, locked=tl.locked, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), diff --git a/docs/song-markers.md b/docs/song-markers.md index 2549c02..10ead1c 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -75,20 +75,33 @@ Workflow: ### Behavior -For each song marker (ascending time), pull the **closest** unclaimed timeline cue within proximity onto that marker. Each cue moves at most once (**exclusive assignment**). If no unclaimed cue lies within proximity, that marker is a no-op. Visibility flags are preserved; only cue times change. Lanes are canonicalized after moves. +For each song marker (ascending time), pull the **closest** unclaimed timeline cue within proximity onto that marker. Each cue moves at most once (**exclusive assignment**). If no unclaimed cue lies within proximity, that marker is a no-op. Visibility flags are preserved; only cue times change. Tracks are canonicalized after moves. Default proximity: **5.0s** (session-only; Left/Right on the proximity row, clamp 0.5..30.0, step 0.5). -### Layer scope modal +### Panel layout -Enter on **snap to song markers** opens a choice modal: +Under **snap to bars**, **snap to song markers** is an action row with two child parameter rows: -- **Layer 1** … **Layer N** — only that lane (one option per `layer_z_order` entry) -- **All layers (closest wins)** — for each marker, among cues in all lanes within proximity, move the single closest cue (tie: earlier cue time, then earlier layer in z-order) -- **Each layer** — for each lane independently, each marker pulls that lane's closest unclaimed cue within proximity (claims are per-lane) -- **Cancel** +``` +snap to song markers +└─ proximity: 5.0s +└─ layer scope: all layers +``` -Destructive multi-lane moves stay explicit via the modal; no silent wide snaps. +Parameter rows use `ACTION` green for the label prefix and white `VALUE` for the value. Left/Right adjusts proximity or cycles layer scope. + +### Layer scope + +Session-only **layer scope** (Left/Right on the layer scope row) cycles: + +- **layer 1** … **layer N** — only that track +- **closest wins** — for each marker, among cues in all tracks within proximity, move the single closest cue (tie: earlier cue time, then earlier layer in z-order) +- **all layers** (default) — for each track independently, each marker pulls that track's closest unclaimed cue within proximity (claims are per-track) + +### Confirm + +Enter on **snap to song markers** opens a Yes/CANCEL confirm modal describing proximity and the current layer scope. It does not open a multi-choice scope modal. --- diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 34886c4..5c417ec 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1557,6 +1557,13 @@ def _focus_timeline_snap_song_markers(controls: TuningControls) -> None: controls.focus_descriptor = _desc(view, snap_row) +def _focus_timeline_snap_marker_scope(controls: TuningControls) -> None: + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) + controls.focus_descriptor = _desc(view, row) + + def _focus_timeline_snap_marker_proximity(controls: TuningControls) -> None: controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) @@ -1564,6 +1571,24 @@ def _focus_timeline_snap_marker_proximity(controls: TuningControls) -> None: controls.focus_descriptor = _desc(view, row) +def _set_timeline_snap_marker_scope( + controls: TuningControls, scope: str +) -> None: + controls.session.timeline.song_marker_snap_scope = scope + + +def _cycle_timeline_snap_marker_scope_to( + controls: TuningControls, target_label: str +) -> None: + for _ in range(16): + view = controls.build_view_state(paused=False) + row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) + if target_label in _row_text(view, row): + return + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + raise AssertionError(f"could not cycle scope to {target_label!r}") + + def _focus_timeline_bar_phase(controls: TuningControls) -> None: controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) @@ -1988,7 +2013,7 @@ def test_timeline_snap_bars_no_bars_notifies() -> None: assert view.notification_message == "No bars available; re-run separate" -def test_timeline_snap_song_markers_enter_opens_choice_modal() -> None: +def test_timeline_snap_song_markers_enter_opens_yes_no_modal() -> None: controls = _make_controls(("layer_1", "layer_2")) controls.session.song_markers.times = [10.0, 40.0] controls.session.timeline.lanes = { @@ -1998,15 +2023,10 @@ def test_timeline_snap_song_markers_enter_opens_choice_modal() -> None: assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True modal_view = controls.modal_host.view_state() assert modal_view is not None - assert modal_view.kind == ModalKind.CHOICE - assert modal_view.options == ( - "Layer 1", - "Layer 2", - "All layers (closest wins)", - "Each layer", - "Cancel", - ) + assert modal_view.kind == ModalKind.YES_NO + assert modal_view.options == ("Yes", "CANCEL") assert "within 5.0s" in modal_view.message + assert "all layers" in modal_view.message def test_timeline_snap_song_markers_layer_mutates_only_that_lane() -> None: @@ -2016,9 +2036,10 @@ def test_timeline_snap_song_markers_layer_mutates_only_that_lane() -> None: "layer_1": _lane(None, (4.0, True)), "layer_2": _lane(None, (4.2, False)), } + _set_timeline_snap_marker_scope(controls, "layer_1") _focus_timeline_snap_song_markers(controls) controls.handle_keydown(_keydown(pygame.K_RETURN)) - _choose_modal_option(controls, "Layer 1") + _choose_modal_option(controls, "Yes") assert not controls.modal_host.active assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=5.0, visible=True), @@ -2039,7 +2060,7 @@ def test_timeline_snap_song_markers_each_layer_vs_closest_wins() -> None: } _focus_timeline_snap_song_markers(controls) controls.handle_keydown(_keydown(pygame.K_RETURN)) - _choose_modal_option(controls, "Each layer") + _choose_modal_option(controls, "Yes") assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=5.0, visible=True), ] @@ -2053,9 +2074,10 @@ def test_timeline_snap_song_markers_each_layer_vs_closest_wins() -> None: "layer_1": _lane(None, (4.5, True)), "layer_2": _lane(None, (4.8, False)), } + _set_timeline_snap_marker_scope(controls, "closest_wins") _focus_timeline_snap_song_markers(controls) controls.handle_keydown(_keydown(pygame.K_RETURN)) - _choose_modal_option(controls, "All layers (closest wins)") + _choose_modal_option(controls, "Yes") assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=4.5, visible=True), ] @@ -2137,7 +2159,7 @@ def test_timeline_snap_song_markers_uses_proximity() -> None: modal_view = controls.modal_host.view_state() assert modal_view is not None assert "within 1.0s" in modal_view.message - _choose_modal_option(controls, "Layer 1") + _choose_modal_option(controls, "Yes") assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=3.0, visible=True), ] @@ -2279,8 +2301,9 @@ def test_render_timeline_down_enters_submenu() -> None: grid_row = view.layout.find_by_kind(RowKind.TIMELINE_BAR_GRID) snap_beats_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) - snap_prox_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) snap_markers_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + snap_prox_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + snap_scope_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -2308,12 +2331,16 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, snap_bars_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, snap_markers_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_descriptor == _desc(view, snap_prox_row) assert not isinstance(controls.focus_cursor, TimelineFocus) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_descriptor == _desc(view, snap_markers_row) + assert controls.focus_descriptor == _desc(view, snap_scope_row) assert not isinstance(controls.focus_cursor, TimelineFocus) controls.handle_keydown(_keydown(pygame.K_DOWN)) @@ -2339,6 +2366,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -2466,7 +2494,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) - assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_SCOPE) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -2475,8 +2503,8 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - snap_markers_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) - controls.focus_descriptor = _desc(view, snap_markers_row) + snap_scope_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) + controls.focus_descriptor = _desc(view, snap_scope_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) @@ -2576,8 +2604,9 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_BAR_GRID, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, + RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, + RowKind.TIMELINE_SNAP_MARKER_SCOPE, ): row = view.layout.find_by_kind(kind) assert _row_value_color(view, row) == DISABLED, kind diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index df75f46..407586c 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -16,6 +16,7 @@ TuningOverlay, _row_bg_color, _row_text, + _action_parameter_label_color, _row_value_color, fit_row_text, panel_content_max_width, @@ -866,6 +867,30 @@ def test_action_row_value_color() -> None: assert _row_value_color(state, delete_row) == ACTION +def test_action_parameter_row_value_color() -> None: + state = _minimal_view_state( + render_timeline=RenderTimelineBlock(enabled=True, expanded=True), + ) + snap_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + prox_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + scope_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) + assert _row_value_color(state, snap_row) == ACTION + assert _row_value_color(state, prox_row) == VALUE + assert _row_value_color(state, scope_row) == VALUE + assert _action_parameter_label_color(state, prox_row) == ACTION + assert _action_parameter_label_color(state, scope_row) == ACTION + + locked = _minimal_view_state( + render_timeline=RenderTimelineBlock( + enabled=True, + expanded=True, + locked=True, + ), + ) + prox_locked = locked.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + assert _row_value_color(locked, prox_locked) == LOCKED + + def test_draw_layer_management_rows_without_error() -> None: pygame.init() overlay = TuningOverlay() diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index d52d4ec..22bd13a 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -290,7 +290,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 72 + assert len(ROW_FIELDS) == 73 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index e9b2861..3675fed 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -74,6 +74,7 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.SETTINGS_UI_FADE, RowKind.TIMELINE_BAR_PHASE, RowKind.TIMELINE_SNAP_MARKER_PROXIMITY, + RowKind.TIMELINE_SNAP_MARKER_SCOPE, } ) @@ -264,6 +265,7 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.TIMELINE_BAR_PHASE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_BARS) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) is True + assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_MARKER_SCOPE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) is True assert row_blocked_by_section_lock(RowKind.SONG_MARKER_ITEM) is True assert row_blocked_by_section_lock(RowKind.SONG_MARKERS_HEADER) is False diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 62b5661..7842c88 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -148,8 +148,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: bar_grid = RowDescriptor(RowKind.TIMELINE_BAR_GRID) snap_beats = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) snap_bars = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) - snap_prox = RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) snap_markers = RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + snap_prox = RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + snap_scope = RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_SCOPE) markers_header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) assert presets not in view_closed.layout.rows assert bar_phase not in view_closed.layout.rows @@ -157,6 +158,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_beats not in view_closed.layout.rows assert snap_bars not in view_closed.layout.rows assert snap_prox not in view_closed.layout.rows + assert snap_scope not in view_closed.layout.rows assert snap_markers not in view_closed.layout.rows assert markers_header not in view_closed.layout.rows @@ -169,6 +171,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_beats in view_open.layout.rows assert snap_bars in view_open.layout.rows assert snap_prox in view_open.layout.rows + assert snap_scope in view_open.layout.rows assert snap_markers in view_open.layout.rows assert markers_header in view_open.layout.rows presets_idx = view_open.layout.rows.index(presets) @@ -177,6 +180,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: snap_beats_idx = view_open.layout.rows.index(snap_beats) snap_bars_idx = view_open.layout.rows.index(snap_bars) snap_prox_idx = view_open.layout.rows.index(snap_prox) + snap_scope_idx = view_open.layout.rows.index(snap_scope) snap_markers_idx = view_open.layout.rows.index(snap_markers) markers_idx = view_open.layout.rows.index(markers_header) assert presets_idx == markers_idx + 1 @@ -184,8 +188,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid_idx == bar_phase_idx + 1 assert snap_beats_idx == bar_grid_idx + 1 assert snap_bars_idx == snap_beats_idx + 1 - assert snap_prox_idx == snap_bars_idx + 1 - assert snap_markers_idx == snap_prox_idx + 1 + assert snap_markers_idx == snap_bars_idx + 1 + assert snap_prox_idx == snap_markers_idx + 1 + assert snap_scope_idx == snap_prox_idx + 1 session.timeline.panel_open = False view_closed_again = builder.build(paused=False) @@ -196,6 +201,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_beats not in view_closed_again.layout.rows assert snap_bars not in view_closed_again.layout.rows assert snap_prox not in view_closed_again.layout.rows + assert snap_scope not in view_closed_again.layout.rows assert snap_markers not in view_closed_again.layout.rows assert markers_header not in view_closed_again.layout.rows From a5033aa1b962380649a54694fe5b0733e28cb3a5 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Mon, 13 Jul 2026 00:50:25 +0100 Subject: [PATCH 11/12] Add collapsable sectio to the button row --- cleave/viz/controls.py | 12 ++++- cleave/viz/row_fields.py | 4 +- cleave/viz/row_sections.py | 24 +++++++-- cleave/viz/row_semantics.py | 7 ++- cleave/viz/session.py | 1 + cleave/viz/tuning_panel_draw.py | 49 +++++++++++++++++-- cleave/viz/tuning_view_state.py | 4 ++ docs/song-markers.md | 2 +- tests/cleave/viz/test_controls.py | 30 ++++++++++++ tests/cleave/viz/test_overlay.py | 7 ++- tests/cleave/viz/test_view_state_structure.py | 28 +++++++++-- 11 files changed, 153 insertions(+), 15 deletions(-) diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index e6cb85a..f48ca09 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -718,7 +718,11 @@ def _apply_horizontal(self, key: int, mod: int, kind: RowKind) -> None: field = ROW_FIELDS.get(kind) if ( field is not None - and field.present_style == RowPresentStyle.EXPAND_SUBHEADER + and field.present_style + in { + RowPresentStyle.EXPAND_SUBHEADER, + RowPresentStyle.ACTION_EXPAND_SUBHEADER, + } and field.apply_horizontal is not None ): field.apply_horizontal( @@ -1003,6 +1007,12 @@ def _set_song_markers_expanded(self, expanded: bool) -> None: return markers.expanded = expanded + def _set_song_marker_snap_expanded(self, expanded: bool) -> None: + tl = self.session.timeline + if tl.song_marker_snap_expanded == expanded: + return + tl.song_marker_snap_expanded = expanded + def _set_beat(self, slot: str, value: float) -> None: layer = self.session.layers[slot] layer.beat_sensitivity = clamp_beat_sensitivity(value) diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 0b31103..4e915e4 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -46,6 +46,7 @@ class RowPresentStyle(Enum): LABELED_VALUE = auto() ACTION_PARAMETER = auto() EXPAND_SUBHEADER = auto() + ACTION_EXPAND_SUBHEADER = auto() COMPOSITE_HEADER = auto() PATH_ICON = auto() FULL_LINE = auto() @@ -1418,7 +1419,8 @@ def _apply_transport( ), RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowFieldDef( panel_label="snap to song markers", - present_style=RowPresentStyle.FULL_LINE, + present_style=RowPresentStyle.ACTION_EXPAND_SUBHEADER, + apply_horizontal=_apply_expand_subheader, ), RowKind.SONG_MARKERS_HEADER: RowFieldDef( panel_label="song markers", diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 8296859..1fb91cf 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -91,6 +91,14 @@ def _toggle_song_markers(controls: TuningControls, _slot: str | None, forward: b controls._set_song_markers_expanded(forward) +def _toggle_song_marker_snap(controls: TuningControls, _slot: str | None, forward: bool) -> None: + controls._set_song_marker_snap_expanded(forward) + + +def _song_marker_snap_expanded(state: TuningViewState, _slot: str | None) -> bool: + return state.render_timeline.song_marker_snap_expanded + + def _open_timeline_panel(controls: TuningControls, forward: bool) -> None: if forward: controls._open_timeline_panel() @@ -541,6 +549,17 @@ def _hard_cut_enabled(state: TuningViewState, desc: RowDescriptor) -> bool: append_dynamic_children=_append_song_marker_rows, ) +SNAP_TO_SONG_MARKERS_SECTION = ExpandSectionDef( + header_kind=RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, + context="global", + read_expanded=_song_marker_snap_expanded, + toggle=_toggle_song_marker_snap, + children=( + SectionNode(leaf_kind=RowKind.TIMELINE_SNAP_MARKER_PROXIMITY), + SectionNode(leaf_kind=RowKind.TIMELINE_SNAP_MARKER_SCOPE), + ), +) + def _collect_expand_sections( *roots: ExpandSectionDef, @@ -573,6 +592,7 @@ def walk_nodes(nodes: tuple[SectionNode, ...]) -> None: SETTINGS_SECTION, TRACK_SECTION, SONG_MARKERS_SECTION, + SNAP_TO_SONG_MARKERS_SECTION, extra_nodes=RENDER_SECTION_NODES, ) @@ -842,9 +862,7 @@ def append_render_section_rows( row_list.append(RowDescriptor(RowKind.TIMELINE_BAR_GRID)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) - row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS)) - row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY)) - row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_MARKER_SCOPE)) + append_expand_section_rows(row_list, SNAP_TO_SONG_MARKERS_SECTION, state) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 6dfadad..cd93481 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -784,9 +784,14 @@ class RowBehavior: RowKind.TIMELINE_SNAP_TO_SONG_MARKERS: RowBehavior( RowAffordance.ACTION, navigable=True, + navigable_when_section_locked=True, blocked_by_section_lock=True, help_title="Snap to song markers", - help_entries=(("Enter", "snap cues to song markers"),), + help_entries=( + ("Enter", "snap cues to song markers"), + ("Left", "collapse"), + ("Right", "expand"), + ), help_description=( "Pull closest cues within proximity onto song markers", "(irreversible; confirm uses proximity and layer scope).", diff --git a/cleave/viz/session.py b/cleave/viz/session.py index a20ff91..1c9a7e3 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -150,6 +150,7 @@ class TimelineRuntime: show_bar_grid: bool = False song_marker_snap_proximity: float = 5.0 song_marker_snap_scope: str = "each_layer" + song_marker_snap_expanded: bool = False def default_timeline_runtime() -> TimelineRuntime: diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index 1281623..2dbbedb 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -173,6 +173,8 @@ def _row_text(state: TuningViewState, index: int) -> str: return row_action_parameter_display_text(state, desc) if field.present_style == RowPresentStyle.EXPAND_SUBHEADER: return row_expand_subheader_display_text(state, desc) + if field.present_style == RowPresentStyle.ACTION_EXPAND_SUBHEADER: + return row_expand_subheader_display_text(state, desc) if field.present_style == RowPresentStyle.COMPOSITE_HEADER: return row_composite_header_display_text(state, desc) if field.present_style == RowPresentStyle.PATH_ICON: @@ -192,6 +194,27 @@ def _action_parameter_row_value(state: TuningViewState, index: int) -> str: return format_row_value(state, state.layout.descriptor(index)) +def _expand_subheader_arrow_color( + state: TuningViewState, index: int +) -> tuple[int, int, int]: + """Expand arrow uses VALUE white (and focus/disabled/locked), not ACTION green.""" + kind = state.layout.kind(index) + desc = state.layout.descriptor(index) + locked_blocked = section_locked(state, desc) and row_blocked_by_section_lock(kind) + + if kind in RENDER_TIMELINE_SECTION_KINDS: + if not state.render_timeline.enabled: + return DISABLED + + if locked_blocked: + return LOCKED + + if _row_has_tree_focus(state, index): + return _row_highlight_color(state, index) + + return VALUE + + def _action_parameter_label_color( state: TuningViewState, index: int ) -> tuple[int, int, int]: @@ -528,6 +551,8 @@ def _fit( return row_composite_header_display_text(state, state.layout.descriptor(index)) if field is not None and field.present_style == RowPresentStyle.EXPAND_SUBHEADER: return row_expand_subheader_display_text(state, state.layout.descriptor(index)) + if field is not None and field.present_style == RowPresentStyle.ACTION_EXPAND_SUBHEADER: + return row_expand_subheader_display_text(state, state.layout.descriptor(index)) if kind in ACTION_PARAMETER_SUB_ROW_KINDS: return _action_parameter_row_prefix(kind) + _fit_action_parameter_row_value( font, @@ -943,7 +968,11 @@ def _estimate_row_content_width( if ( ROW_FIELDS.get(kind) is not None - and ROW_FIELDS[kind].present_style == RowPresentStyle.EXPAND_SUBHEADER + and ROW_FIELDS[kind].present_style + in { + RowPresentStyle.EXPAND_SUBHEADER, + RowPresentStyle.ACTION_EXPAND_SUBHEADER, + } ): desc = state.layout.descriptor(index) prefix = expand_subheader_prefix(kind) @@ -978,7 +1007,6 @@ def _estimate_row_content_width( RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, } ): label = _row_text(state, index) @@ -1439,6 +1467,22 @@ def _build_row_at_index( ) return surf, None, indent + surf.get_width() + if ( + ROW_FIELDS.get(kind) is not None + and ROW_FIELDS[kind].present_style == RowPresentStyle.ACTION_EXPAND_SUBHEADER + ): + desc = state.layout.descriptor(index) + surf = _render_label_value_row( + font, + prefix=expand_subheader_prefix(kind), + value=format_expand_subheader_value(state, desc), + value_color=_expand_subheader_arrow_color(state, index), + prefix_color=_action_parameter_label_color(state, index), + line_height=line_h, + counters=counters, + ) + return surf, None, indent + surf.get_width() + if kind in ACTION_PARAMETER_SUB_ROW_KINDS: prefix = _action_parameter_row_prefix(kind) value = _fit_action_parameter_row_value( @@ -1489,7 +1533,6 @@ def _build_row_at_index( RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, RowKind.TIMELINE_SNAP_TO_BARS, - RowKind.TIMELINE_SNAP_TO_SONG_MARKERS, } ): label = _row_text(state, index) diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index d387577..e207d27 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -134,6 +134,7 @@ class RenderTimelineBlock: show_bar_grid: bool = False song_marker_snap_proximity: float = 5.0 song_marker_snap_scope: str = "each_layer" + song_marker_snap_expanded: bool = False locked: bool = False song_markers_expanded: bool = False song_marker_times: tuple[float, ...] = () @@ -309,6 +310,7 @@ def view_state_structure_signature( "panel_open": tl.panel_open, "song_markers_expanded": session.song_markers.expanded, "song_marker_count": len(session.song_markers.times), + "song_marker_snap_expanded": tl.song_marker_snap_expanded, }, "timeline": {"enabled": tl.enabled}, } @@ -435,6 +437,7 @@ def _build_structure( show_bar_grid=tl.show_bar_grid, song_marker_snap_proximity=tl.song_marker_snap_proximity, song_marker_snap_scope=tl.song_marker_snap_scope, + song_marker_snap_expanded=tl.song_marker_snap_expanded, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), ) @@ -586,6 +589,7 @@ def build( show_bar_grid=tl.show_bar_grid, song_marker_snap_proximity=tl.song_marker_snap_proximity, song_marker_snap_scope=tl.song_marker_snap_scope, + song_marker_snap_expanded=tl.song_marker_snap_expanded, locked=tl.locked, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), diff --git a/docs/song-markers.md b/docs/song-markers.md index 10ead1c..045e4fb 100644 --- a/docs/song-markers.md +++ b/docs/song-markers.md @@ -81,7 +81,7 @@ Default proximity: **5.0s** (session-only; Left/Right on the proximity row, clam ### Panel layout -Under **snap to bars**, **snap to song markers** is an action row with two child parameter rows: +Under **snap to bars**, **snap to song markers** is an expandable action row (Left/Right toggles; white expand arrow) with two child parameter rows when expanded: ``` snap to song markers diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 5c417ec..eaec908 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1559,6 +1559,7 @@ def _focus_timeline_snap_song_markers(controls: TuningControls) -> None: def _focus_timeline_snap_marker_scope(controls: TuningControls) -> None: controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) controls.focus_descriptor = _desc(view, row) @@ -1566,6 +1567,7 @@ def _focus_timeline_snap_marker_scope(controls: TuningControls) -> None: def _focus_timeline_snap_marker_proximity(controls: TuningControls) -> None: controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) controls.focus_descriptor = _desc(view, row) @@ -2126,6 +2128,29 @@ def test_timeline_snap_song_markers_no_cues_notifies() -> None: assert view.notification_message == "No timeline cues to snap" +def test_timeline_snap_song_markers_expand_collapse() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) + assert RowKind.TIMELINE_SNAP_MARKER_PROXIMITY not in { + desc.kind for desc in view.layout.rows + } + controls.focus_descriptor = _desc(view, snap_row) + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + view_expanded = controls.build_view_state(paused=False) + assert view_expanded.render_timeline.song_marker_snap_expanded is True + view_expanded.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) + assert "▼" in _row_text(view_expanded, snap_row) + controls.handle_keydown(_keydown(pygame.K_LEFT)) + view_collapsed = controls.build_view_state(paused=False) + assert view_collapsed.render_timeline.song_marker_snap_expanded is False + assert RowKind.TIMELINE_SNAP_MARKER_PROXIMITY not in { + desc.kind for desc in view_collapsed.layout.rows + } + assert "▶" in _row_text(view_collapsed, snap_row) + + def test_timeline_snap_marker_proximity_left_right() -> None: controls = _make_controls(("layer_1",)) assert controls.session.timeline.song_marker_snap_proximity == 5.0 @@ -2293,6 +2318,7 @@ def test_render_timeline_right_opens_panel() -> None: def test_render_timeline_down_enters_submenu() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) markers_row = view.layout.find_by_kind(RowKind.SONG_MARKERS_HEADER) @@ -2352,6 +2378,7 @@ def test_render_timeline_down_enters_submenu() -> None: def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_descriptor = _desc(view, header_row) @@ -2485,6 +2512,7 @@ def test_held_key_repeat_keeps_overlay_visible() -> None: def test_render_timeline_submenu_up_returns_to_header() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) controls.focus_descriptor = _desc(view, header_row) @@ -2502,6 +2530,7 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True view = controls.build_view_state(paused=False) snap_scope_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) controls.focus_descriptor = _desc(view, snap_scope_row) @@ -2594,6 +2623,7 @@ def test_render_timeline_header_eye_color_when_disabled() -> None: def test_render_timeline_sub_rows_dim_when_disabled() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True + controls.session.timeline.song_marker_snap_expanded = True controls.session.timeline.enabled = False view = controls.build_view_state(paused=False) for kind in ( diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 407586c..144cb38 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -17,6 +17,7 @@ _row_bg_color, _row_text, _action_parameter_label_color, + _expand_subheader_arrow_color, _row_value_color, fit_row_text, panel_content_max_width, @@ -869,12 +870,15 @@ def test_action_row_value_color() -> None: def test_action_parameter_row_value_color() -> None: state = _minimal_view_state( - render_timeline=RenderTimelineBlock(enabled=True, expanded=True), + render_timeline=RenderTimelineBlock( + enabled=True, expanded=True, song_marker_snap_expanded=True + ), ) snap_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_SONG_MARKERS) prox_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_PROXIMITY) scope_row = state.layout.find_by_kind(RowKind.TIMELINE_SNAP_MARKER_SCOPE) assert _row_value_color(state, snap_row) == ACTION + assert _expand_subheader_arrow_color(state, snap_row) == VALUE assert _row_value_color(state, prox_row) == VALUE assert _row_value_color(state, scope_row) == VALUE assert _action_parameter_label_color(state, prox_row) == ACTION @@ -884,6 +888,7 @@ def test_action_parameter_row_value_color() -> None: render_timeline=RenderTimelineBlock( enabled=True, expanded=True, + song_marker_snap_expanded=True, locked=True, ), ) diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 7842c88..e665aed 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -170,17 +170,15 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert bar_grid in view_open.layout.rows assert snap_beats in view_open.layout.rows assert snap_bars in view_open.layout.rows - assert snap_prox in view_open.layout.rows - assert snap_scope in view_open.layout.rows assert snap_markers in view_open.layout.rows + assert snap_prox not in view_open.layout.rows + assert snap_scope not in view_open.layout.rows assert markers_header in view_open.layout.rows presets_idx = view_open.layout.rows.index(presets) bar_phase_idx = view_open.layout.rows.index(bar_phase) bar_grid_idx = view_open.layout.rows.index(bar_grid) snap_beats_idx = view_open.layout.rows.index(snap_beats) snap_bars_idx = view_open.layout.rows.index(snap_bars) - snap_prox_idx = view_open.layout.rows.index(snap_prox) - snap_scope_idx = view_open.layout.rows.index(snap_scope) snap_markers_idx = view_open.layout.rows.index(snap_markers) markers_idx = view_open.layout.rows.index(markers_header) assert presets_idx == markers_idx + 1 @@ -189,6 +187,13 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_beats_idx == bar_grid_idx + 1 assert snap_bars_idx == snap_beats_idx + 1 assert snap_markers_idx == snap_bars_idx + 1 + + session.timeline.song_marker_snap_expanded = True + view_snap_expanded = builder.build(paused=False) + assert view_snap_expanded.layout is not view_open.layout + snap_prox_idx = view_snap_expanded.layout.rows.index(snap_prox) + snap_scope_idx = view_snap_expanded.layout.rows.index(snap_scope) + snap_markers_idx = view_snap_expanded.layout.rows.index(snap_markers) assert snap_prox_idx == snap_markers_idx + 1 assert snap_scope_idx == snap_prox_idx + 1 @@ -324,6 +329,21 @@ def test_structure_signature_invalidates_on_song_markers_expanded() -> None: assert sig_before != sig_after +def test_structure_signature_invalidates_on_song_marker_snap_expanded() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.timeline.song_marker_snap_expanded = False + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.timeline.song_marker_snap_expanded = True + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + def test_row_layout_includes_song_marker_items_when_expanded() -> None: controls = _make_controls(("layer_1",)) session = controls.session From 34981e87940d2490ae6b82293d52df7b3387ff65 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Mon, 13 Jul 2026 21:11:44 +0100 Subject: [PATCH 12/12] Update help ui --- cleave/viz/help_content.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index a617fa4..189485b 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -44,8 +44,6 @@ def description_section( ("Ctrl + Q", "quit"), ("Ctrl + S", "save config"), ("Ctrl + Enter", "place song marker"), - ("Ctrl + f", "favourite preset"), - ("Ctrl + b", "blacklist preset"), ), )