T12c, T13 review fixes: an emptied lightbox closes through its close path, keys pass over disabled and loading rows, the list cursor follows its row through a re-sort

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 21:21:33 -04:00
1 parent f8daaf282b
commit 492578a67c
8 files changed
+196 -41

No files matched your search

+4
View File
@@ -77,6 +77,10 @@ impl ListItem {
self
}
pub(crate) fn is_disabled(&self) -> bool {
self.disabled
}
/// Makes the row pressable; the press keeps focus where it is.
pub fn on_click(
mut self,
+48 -14
View File
@@ -38,11 +38,12 @@ pub(crate) fn picked(
.collect()
}
/// The row the keyboard is on, where a Shift range starts, 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.
#[derive(Default)]
struct Cursor {
at: usize,
anchor: usize,
keys: Option<(SharedString, SharedString)>,
scroll: ScrollHandle,
}
@@ -50,7 +51,7 @@ type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
type OnActivate = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
type Picker = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
/// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all.
/// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all. Keys pass over disabled rows, and no pick adds one.
#[derive(IntoElement)]
pub struct SelectableList {
id: ElementId,
@@ -123,14 +124,34 @@ impl RenderOnce for SelectableList {
let focused = focus.is_focused(window);
let cursor =
window.use_keyed_state((self.id.clone(), "cursor"), cx, |_, _| Cursor::default());
let (at, scroll) = {
let keys: Rc<[SharedString]> = self.rows.iter().map(|(key, _)| key.clone()).collect();
let (at, anchor, scroll) = {
let cursor = cursor.read(cx);
let place = |key: Option<&SharedString>, ix: usize| {
key.and_then(|key| keys.iter().position(|row| row == key))
.unwrap_or(ix.min(count.saturating_sub(1)))
};
let held = cursor.keys.as_ref();
(
cursor.at.min(count.saturating_sub(1)),
place(held.map(|keys| &keys.0), cursor.at),
place(held.map(|keys| &keys.1), cursor.anchor),
cursor.scroll.clone(),
)
};
let keys: Rc<[SharedString]> = self.rows.iter().map(|(key, _)| key.clone()).collect();
if (at, anchor) != (cursor.read(cx).at, cursor.read(cx).anchor) {
cursor.update(cx, |cursor, _| (cursor.at, cursor.anchor) = (at, anchor));
}
let off: Rc<[bool]> = self
.rows
.iter()
.map(|(_, item)| item.is_disabled())
.collect();
let barred: Vec<SharedString> = self
.rows
.iter()
.filter(|(_, item)| item.is_disabled())
.map(|(key, _)| key.clone())
.collect();
let selected = self.selected;
let multiple = self.multiple;
let pick: Picker = {
@@ -147,12 +168,20 @@ impl RenderOnce for SelectableList {
(false, Pick::All) => return,
(false, _) => Pick::One,
};
let next = picked(&keys, &selected, cursor.read(cx).anchor, ix, pick);
let next: Vec<SharedString> =
picked(&keys, &selected, cursor.read(cx).anchor, ix, pick)
.into_iter()
.filter(|key| !barred.contains(key) || selected.contains(key))
.collect();
cursor.update(cx, |cursor, cx| {
cursor.at = ix;
if pick != Pick::Range {
cursor.anchor = ix;
}
cursor.keys = Some((
keys[ix].clone(),
keys[cursor.anchor.min(keys.len() - 1)].clone(),
));
cursor.scroll.scroll_to_item(ix);
cx.notify();
});
@@ -209,22 +238,27 @@ impl RenderOnce for SelectableList {
let held = &event.keystroke.modifiers;
let at = cursor.read(cx).at.min(count - 1);
let extend = if held.shift { Pick::Range } else { Pick::One };
let open = |ix: &usize| !off[*ix];
let (to, how) = match event.keystroke.key.as_str() {
"down" => ((at + 1).min(count - 1), extend),
"up" => (at.saturating_sub(1), extend),
"home" => (0, extend),
"end" => (count - 1, extend),
"space" => (at, Pick::Toggle),
"down" => ((at + 1..count).find(open), extend),
"up" => ((0..at).rev().find(open), extend),
"home" => ((0..count).find(open), extend),
"end" => ((0..count).rev().find(open), extend),
"space" => (Some(at).filter(open), Pick::Toggle),
"enter" => {
cx.stop_propagation();
activate(&keys[at], window, cx);
if open(&at) {
activate(&keys[at], window, cx);
}
return;
}
"a" if held.platform => (at, Pick::All),
"a" if held.platform => (Some(at), Pick::All),
_ => return,
};
cx.stop_propagation();
pick(to, how, window, cx);
if let Some(to) = to {
pick(to, how, window, cx);
}
})
.children(rows)
}
+68 -7
View File
@@ -36,16 +36,29 @@ fn settle(cx: &mut VisualTestContext) {
const NAMES: [&str; 4] = ["a", "b", "c", "d"];
/// A multiple-choice list of four rows that keeps what it reports, and what was opened.
struct Picks(Vec<SharedString>, Vec<SharedString>);
/// A multiple-choice list of four rows, one maybe disabled, maybe reversed, that keeps what it reports and what was opened.
struct Picks(
Vec<SharedString>,
Vec<SharedString>,
Option<&'static str>,
bool,
);
impl Render for Picks {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (view, opened) = (cx.entity(), cx.entity());
NAMES
.iter()
let names: Vec<&&str> = if self.3 {
NAMES.iter().rev().collect()
} else {
NAMES.iter().collect()
};
names
.into_iter()
.fold(SelectableList::new("picks").multiple(), |list, name| {
list.row(*name, ListItem::new(*name, *name))
list.row(
*name,
ListItem::new(*name, *name).disabled(self.2 == Some(*name)),
)
})
.selected(self.0.clone())
.on_change(move |keys, _, cx| view.update(cx, |picks, _| picks.0 = keys.to_vec()))
@@ -69,7 +82,7 @@ fn row(ix: usize) -> Point<Pixels> {
#[gpui::test]
fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new()));
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new(), None, false));
settle(cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
@@ -277,7 +290,7 @@ fn a_long_list_builds_only_what_is_near_the_view(cx: &mut TestAppContext) {
#[gpui::test]
fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new()));
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new(), None, false));
settle(cx);
cx.simulate_click(row(1), Modifiers::none());
settle(cx);
@@ -304,3 +317,51 @@ fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) {
});
assert_eq!(opened, ["b", "d"]);
}
#[gpui::test]
fn keys_pass_over_a_disabled_row_and_never_pick_or_open_it(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new(), Some("b"), false));
settle(cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
cx.simulate_keystrokes("down");
settle(cx);
assert_eq!(picks(&view, cx), ["c"], "down steps over the disabled row");
cx.simulate_keystrokes("up enter cmd-a");
settle(cx);
assert_eq!(
picks(&view, cx),
["a", "c", "d"],
"all leaves the disabled row out"
);
let opened: Vec<String> = view.read_with(cx, |picks, _| {
picks.1.iter().map(|key| key.to_string()).collect()
});
assert_eq!(
opened,
["a"],
"up landed on the first row, not the disabled one"
);
}
#[gpui::test]
fn the_cursor_stays_on_its_row_when_the_rows_reorder(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new(), None, false));
settle(cx);
cx.simulate_click(row(0), Modifiers::none());
settle(cx);
view.update(cx, |picks, cx| {
picks.3 = true;
cx.notify();
});
settle(cx);
cx.simulate_keystrokes("enter up");
settle(cx);
let opened: Vec<String> = view.read_with(cx, |picks, _| {
picks.1.iter().map(|key| key.to_string()).collect()
});
assert_eq!(opened, ["a"], "enter opens the row the cursor was on");
assert_eq!(picks(&view, cx), ["b"], "up moves from where that row went");
}
+33 -7
View File
@@ -181,10 +181,10 @@ pub(crate) enum Move {
Close(SharedString),
}
/// Up, Down, Home and End walk the rows; Right opens a node or enters it, Left closes it or goes to its parent.
/// Up, Down, Home and End walk the rows past any still loading; Right opens a node or enters it, Left closes it or goes to its parent.
pub(crate) fn step(rows: &[Row], at: usize, key: &str) -> Option<Move> {
let last = rows.len().checked_sub(1)?;
let row = rows.get(at)?;
let node = |ix: &usize| rows[*ix].shown != Shown::Loading;
let (opens, open, node_key) = match &row.shown {
Shown::Node {
key, opens, open, ..
@@ -192,12 +192,15 @@ pub(crate) fn step(rows: &[Row], at: usize, key: &str) -> Option<Move> {
Shown::Loading => (false, false, None),
};
match key {
"down" => (at < last).then(|| Move::To(at + 1)),
"up" => at.checked_sub(1).map(Move::To),
"home" => Some(Move::To(0)),
"end" => Some(Move::To(last)),
"down" => (at + 1..rows.len()).find(node).map(Move::To),
"up" => (0..at).rev().find(node).map(Move::To),
"home" => (0..rows.len()).find(node).map(Move::To),
"end" => (0..rows.len()).rev().find(node).map(Move::To),
"right" if opens && !open => node_key.map(Move::Open),
"right" if open => (at < last).then(|| Move::To(at + 1)),
"right" if open => Some(at + 1)
.filter(|next| rows.get(*next).is_some_and(|child| child.depth > row.depth))
.filter(node)
.map(Move::To),
"left" if open => node_key.map(Move::Close),
"left" => row.parent.map(Move::To),
_ => None,
@@ -342,6 +345,29 @@ mod tests {
assert_eq!(step(&shown, 3, "end"), Some(Move::To(4)));
}
#[test]
fn keys_pass_over_a_loading_row() {
let nodes = tree();
let shown = rows(&nodes, &open(&["docs"]));
assert_eq!(
step(&shown, 1, "down"),
Some(Move::To(3)),
"down skips the loading row"
);
assert_eq!(step(&shown, 3, "up"), Some(Move::To(1)));
assert_eq!(
step(&shown, 1, "right"),
None,
"nothing to enter while it loads"
);
let waiting = rows(&[TreeNode::new("docs", "docs").pending()], &open(&["docs"]));
assert_eq!(
step(&waiting, 0, "end"),
Some(Move::To(0)),
"end stops on the last node"
);
}
#[test]
fn boxes_mix_and_fill_by_their_leaves() {
let nodes = tree();