Onboarding · Help: articles in a list the keyboard walks, a long address cut
HelpPanel lists its articles in lists::SelectableList: the list is one stop whose cursor scrolls into view, and Enter or a double press opens an article, focus going to All articles as before. SelectableList now activates on Enter's release, armed by its own press, as the palette and menus pick: an activation that moves focus left the release to press what took it, here the way back. ContactSupport cuts a long address in the middle and puts a Copy beside it. The lists tests move into a folder, the release tests on their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f5e2f73e1
commit
e26b2d1407
9 files changed
+257
-70
No files matched your search
+21
-4
@@ -47,13 +47,14 @@ struct Cursor {
|
||||
scroll: ScrollHandle,
|
||||
seen: Option<Vec<SharedString>>,
|
||||
sent: Option<Vec<SharedString>>,
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
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. Keys pass over disabled rows, and no pick adds one.
|
||||
/// 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, and Enter activates the row on its release, so an activation that moves focus leaves the release nothing to press. Keys pass over disabled rows, and no pick adds one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SelectableList {
|
||||
id: ElementId,
|
||||
@@ -235,6 +236,8 @@ impl RenderOnce for SelectableList {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (lifted, released, barred_rows, keyed) =
|
||||
(cursor.clone(), activate.clone(), off.clone(), keys.clone());
|
||||
self.base
|
||||
.id(self.id)
|
||||
.track_focus(&focus)
|
||||
@@ -243,6 +246,22 @@ impl RenderOnce for SelectableList {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_0p5()
|
||||
.on_key_up(move |event, window, cx| {
|
||||
if event.keystroke.key != "enter" || count == 0 {
|
||||
return;
|
||||
}
|
||||
let (armed, at) = lifted.update(cx, |cursor, _| {
|
||||
(std::mem::take(&mut cursor.armed), cursor.at.min(count - 1))
|
||||
});
|
||||
if !armed {
|
||||
return;
|
||||
}
|
||||
cx.stop_propagation();
|
||||
if barred_rows[at] {
|
||||
return;
|
||||
}
|
||||
released(&keyed[at], window, cx);
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if count == 0 {
|
||||
return;
|
||||
@@ -259,9 +278,7 @@ impl RenderOnce for SelectableList {
|
||||
"space" => (Some(at).filter(open), Pick::Toggle),
|
||||
"enter" => {
|
||||
cx.stop_propagation();
|
||||
if open(&at) {
|
||||
activate(&keys[at], window, cx);
|
||||
}
|
||||
cursor.update(cx, |cursor, _| cursor.armed = !held.modified());
|
||||
return;
|
||||
}
|
||||
"a" if held.platform => (Some(at), Pick::All),
|
||||
|
||||
@@ -18,6 +18,8 @@ use crate::{
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod release;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
@@ -322,6 +324,14 @@ fn a_long_list_builds_only_what_is_near_the_view(cx: &mut TestAppContext) {
|
||||
assert!((9..60).contains(&most), "built up to row {most} of 1000");
|
||||
}
|
||||
|
||||
/// Enter, pressed and let go: a row opens on the release.
|
||||
fn enter(cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes("enter");
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("enter").expect("a key"),
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
@@ -329,7 +339,7 @@ fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) {
|
||||
settle(cx);
|
||||
cx.simulate_click(row(1), Modifiers::none());
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter");
|
||||
enter(cx);
|
||||
settle(cx);
|
||||
for count in [1, 2] {
|
||||
cx.simulate_event(gpui::MouseDownEvent {
|
||||
@@ -363,7 +373,9 @@ fn keys_pass_over_a_disabled_row_and_never_pick_or_open_it(cx: &mut TestAppConte
|
||||
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");
|
||||
cx.simulate_keystrokes("up");
|
||||
enter(cx);
|
||||
cx.simulate_keystrokes("cmd-a");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
picks(&view, cx),
|
||||
@@ -392,7 +404,8 @@ fn the_cursor_stays_on_its_row_when_the_rows_reorder(cx: &mut TestAppContext) {
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
cx.simulate_keystrokes("enter up");
|
||||
enter(cx);
|
||||
cx.simulate_keystrokes("up");
|
||||
settle(cx);
|
||||
let opened: Vec<String> = view.read_with(cx, |picks, _| {
|
||||
picks.1.iter().map(|key| key.to_string()).collect()
|
||||
@@ -0,0 +1,94 @@
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, TestAppContext, Window, div,
|
||||
};
|
||||
|
||||
use super::{enter, settle, setup};
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
forms::{Enter, Input, TextInput},
|
||||
lists::{ListItem, SelectableList},
|
||||
};
|
||||
|
||||
/// A list whose open hands focus to a button beside it, and what each heard.
|
||||
struct Handoff {
|
||||
button: FocusHandle,
|
||||
heard: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Render for Handoff {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (to, opened, clicked) = (self.button.clone(), cx.entity(), cx.entity());
|
||||
div()
|
||||
.child(
|
||||
SelectableList::new("handoff")
|
||||
.row("a", ListItem::new("handoff-a", "A"))
|
||||
.on_activate(move |_, window, cx| {
|
||||
window.focus(&to);
|
||||
opened.update(cx, |view, _| view.heard.push("open"));
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("after", "After")
|
||||
.focus_handle(&self.button)
|
||||
.on_click(move |_, _, cx| {
|
||||
clicked.update(cx, |view, _| view.heard.push("click"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter opens on its release, so an open that moves focus to a button leaves the button unpressed.
|
||||
#[gpui::test]
|
||||
fn an_open_that_moves_focus_leaves_the_release_alone(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Handoff {
|
||||
button: cx.focus_handle().tab_stop(true),
|
||||
heard: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
enter(cx);
|
||||
settle(cx);
|
||||
assert_eq!(view.read_with(cx, |view, _| view.heard.clone()), ["open"]);
|
||||
}
|
||||
|
||||
/// A field whose Enter moves focus on to a list, and what the list opened.
|
||||
struct Relay {
|
||||
field: Entity<TextInput>,
|
||||
heard: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl Render for Relay {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let opened = cx.entity();
|
||||
div()
|
||||
.capture_action(|_: &Enter, window, _| window.focus_next())
|
||||
.child(Input::new(&self.field))
|
||||
.child(
|
||||
SelectableList::new("relay")
|
||||
.row("a", ListItem::new("relay-a", "A"))
|
||||
.on_activate(move |_, _, cx| {
|
||||
opened.update(cx, |view, _| view.heard.push("open"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A release whose press fell elsewhere opens nothing.
|
||||
#[gpui::test]
|
||||
fn a_release_that_began_elsewhere_opens_nothing(cx: &mut TestAppContext) {
|
||||
setup(cx);
|
||||
cx.update(crate::forms::bind_keys);
|
||||
let (view, cx) = cx.add_window_view(|window, cx| Relay {
|
||||
field: cx.new(|cx| TextInput::new(window, cx)),
|
||||
heard: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
enter(cx);
|
||||
settle(cx);
|
||||
assert!(view.read_with(cx, |view, _| view.heard.is_empty()));
|
||||
}
|
||||
Reference in new issue
Block a user