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() {