Interaction · Keys, scroll and focus: roving focus, scroll sync, type-ahead
RovingFocus keeps a row or column of items behind one Tab stop: the arrows walk focus, Home and End reach the ends, Enter or Space presses, and a press moves the stop without taking focus. ScrollSync keeps boxes at one place along an axis: the pane a wheel turned leads, and a canvas ahead of the panes copies its place before they draw. SelectableList moves its cursor to the row that starts with the letters typed, the letters joining within a pause and a Command letter left to the app. forms::History becomes public as the undo manager. The gallery shows gpui's own key bindings, a two-stroke chord waiting on its first stroke, uniform_list, autofocus and the focus ring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec4c15b539
commit
9e3e6c109d
18 files changed
+925
-29
No files matched your search
@@ -113,6 +113,10 @@ impl ListItem {
|
||||
self.disabled
|
||||
}
|
||||
|
||||
pub(crate) fn title_text(&self) -> &SharedString {
|
||||
&self.title
|
||||
}
|
||||
|
||||
/// Makes the row pressable; the press keeps focus where it is.
|
||||
pub fn on_click(
|
||||
mut self,
|
||||
|
||||
+90
-4
@@ -1,4 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
use std::{
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Div, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||
@@ -38,7 +41,7 @@ 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 owner's last selection, and the one the list last sent.
|
||||
/// 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; whether Enter's press fell here; the letters typed and when the last one came.
|
||||
#[derive(Default)]
|
||||
struct Cursor {
|
||||
at: usize,
|
||||
@@ -48,13 +51,36 @@ struct Cursor {
|
||||
seen: Option<Vec<SharedString>>,
|
||||
sent: Option<Vec<SharedString>>,
|
||||
armed: bool,
|
||||
typed: String,
|
||||
typed_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Letters typed within this pause join one prefix.
|
||||
const TYPING: Duration = Duration::from_millis(800);
|
||||
|
||||
/// The row typed letters go to: one letter moves past the cursor to the next row that starts with it, round to the top; more letters stay on the cursor's row while it still starts with them. Disabled rows are passed over.
|
||||
pub(crate) fn typed_to(
|
||||
titles: &[SharedString],
|
||||
off: &[bool],
|
||||
at: usize,
|
||||
typed: &str,
|
||||
) -> Option<usize> {
|
||||
let count = titles.len();
|
||||
let from = if typed.chars().count() == 1 {
|
||||
at + 1
|
||||
} else {
|
||||
at
|
||||
};
|
||||
(0..count)
|
||||
.map(|step| (from + step) % count)
|
||||
.find(|ix| !off[*ix] && titles[*ix].to_lowercase().starts_with(typed))
|
||||
}
|
||||
|
||||
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, 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.
|
||||
/// 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, typing a row's first letters moves to it, 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,
|
||||
@@ -153,6 +179,11 @@ impl RenderOnce for SelectableList {
|
||||
if (at, anchor) != (cursor.read(cx).at, cursor.read(cx).anchor) {
|
||||
cursor.update(cx, |cursor, _| (cursor.at, cursor.anchor) = (at, anchor));
|
||||
}
|
||||
let titles: Rc<[SharedString]> = self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(_, item)| item.title_text().clone())
|
||||
.collect();
|
||||
let off: Rc<[bool]> = self
|
||||
.rows
|
||||
.iter()
|
||||
@@ -270,6 +301,31 @@ impl RenderOnce for SelectableList {
|
||||
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 letter = event
|
||||
.keystroke
|
||||
.key_char
|
||||
.as_ref()
|
||||
.filter(|typed| !typed.trim().is_empty())
|
||||
.filter(|_| !held.platform && !held.control && !held.alt && !held.function);
|
||||
if let Some(letter) = letter {
|
||||
let now = cx.background_executor().now();
|
||||
let typed = cursor.update(cx, |cursor, _| {
|
||||
if cursor
|
||||
.typed_at
|
||||
.is_none_or(|at| now.duration_since(at) > TYPING)
|
||||
{
|
||||
cursor.typed.clear();
|
||||
}
|
||||
cursor.typed.push_str(&letter.to_lowercase());
|
||||
cursor.typed_at = Some(now);
|
||||
cursor.typed.clone()
|
||||
});
|
||||
if let Some(to) = typed_to(&titles, &off, at, &typed) {
|
||||
cx.stop_propagation();
|
||||
pick(to, Pick::One, window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (to, how) = match event.keystroke.key.as_str() {
|
||||
"down" => ((at + 1..count).find(open), extend),
|
||||
"up" => ((0..at).rev().find(open), extend),
|
||||
@@ -314,7 +370,7 @@ pub(crate) fn row_action(button: impl IntoElement) -> gpui::Div {
|
||||
mod tests {
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::{Pick, picked};
|
||||
use super::{Pick, picked, typed_to};
|
||||
|
||||
fn keys(list: &[&'static str]) -> Vec<SharedString> {
|
||||
list.iter().map(|key| SharedString::from(*key)).collect()
|
||||
@@ -342,4 +398,34 @@ mod tests {
|
||||
);
|
||||
assert_eq!(picked(&rows, &keys(&["b"]), 0, 0, Pick::All), rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_letters_go_to_the_next_row_that_starts_with_them() {
|
||||
let titles = keys(&["Apple", "Banana", "Blueberry", "Cherry"]);
|
||||
let open = [false; 4];
|
||||
assert_eq!(typed_to(&titles, &open, 0, "b"), Some(1));
|
||||
assert_eq!(
|
||||
typed_to(&titles, &open, 1, "b"),
|
||||
Some(2),
|
||||
"one letter moves on"
|
||||
);
|
||||
assert_eq!(
|
||||
typed_to(&titles, &open, 2, "b"),
|
||||
Some(1),
|
||||
"round to the top"
|
||||
);
|
||||
assert_eq!(
|
||||
typed_to(&titles, &open, 1, "ba"),
|
||||
Some(1),
|
||||
"a prefix stays while it fits"
|
||||
);
|
||||
assert_eq!(typed_to(&titles, &open, 1, "bl"), Some(2));
|
||||
assert_eq!(typed_to(&titles, &open, 0, "z"), None);
|
||||
let off = [false, true, false, false];
|
||||
assert_eq!(
|
||||
typed_to(&titles, &off, 0, "b"),
|
||||
Some(2),
|
||||
"a disabled row is passed over"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
Render, TestAppContext, Window, div,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, Modifiers,
|
||||
ParentElement, Render, TestAppContext, Window, div,
|
||||
};
|
||||
|
||||
use super::{enter, settle, setup};
|
||||
use super::{Picks, enter, picks, row, settle, setup};
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
forms::{Enter, Input, TextInput},
|
||||
@@ -92,3 +94,44 @@ fn a_release_that_began_elsewhere_opens_nothing(cx: &mut TestAppContext) {
|
||||
settle(cx);
|
||||
assert!(view.read_with(cx, |view, _| view.heard.is_empty()));
|
||||
}
|
||||
|
||||
/// Typing a row's first letters moves the cursor there and picks it; after a pause the letters start over.
|
||||
#[gpui::test]
|
||||
fn letters_move_to_the_row_that_starts_with_them(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);
|
||||
cx.simulate_input("c");
|
||||
settle(cx);
|
||||
assert_eq!(picks(&view, cx), ["c"]);
|
||||
cx.executor().advance_clock(Duration::from_secs(1));
|
||||
cx.simulate_input("b");
|
||||
settle(cx);
|
||||
assert_eq!(
|
||||
picks(&view, cx),
|
||||
["b"],
|
||||
"the pause started the letters over"
|
||||
);
|
||||
}
|
||||
|
||||
/// A letter held with Command is the app's shortcut, not a letter to move by: Command-A still takes all.
|
||||
#[gpui::test]
|
||||
fn a_letter_with_command_stays_a_shortcut(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(1), Modifiers::none());
|
||||
settle(cx);
|
||||
cx.simulate_event(gpui::KeyDownEvent {
|
||||
keystroke: gpui::Keystroke {
|
||||
modifiers: Modifiers::command(),
|
||||
key: "a".into(),
|
||||
key_char: Some("a".into()),
|
||||
},
|
||||
is_held: false,
|
||||
});
|
||||
settle(cx);
|
||||
assert_eq!(picks(&view, cx), ["a", "b", "c", "d"]);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod release;
|
||||
mod keys;
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
|
||||
Reference in new issue
Block a user