collective

Browse / github.com/storytold/soundcraft / patch

Draw the playback cursor neutral and keep red for recording

Draw the playback cursor neutral and keep red for recording ## Problem and resulting behavior The Edit window drew the playhead red whenever the transport ran, so plain playback and recording looked the same. Red should mean recording only. ## Change - Plain playback draws the playhead in a new neutral mid grey, `Tokens::playback` (one value per theme), which reads over clips and the rulers. While recording it stays red. - It is not the insertion colour: #973 keeps the edit cursor drawn beside the moving line, and the two must not read as the same line. - One helper, `playhead_colour(tokens, recording)`, serves the Edit window's overlay and the Univ

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasegen (imported from pull request #971, open)
Base
60087e0f46032118bf309ed9ea161a9b4a872713
Size
+79 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
d0ea738c666951033ef03873

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/edit_window.rs crates/ui-egui/src/edit_window/overlay.rs crates/ui-egui/src/edit_window/tests.rs crates/ui-egui/src/edit_window/universe.rs crates/ui-egui/src/theme.rs

diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs--- a/crates/ui-egui/src/edit_window.rs+++ b/crates/ui-egui/src/edit_window.rs@@ -42,7 +42,7 @@ use layout::COLUMN_TITLES_H; #[cfg(test)] use layout::lane_rect; pub use layout::{COLUMN_W, EditLayout, HEADER_W, LANE_H, RULER_H, edit_columns, header_width, rect, sample_at, track_height, x_of};-use overlay::overlay;+use overlay::{overlay, playhead_colour}; pub use rulers::ruler_height; use rulers::{column_titles, draw_rulers, views_button}; pub use scroll::zoom_toggle;diff --git a/crates/ui-egui/src/edit_window/overlay.rs b/crates/ui-egui/src/edit_window/overlay.rs--- a/crates/ui-egui/src/edit_window/overlay.rs+++ b/crates/ui-egui/src/edit_window/overlay.rs@@ -4,6 +4,12 @@ use super::layout::x_of; use super::scroll::{FollowFrame, follow_update}; use super::*; +/// The playback cursor is red only while recording; plain playback draws it in its own neutral+/// grey, apart from the insertion colour of the edit cursor that stands beside it.+pub(super) fn playhead_colour(t: &Tokens, recording: bool) -> Color32 {+    if recording { t.playhead } else { t.playback }+}+ /// Selection overlay, insertion point and playhead across rulers and tracks. /// While playing, auto-scroll keeps the playhead in view for the follow /// modes (`page`/`continuous`/`center`) — but any outside view move (a manual@@ -61,7 +67,7 @@ pub(super) fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {     }     if app.is_playing() {         let x = x_of(s, tl, app.position());-        painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));+        painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, playhead_colour(&t, app.engine.transport.recording)));         // Session reads first (owned copies), then the layout mutation: the         // two borrow disjoint fields.         let pos = app.position();diff --git a/crates/ui-egui/src/edit_window/tests.rs b/crates/ui-egui/src/edit_window/tests.rs--- a/crates/ui-egui/src/edit_window/tests.rs+++ b/crates/ui-egui/src/edit_window/tests.rs@@ -1482,3 +1482,65 @@ fn alt_click_on_a_breakpoint_deletes_it() {         assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");     } }++#[test]+fn playhead_is_red_only_while_recording() {+    for t in [Tokens::DARK, Tokens::LIGHT] {+        assert_eq!(playhead_colour(&t, true), t.playhead);+        assert_eq!(playhead_colour(&t, false), t.playback);+        assert_ne!(t.playback, t.playhead, "plain playback must not read as the record line");+    }+}++/// WCAG contrast ratio between two colours (1 = identical, 21 = black on white).+fn contrast(a: Color32, b: Color32) -> f32 {+    let lum = |c: Color32| {+        let lin = |v: u8| {+            let v = f32::from(v) / 255.0;+            if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }+        };+        0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())+    };+    let (hi, lo) = (lum(a).max(lum(b)), lum(a).min(lum(b)));+    (hi + 0.05) / (lo + 0.05)+}++/// The edit cursor stands still beside the moving playback line while playing: in both themes+/// they must be two clearly different greys, and the line must still read on the lanes.+#[test]+fn the_playback_line_is_set_apart_from_the_insertion_point_in_both_themes() {+    for (name, t) in [("dark", Tokens::DARK), ("light", Tokens::LIGHT)] {+        let apart = contrast(t.playback, t.insertion);+        assert!(apart >= 2.5, "{name}: playback {:?} and insertion {:?} are only {apart:.2}:1 apart", t.playback, t.insertion);+        let reads = contrast(t.playback, t.playlist_bg);+        assert!(reads >= 3.0, "{name}: the playback line is only {reads:.2}:1 on the lanes");+    }+}++/// The colour `overlay` gives the full-height playback line (width 1.5) in this transport state.+fn playback_line_colour(recording: bool) -> Option<Color32> {+    use egui::{Rect, pos2};+    let _palette = crate::theme::lock_test_palette();+    let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());+    app.engine.session_mut().edit.zoom.samples_per_px = 1_000.0;+    app.engine.transport.playing = true;+    app.engine.transport.recording = recording;+    app.engine.transport.position = 80_000;+    let tl = Rect::from_min_max(pos2(100.0, 100.0), pos2(900.0, 500.0));+    let area = Rect::from_min_max(pos2(100.0, 50.0), tl.max);+    let ctx = egui::Context::default();+    let mut output = ctx.run_ui(egui::RawInput::default(), |ui| overlay(&mut app, ui, tl, area));+    output.textures_delta.clear();+    output.shapes.iter().find_map(|s| match &s.shape {+        Shape::LineSegment { points, stroke } if points[0].x == 180.0 && stroke.width == 1.5 => Some(stroke.color),+        _ => None,+    })+}++#[test]+fn overlay_draws_the_playback_line_neutral_and_the_record_line_red() {+    let _palette = crate::theme::lock_test_palette();+    let t = Tokens::current();+    assert_eq!(playback_line_colour(false), Some(t.playback));+    assert_eq!(playback_line_colour(true), Some(t.playhead));+}diff --git a/crates/ui-egui/src/edit_window/universe.rs b/crates/ui-egui/src/edit_window/universe.rs--- a/crates/ui-egui/src/edit_window/universe.rs+++ b/crates/ui-egui/src/edit_window/universe.rs@@ -82,7 +82,10 @@ pub(super) fn universe(app: &mut SoundApp, ui: &mut Ui) {     ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Tokens::current().universe_view), StrokeKind::Outside);     if app.is_playing() {         let x = r.min.x + app.position() as f32 / total * r.width();-        ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead));+        ui.painter().line_segment(+            [pos2(x, r.min.y), pos2(x, r.max.y)],+            Stroke::new(1.0, playhead_colour(&Tokens::current(), app.engine.transport.recording)),+        );     }     let resp = ui.interact(r, ui.scope_id().with("universe"), Sense::click_and_drag());     if let Some(p) = resp.interact_pointer_pos()diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -143,6 +143,9 @@ pub struct Tokens {     pub selection: Color32,     pub playhead: Color32,     pub insertion: Color32,+    /// The playback line while the transport runs without recording: a mid grey, set well apart+    /// from `insertion` (the edit cursor stands beside it while playing) in both themes.+    pub playback: Color32,     pub solo: Color32,     pub mute: Color32,     pub rec: Color32,@@ -253,6 +256,7 @@ impl Tokens {         selection: Color32::from_rgba_premultiplied(40, 70, 110, 110),         playhead: Color32::from_rgb(232, 60, 50),         insertion: Color32::from_rgb(230, 230, 230),+        playback: Color32::from_rgb(140, 140, 142),         solo: Color32::from_rgb(222, 196, 52),         mute: Color32::from_rgb(232, 148, 40),         rec: Color32::from_rgb(214, 52, 46),@@ -338,6 +342,7 @@ impl Tokens {         selection: Color32::from_rgba_premultiplied(100, 145, 205, 90),         playhead: Color32::from_rgb(210, 48, 42),         insertion: Color32::from_rgb(35, 36, 40),+        playback: Color32::from_rgb(110, 113, 119),         solo: Color32::from_rgb(178, 145, 20),         mute: Color32::from_rgb(194, 106, 22),         rec: Color32::from_rgb(190, 42, 38),