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
@@ -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;@@ -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();@@ -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));+}@@ -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()@@ -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),