From 235f059c8e3df8ed859ad6bd16dd7a02f8ec317a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 26 Sep 2026 18:44:38 -0400 Subject: [PATCH] Lists: a selectable list's cursor starts on the owner's pick; the waveform's fit is tested SelectableList keeps the owner's last selection and the one it last sent. A selection the owner makes, on first paint or later, moves the cursor to its first row; the echo of the list's own pick leaves the cursor where the pick put it. The playlist's keys start from the song under way. The waveform's bar count and inset move into `fit`, tested beside `resample`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- TASKS.md | 1 + src/lists/select.rs | 14 +++++++++++++- src/lists/tests.rs | 35 +++++++++++++++++++++++++++++++++++ src/media/audio.rs | 19 ++++++++++++++++--- src/media/tests/audio.rs | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 89dfde8..a022611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - gpui styles a drag-over only on an element with its own hitbox. A veil that shows while files hover takes the drop itself (`chat::DragDropOverlay`). - gpui 0.2.2 never clears `debug_bounds`: a test sees an element appear, not leave. - The system time zone comes from `typography::format::system_zone`, which fails loud; jiff's `TimeZone::system()` falls back to UTC in silence. -- A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked. +- A list opens with its cursor on the current choice: Select on the chosen row, Combobox on its value, MultiSelect on the first one ticked, SelectableList on its first selected row. A selection the owner makes, not the echo of the list's own pick, moves SelectableList's cursor there. - A search step, a tool call or an agent's step says where it stands with `chat::StepState` and draws it with `chat::step_mark`. - A flex line whose free space goes to an auto margin loses its gaps in taffy 0.9. A row that parts left and right groups its sides and uses `justify_between`; `ml_auto` is safe only beside a `flex_1` item that leaves no free space. - A header that holds a name beside counts and actions wraps: `flex_wrap`, the name `flex_1` with `min_w(label_width)`, the rest one `flex_none` group that drops below. diff --git a/TASKS.md b/TASKS.md index d4170ab..d5aa3dc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -314,3 +314,4 @@ Per-component lines live in `tasks/`. Tags there: | T26c | 1 | FAIL | the caption faded with the controls; Space on a focused bar button acted twice; Command letters fired the player and the strip; a long caption, cue words and the scrubber's tip ran past their boxes at 280px | | T26c | 2 | FAIL | the bar's fade restarted an animation id per rest and wake, which rebuilt the scrubber's drag, hover and focus under it | | T26c | 3 | PASS | | +| T26d | 1 | FAIL | the playlist's keys started from the first row, not the song under way; the waveform's bar fit sat untested in paint | diff --git a/src/lists/select.rs b/src/lists/select.rs index 899f749..c77f8ec 100644 --- a/src/lists/select.rs +++ b/src/lists/select.rs @@ -38,13 +38,15 @@ pub(crate) fn picked( .collect() } -/// The row the keyboard is on and where a Shift range starts, by index and by key so they follow their rows when the owner reorders them, and the scroll that follows them. +/// The row the keyboard is on and where a Shift range starts, by index and by key so they follow their rows when the owner reorders them, and the scroll that follows them; the owner's last selection, and the one the list last sent. #[derive(Default)] struct Cursor { at: usize, anchor: usize, keys: Option<(SharedString, SharedString)>, scroll: ScrollHandle, + seen: Option>, + sent: Option>, } type OnSelect = Rc; @@ -125,6 +127,15 @@ impl RenderOnce for SelectableList { let cursor = window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| Cursor::default()); let keys: Rc<[SharedString]> = self.rows.iter().map(|(key, _)| key.clone()).collect(); + if cursor.read(cx).seen.as_ref() != Some(&self.selected) { + cursor.update(cx, |cursor, _| { + let echo = cursor.sent.take().as_ref() == Some(&self.selected); + if let Some(first) = self.selected.first().filter(|_| !echo) { + cursor.keys = Some((first.clone(), first.clone())); + } + cursor.seen = Some(self.selected.clone()); + }); + } let (at, anchor, scroll) = { let cursor = cursor.read(cx); let place = |key: Option<&SharedString>, ix: usize| { @@ -183,6 +194,7 @@ impl RenderOnce for SelectableList { keys[cursor.anchor.min(keys.len() - 1)].clone(), )); cursor.scroll.scroll_to_item(ix); + cursor.sent = Some(next.clone()); cx.notify(); }); log::info!("selectable list {id:?}: {next:?}"); diff --git a/src/lists/tests.rs b/src/lists/tests.rs index 7b68cdd..0a1221c 100644 --- a/src/lists/tests.rs +++ b/src/lists/tests.rs @@ -117,6 +117,41 @@ fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext assert_eq!(picks(&view, cx), NAMES); } +#[gpui::test] +fn the_cursor_starts_on_the_first_selected_row(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Picks(vec!["c".into()], Vec::new(), None, false)); + settle(cx); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("down"); + settle(cx); + assert_eq!(picks(&view, cx), ["d"]); +} + +#[gpui::test] +fn a_selection_from_the_owner_moves_the_cursor_and_an_echo_does_not(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Picks(vec!["a".into()], Vec::new(), None, false)); + settle(cx); + view.update(cx, |picks, _| picks.0 = vec!["b".into()]); + settle(cx); + cx.update(|window, _| window.focus_next()); + cx.simulate_keystrokes("shift-down shift-down"); + settle(cx); + assert_eq!( + picks(&view, cx), + ["b", "c", "d"], + "the range starts where the owner put it" + ); + cx.simulate_keystrokes("up"); + settle(cx); + assert_eq!( + picks(&view, cx), + ["c"], + "its own range left the cursor at its end" + ); +} + /// A row with two actions, 144 across; it reports where the row sits and which action ran. struct Swiped { left: Rc>, diff --git a/src/media/audio.rs b/src/media/audio.rs index 3f11b02..1ae82c9 100644 --- a/src/media/audio.rs +++ b/src/media/audio.rs @@ -32,6 +32,13 @@ pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec { .collect() } +/// How many bars `bar` wide with `gap` between fit across `width`, at least one, and the inset that centers them. +pub(crate) fn fit(width: f32, bar: f32, gap: f32) -> (usize, f32) { + let count = ((width + gap) / (bar + gap)).floor().max(1.0) as usize; + let used = (bar + gap) * count as f32 - gap; + (count, ((width - used) / 2.0).max(0.0)) +} + /// A seeking drag on a waveform, marked with its waveform. struct Seek(EntityId); @@ -114,8 +121,7 @@ impl RenderOnce for AudioWaveform { move |bounds, _, window, _| { let width = f32::from(bounds.size.width); let pitch = f32::from(bar + gap); - let count = ((width + f32::from(gap)) / pitch).floor().max(1.0) as usize; - let inset = (width - (pitch * count as f32 - f32::from(gap))) / 2.0; + let (count, inset) = fit(width, f32::from(bar), f32::from(gap)); for (ix, peak) in resample(&peaks, count).into_iter().enumerate() { let left = inset + pitch * ix as f32; let tall = (bounds.size.height * peak).max(bar); @@ -242,7 +248,14 @@ impl RenderOnce for AudioSpectrum { #[cfg(test)] mod tests { - use super::resample; + use super::{fit, resample}; + + #[test] + fn the_bars_that_fit_sit_centered() { + assert_eq!(fit(100.0, 3.0, 2.0), (20, 1.0)); + assert_eq!(fit(98.0, 3.0, 2.0), (20, 0.0)); + assert_eq!(fit(2.0, 3.0, 2.0), (1, 0.0)); + } #[test] fn peaks_gather_into_bars_by_their_loudest() { diff --git a/src/media/tests/audio.rs b/src/media/tests/audio.rs index 93c7eed..1be9dce 100644 --- a/src/media/tests/audio.rs +++ b/src/media/tests/audio.rs @@ -304,6 +304,42 @@ fn down_and_enter_play_the_next_song(cx: &mut TestAppContext) { assert_eq!(view.read_with(cx, |view, _| view.played.clone()), ["b"]); } +/// Four songs with the third under way, and the songs it was told to play. +struct Midway { + played: Vec, +} + +impl Render for Midway { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let owner = cx.entity(); + let songs = [ + track("a", "One"), + track("b", "Two"), + track("c", "Three"), + track("d", "Four"), + ]; + div().w(px(320.0)).child( + Playlist::new("list", songs) + .current("c", true) + .on_play(move |key, _, cx| { + owner.update(cx, |view, _| view.played.push(key.clone())) + }), + ) + } +} + +#[gpui::test] +fn the_keys_start_from_the_song_under_way(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Midway { played: Vec::new() }); + cx.update(|window, _| window.activate_window()); + settle(cx); + tab_to(1, cx); + press("down", cx); + press("enter", cx); + assert_eq!(view.read_with(cx, |view, _| view.played.clone()), ["d"]); +} + #[test] #[should_panic(expected = "no track c")] fn the_song_under_way_is_one_of_the_list() {