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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 16:10:32 -04:00
1 parent ec4c15b539
commit 9e3e6c109d
18 files changed
+925 -29

No files matched your search

+2 -1
View File
@@ -53,6 +53,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
- Boxes that scroll together go through `interaction::ScrollSync`: the pane a wheel turned leads, and a canvas laid before the panes copies its place in prepaint, before they draw, so no frame shows them apart.
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`).
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces.
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
@@ -141,7 +142,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`).
- A chat holds at its newest message through gpui's `list` with `ListAlignment::Bottom`: it sticks while scrolled to the end, and `scroll_to` one past the last item sticks it again; that call sends no scroll event, so the owner clears its own away state (`chat::MessageList`).
- Anything pressable is a Tab stop: a row, card or toggle takes `tab_index(0)` and `focus_ring`, with a 1px border that stays transparent where it shows none; gpui presses it on the release of Enter or Space.
- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). Enter activates on its release, armed by its own press, so an activation that moves focus leaves the release nothing to press (`onboarding::HelpPanel`). A bare `ListItem` press is the pointer's only. Titled sections of such lists go through `lists::Sections`, for ChannelList, MemberList and MailboxList; each owner builds its rows and keeps its own checks.
- A list of pressable rows goes through `lists::SelectableList`, whose cursor reaches them by key: Up and Down move, Enter or a double press activates (`media::Playlist`). Enter activates on its release, armed by its own press, so an activation that moves focus leaves the release nothing to press (`onboarding::HelpPanel`). Typing a row's first letters moves the cursor there, the letters joining within a pause on the executor's clock; a letter held with Command, Control or Alt stays the app's. A bare `ListItem` press is the pointer's only. Titled sections of such lists go through `lists::Sections`, for ChannelList, MemberList and MailboxList; each owner builds its rows and keeps its own checks.
- Columns that must line up across rows sit absolutely at fractions of their row, as the bars over them do (`calendar::CalendarMonthView`): with real text, gpui 0.2.2 lets a `flex_1` cell's text widen it, and the test platform's fixed advances hide it.
- A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does.
- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up.
+1
View File
@@ -98,6 +98,7 @@ fn main() -> Result<()> {
.with_assets(Assets)
.run(move |cx: &mut App| {
ely_gpui_component::init(cx);
pages::bind_keys(cx);
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
cx.on_action(|_: &Quit, cx| cx.quit());
menus(cx);
+330
View File
@@ -0,0 +1,330 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant, IconButton},
forms::{History, Input, TextInput},
interaction::{RovingFocus, ScrollSync},
lists::{ListItem, SelectableList},
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{KbdCombo, format::plural},
};
use gpui::{
App, Axis, Context, Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement,
SharedString, Styled, Subscription, Window, actions, div, prelude::*, px, uniform_list,
};
use crate::{
probe::probe,
ui::{change, code, keep, section, specimen, specimens},
};
actions!(gallery_interaction, [Shout, SaveAll]);
const KEYS: &str = "InteractionKeys";
/// Binds the page's shortcut and chord under its own key context.
pub fn bind_keys(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("cmd-shift-h", Shout, Some(KEYS)),
KeyBinding::new("cmd-k cmd-s", SaveAll, Some(KEYS)),
]);
}
/// The key demo: what the bindings heard, and the first stroke of a chord while it waits.
struct Heard {
shouts: usize,
saves: usize,
pending: Option<SharedString>,
_pending: Subscription,
}
pub fn keys(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = window.use_keyed_state(
"interaction-heard",
cx,
|window, cx: &mut Context<Heard>| Heard {
shouts: 0,
saves: 0,
pending: None,
_pending: cx.observe_pending_input(window, |heard, window, cx| {
heard.pending = window.pending_input_keystrokes().map(|strokes| {
strokes
.iter()
.map(|stroke| stroke.unparse())
.collect::<Vec<_>>()
.join(" ")
.into()
});
cx.notify();
}),
},
);
let now = state.read(cx);
let (shouts, saves, pending) = (now.shouts, now.saves, now.pending.clone());
let theme = cx.theme();
let [shout, save] = [(); 2].map(|_| state.clone());
section(
"Hotkeys / KeyBinding · KeyChord",
"gpui's own bindings: actions! names them, bind_keys ties keys to them under a key context, and a binding of two strokes waits for the second while the first shows. Focus the box and try them. Ely adds nothing.",
cx,
)
.child(
probe(
"interaction-keys",
div()
.id("interaction-keys")
.key_context(KEYS)
.w(px(420.))
.p_4()
.flex()
.flex_col()
.gap_2()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.tab_index(0)
.focus_ring(cx)
.on_action(move |_: &Shout, _, cx| change(&shout, cx, |heard| heard.shouts += 1))
.on_action(move |_: &SaveAll, _, cx| change(&save, cx, |heard| heard.saves += 1))
.child(div().flex().items_center().gap_2().child(KbdCombo::new("cmd-shift-h")).child(format!("heard {}", plural(shouts as u64, "time", "times"))))
.child(div().flex().items_center().gap_2().child(KbdCombo::new("cmd-k cmd-s")).child(format!("saved {}", plural(saves as u64, "time", "times"))))
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(match pending {
Some(first) => format!("{first} … waiting for the second key"),
None => "No chord waiting.".to_string(),
}),
),
),
)
.child(code("actions!(…) · cx.bind_keys([KeyBinding::new(\"cmd-k cmd-s\", SaveAll, Some(\"InteractionKeys\"))]) · .key_context(…) · .on_action(…)", cx))
}
/// The undo demo: a count, and its history.
#[derive(Default)]
struct Tally {
count: i32,
history: History<i32>,
}
pub fn undo(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep("interaction-tally", Tally::default, window, cx);
let now = state.read(cx);
let (count, can_undo, can_redo) = (now.count, now.history.can_undo(), now.history.can_redo());
let [add, take, back, again] = [(); 4].map(|_| state.clone());
let theme = cx.theme();
section(
"Undo / Redo Manager",
"forms::History keeps whole snapshots, the one every editor here undoes with; a burst of typing makes one step.",
cx,
)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.gap_2()
.child(div().w(px(64.)).text_size(theme.text_size(TextSize::Lg)).child(count.to_string()))
.child(Button::new("interaction-add", "Add one").on_click(move |_, _, cx| {
change(&add, cx, |tally| {
tally.history.record(tally.count, false);
tally.count += 1;
})
}))
.child(Button::new("interaction-take", "Take one").on_click(move |_, _, cx| {
change(&take, cx, |tally| {
tally.history.record(tally.count, false);
tally.count -= 1;
})
}))
.child(
IconButton::new("interaction-undo", IconName::Undo2)
.variant(ButtonVariant::Ghost)
.tooltip("Undo")
.disabled(!can_undo)
.on_click(move |_, _, cx| {
change(&back, cx, |tally| {
let previous = tally.history.undo(tally.count).expect("Undo rests with no step");
tally.count = previous;
})
}),
)
.child(
IconButton::new("interaction-redo", IconName::Redo2)
.variant(ButtonVariant::Ghost)
.tooltip("Redo")
.disabled(!can_redo)
.on_click(move |_, _, cx| {
change(&again, cx, |tally| {
let next = tally.history.redo(tally.count).expect("Redo rests with no step");
tally.count = next;
})
}),
),
)
}
pub fn scrolling(cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
let (border, muted) = (theme.colors.border, theme.colors.fg_muted);
let text = theme.text_size(TextSize::Sm);
let lines = |side: &'static str| {
div().flex().flex_col().children((1..=40).map(move |n| {
div()
.px_3()
.py_1()
.text_size(text)
.child(format!("{side} line {n}"))
}))
};
section(
"VirtualScroller · ScrollSync · InfiniteScroll Trigger",
"gpui's uniform_list draws only the rows in view, ten thousand here. Two panes scroll as one: a wheel on either takes the other along. An endless list is lists::InfiniteList.",
cx,
)
.child(
specimens()
.child(specimen(
"uniform_list",
div().w(px(220.)).h(px(200.)).rounded_lg().border_1().border_color(border).child(
uniform_list("interaction-rows", 10_000, move |range, _, _| {
range
.map(|ix| div().w_full().px_3().py_1().text_size(text).text_color(muted).child(format!("Row {}", ix + 1)))
.collect()
})
.h_full(),
),
cx,
))
.child(specimen(
"ScrollSync",
ScrollSync::new("interaction-sync", Axis::Vertical)
.w(px(360.))
.h(px(200.))
.rounded_lg()
.border_1()
.border_color(border)
.pane(lines("Before"))
.pane(div().border_l_1().border_color(border).child(lines("After"))),
cx,
)),
)
.child(code("uniform_list(id, count, |range, window, cx| rows)", cx))
}
/// The focus demos: whether the field shows, the tool pressed, and the fruit picked.
struct Focusing {
field: bool,
tool: SharedString,
fruit: Vec<SharedString>,
}
pub fn focus(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep(
"interaction-focusing",
|| Focusing {
field: false,
tool: "bold".into(),
fruit: vec!["apple".into()],
},
window,
cx,
);
let field = window.use_keyed_state("interaction-autofocus", cx, |window, cx| {
TextInput::new(window, cx).placeholder("Focused as it appears")
});
let now = state.read(cx);
let (shown, tool, fruit) = (now.field, now.tool.clone(), now.fruit.clone());
let [show, press, pick] = [(); 3].map(|_| state.clone());
let focus = field.read(cx).focus_handle(cx);
let theme = cx.theme();
let tools = [
("bold", IconName::Bold),
("italic", IconName::Italic),
("underline", IconName::Underline),
("strike", IconName::Strikethrough),
];
let toolbar = tools.into_iter().fold(
RovingFocus::new("interaction-tools", Axis::Horizontal)
.gap_0p5()
.p_0p5()
.rounded_lg()
.border_1()
.border_color(theme.colors.border),
|bar, (key, icon)| {
let on = tool.as_ref() == key;
bar.item(
key,
div()
.p_1p5()
.rounded_md()
.when(on, |tool| tool.bg(theme.colors.active))
.child(Icon::new(icon).size(IconSize::Sm).color(if on {
theme.colors.fg
} else {
theme.colors.fg_muted
})),
)
},
);
const FRUIT: [&str; 6] = ["Apple", "Apricot", "Banana", "Blueberry", "Cherry", "Grape"];
let list = FRUIT.iter().fold(
SelectableList::new("interaction-fruit")
.w(px(200.))
.h(px(180.)),
|list, name| {
list.row(
name.to_lowercase(),
ListItem::new(
SharedString::from(format!("interaction-fruit-{name}")),
*name,
),
)
},
);
section(
"Autofocus · RovingTabIndex · TypeAhead · FocusVisible",
"A field takes focus as it appears through gpui's window.focus. A toolbar keeps one Tab stop and the arrows walk it. Typing a row's first letters moves a list to it. The focus ring is primitives::FocusRing.",
cx,
)
.child(
specimens()
.child(specimen(
"Autofocus",
div().w(px(240.)).flex().flex_col().gap_2().child(
Button::new("interaction-show-field", if shown { "Hide the field" } else { "Show a field" })
.variant(ButtonVariant::Secondary)
.on_click(move |_, window, cx| {
change(&show, cx, |focusing| focusing.field = !focusing.field);
let focus = focus.clone();
window.defer(cx, move |window, _| window.focus(&focus));
}),
)
.when(shown, |column| column.child(Input::new(&field))),
cx,
))
.child(specimen(
"RovingFocus",
probe(
"interaction-tools",
toolbar.on_press(move |key, _, cx| {
let key = key.clone();
change(&press, cx, |focusing| focusing.tool = key)
}),
),
cx,
))
.child(specimen(
"TypeAhead → lists::SelectableList",
probe(
"interaction-fruit",
list.selected(fruit).on_change(move |keys, _, cx| {
let keys = keys.to_vec();
change(&pick, cx, |focusing| focusing.fruit = keys)
}),
),
cx,
)),
)
}
+21 -1
View File
@@ -1,7 +1,10 @@
use gpui::{AnyElement, App, Window, div, prelude::*};
mod keys;
mod pointer;
pub use keys::bind_keys;
use super::Page;
use crate::script::Step;
@@ -9,7 +12,7 @@ pub const PAGE: Page = Page {
number: 38,
slug: "interaction",
title: "Interaction",
summary: "Drag and drop, resizing and turning, band selection, presses and gestures.",
summary: "Drag and drop, resizing and turning, band selection, presses and gestures, keys, scrolling and focus.",
render,
script: SCRIPT,
};
@@ -25,11 +28,28 @@ const SCRIPT: &[Step] = &[
Step::UpAt("interaction-rotatable", 116.0, 70.0),
Step::Wait(100),
Step::Shot("turned"),
Step::DownAt("interaction-keys", 12.0, 12.0),
Step::UpAt("interaction-keys", 12.0, 12.0),
Step::Key("cmd-k"),
Step::Wait(100),
Step::Shot("chord-waiting"),
Step::Key("cmd-s"),
Step::Wait(100),
Step::Shot("chord-done"),
Step::DownAt("interaction-fruit", 40.0, 16.0),
Step::UpAt("interaction-fruit", 40.0, 16.0),
Step::Type("bl"),
Step::Wait(100),
Step::Shot("type-ahead"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(pointer::drag_and_drop(window, cx))
.child(pointer::shapes(window, cx))
.child(keys::keys(window, cx))
.child(keys::undo(window, cx))
.child(keys::scrolling(cx))
.child(keys::focus(window, cx))
.into_any_element()
}
@@ -4,6 +4,7 @@ use ely_gpui_component::{
motion::Reorder,
primitives::{FocusRing, Pressable},
theme::{ActiveTheme, Elevation, Radius, TextSize},
typography::format::plural,
};
use gpui::{
App, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
@@ -298,7 +299,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
change(&double, cx, |shapes| shapes.doubles += 1)
}
})
.child(format!("Opened {doubles} times")),
.child(format!("Opened {}", plural(doubles as u64, "time", "times"))),
cx,
))
.child(specimen(
+1
View File
@@ -94,6 +94,7 @@ pub const ALL: &[Page] = &[
theme::PAGE,
];
pub use interaction::bind_keys;
pub use shell::{open_about, open_managed};
pub fn find(slug: &str) -> Option<usize> {
+2 -2
View File
@@ -112,9 +112,9 @@ pub use structure::{
pub use switch::Switch;
pub use tags::TagInput;
pub(crate) use text::{
Backspace, Down, Enter, History, Redo, Submit, Undo, Up, bind_keys, from_utf16, to_utf16,
Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys, from_utf16, to_utf16,
};
pub use text::{Highlight, InputEvent, TextInput};
pub use text::{Highlight, History, InputEvent, TextInput};
pub use transfer::TransferList;
pub use unit::UnitInput;
pub use upload::{Upload, UploadList, UploadState};
+1 -1
View File
@@ -94,7 +94,7 @@ pub(crate) struct Snapshot {
const JOIN: Duration = Duration::from_millis(900);
/// Undo and redo as whole snapshots of what an editor holds.
pub(crate) struct History<S> {
pub struct History<S> {
undo: Vec<S>,
redo: Vec<S>,
typing_since: Option<Instant>,
+2 -1
View File
@@ -13,8 +13,9 @@ use gpui::{
};
pub(crate) use actions::{Backspace, Down, Enter, Redo, Submit, Undo, Up, bind_keys};
pub use edit::History;
use edit::Snapshot;
pub(crate) use edit::{History, from_utf16, to_utf16};
pub(crate) use edit::{from_utf16, to_utf16};
pub use highlight::Highlight;
use crate::theme::ActiveTheme;
+4
View File
@@ -1,10 +1,14 @@
mod resizable;
mod rotatable;
mod roving;
mod selection;
mod sync;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use resizable::Resizable;
pub use rotatable::Rotatable;
pub use roving::RovingFocus;
pub use selection::SelectionArea;
pub use sync::ScrollSync;
+170
View File
@@ -0,0 +1,170 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Axis, Div, ElementId, FocusHandle, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement,
StyleRefinement, Styled, Window, div, prelude::*,
};
use crate::{
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius},
};
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// Where an arrow key or Home and End take focus among `count` items from `at`, along `axis`; the ends hold.
fn roved(key: &str, axis: Axis, at: usize, count: usize) -> Option<usize> {
let (back, ahead) = match axis {
Axis::Horizontal => ("left", "right"),
Axis::Vertical => ("up", "down"),
};
match key {
key if key == back => at.checked_sub(1),
key if key == ahead => (at + 1 < count).then_some(at + 1),
"home" => Some(0),
"end" => count.checked_sub(1),
_ => None,
}
}
/// Items in a row or a column behind one Tab stop: the arrows along the axis move focus among them, Home and End go to the ends, and Enter or Space presses the one focused. The stop stays on the item last focused or pressed; a press, as a button's, leaves focus where it was. The owner lays them out through the box's style.
#[derive(IntoElement)]
pub struct RovingFocus {
id: ElementId,
axis: Axis,
base: Div,
items: Vec<(SharedString, AnyElement)>,
on_press: Option<OnKey>,
}
impl RovingFocus {
pub fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
Self {
id: id.into(),
axis,
base: div(),
items: Vec::new(),
on_press: None,
}
}
pub fn item(mut self, key: impl Into<SharedString>, content: impl IntoElement) -> Self {
self.items.push((key.into(), content.into_any_element()));
self
}
/// Runs with the key of the item pressed.
pub fn on_press(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
}
impl Styled for RovingFocus {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for RovingFocus {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_press = self
.on_press
.unwrap_or_else(|| panic!("roving focus {id:?} has no on_press"));
assert!(!self.items.is_empty(), "roving focus {id:?} has no item");
let stop = window.use_keyed_state((id.clone(), "stop"), cx, |_, _| 0usize);
let count = self.items.len();
let handles: Vec<FocusHandle> = (0..count)
.map(|ix| tab_stop((id.clone(), format!("item-{ix}")).into(), false, window, cx))
.collect();
let focused = handles.iter().position(|handle| handle.is_focused(window));
let at = focused.unwrap_or(*stop.read(cx)).min(count - 1);
if at != *stop.read(cx) {
stop.update(cx, |stop, _| *stop = at);
}
let handles: Vec<FocusHandle> = handles
.into_iter()
.enumerate()
.map(|(ix, handle)| handle.tab_stop(ix == at))
.collect();
let theme = cx.theme();
let radius = theme.radius(Radius::Md);
let items: Vec<_> = self
.items
.into_iter()
.zip(handles.iter().cloned())
.enumerate()
.map(|(ix, ((key, content), handle))| {
let (press, held) = (on_press.clone(), stop.clone());
div()
.id((id.clone(), format!("item-{ix}")))
.track_focus(&handle)
.rounded(radius)
.border_1()
.border_color(gpui::transparent_black())
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
held.update(cx, |stop, _| *stop = ix);
})
.on_click(move |_, window, cx| {
log::info!("roving focus: press {key}");
press(&key, window, cx)
})
.child(content)
})
.collect();
let (axis, moved) = (self.axis, stop);
self.base
.id(id)
.flex()
.when(axis == Axis::Vertical, |group| group.flex_col())
.on_key_down(move |event, window, cx| {
if event.keystroke.modifiers.modified() {
return;
}
let Some(to) = roved(&event.keystroke.key, axis, at, count) else {
return;
};
cx.stop_propagation();
moved.update(cx, |stop, cx| {
*stop = to;
cx.notify();
});
window.focus(&handles[to]);
})
.children(items)
}
}
#[cfg(test)]
mod tests {
use gpui::Axis;
use super::roved;
#[test]
fn arrows_move_along_the_axis_and_hold_at_the_ends() {
assert_eq!(roved("right", Axis::Horizontal, 0, 3), Some(1));
assert_eq!(
roved("right", Axis::Horizontal, 2, 3),
None,
"the end holds"
);
assert_eq!(roved("left", Axis::Horizontal, 0, 3), None);
assert_eq!(
roved("down", Axis::Horizontal, 0, 3),
None,
"across the axis does nothing"
);
assert_eq!(roved("down", Axis::Vertical, 0, 3), Some(1));
assert_eq!(roved("end", Axis::Vertical, 0, 3), Some(2));
assert_eq!(roved("home", Axis::Horizontal, 2, 3), Some(0));
}
}
+133
View File
@@ -0,0 +1,133 @@
use gpui::{
AnyElement, App, Axis, Div, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
Point, RenderOnce, ScrollHandle, StatefulInteractiveElement, StyleRefinement, Styled, Window,
canvas, div, point, prelude::*,
};
use crate::layout::on_axis;
/// `to` with its place along `axis` taken from `from`.
fn along(to: Point<Pixels>, from: Point<Pixels>, axis: Axis) -> Point<Pixels> {
match axis {
Axis::Vertical => point(to.x, from.y),
Axis::Horizontal => point(from.x, to.y),
}
}
/// Boxes that scroll together along an axis: a wheel on one takes the others to its place before they draw. Side by side for a vertical axis, stacked for a horizontal one; the owner sizes them through the frame's style, and each fills its share.
#[derive(IntoElement)]
pub struct ScrollSync {
id: ElementId,
axis: Axis,
base: Div,
panes: Vec<AnyElement>,
}
impl ScrollSync {
pub fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
Self {
id: id.into(),
axis,
base: div(),
panes: Vec::new(),
}
}
pub fn pane(mut self, content: impl IntoElement) -> Self {
self.panes.push(content.into_any_element());
self
}
}
impl Styled for ScrollSync {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for ScrollSync {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, axis) = (self.id, self.axis);
assert!(self.panes.len() >= 2, "scroll sync {id:?} needs two panes");
let handles: Vec<ScrollHandle> = (0..self.panes.len())
.map(|ix| {
window
.use_keyed_state((id.clone(), format!("pane-{ix}")), cx, |_, _| {
ScrollHandle::new()
})
.read(cx)
.clone()
})
.collect();
let leader = window.use_keyed_state((id.clone(), "leader"), cx, |_, _| None::<usize>);
let (lead, synced) = (*leader.read(cx), handles.clone());
let sync = canvas(
move |_, _, _| {
let Some(lead) = lead else { return };
let place = synced[lead].offset();
for (ix, handle) in synced.iter().enumerate() {
let now = handle.offset();
let next = along(now, place, axis);
if ix != lead && next != now {
handle.set_offset(next);
}
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_0();
let panes =
self.panes
.into_iter()
.zip(handles)
.enumerate()
.map(|(ix, (content, handle))| {
let wheel = leader.clone();
let pane = div()
.id((id.clone(), format!("pane-{ix}")))
.flex_1()
.track_scroll(&handle)
.on_scroll_wheel(move |_, _, cx| {
if *wheel.read(cx) != Some(ix) {
log::info!("scroll sync: pane {ix} leads");
wheel.update(cx, |lead, _| *lead = Some(ix));
}
})
.child(content);
on_axis(match axis {
Axis::Vertical => pane.min_w_0().overflow_y_scroll(),
Axis::Horizontal => pane.min_h_0().overflow_x_scroll(),
})
});
self.base
.id(id.clone())
.relative()
.flex()
.when(axis == Axis::Horizontal, |frame| frame.flex_col())
.child(sync)
.children(panes)
}
}
#[cfg(test)]
mod tests {
use gpui::{Axis, point, px};
use super::along;
#[test]
fn a_place_follows_only_along_the_axis() {
let (mine, theirs) = (point(px(5.0), px(-20.0)), point(px(-40.0), px(-90.0)));
assert_eq!(
along(mine, theirs, Axis::Vertical),
point(px(5.0), px(-90.0))
);
assert_eq!(
along(mine, theirs, Axis::Horizontal),
point(px(-40.0), px(-20.0))
);
}
}
+105 -4
View File
@@ -1,10 +1,10 @@
use gpui::{
AnyElement, Context, Entity, IntoElement, KeyBinding, Modifiers, MouseButton, ParentElement,
Pixels, Point, Render, SharedString, Size, Styled, TestAppContext, VisualTestContext, Window,
div, point, px, size,
AnyElement, Axis, Context, Entity, InteractiveElement, IntoElement, KeyBinding, Modifiers,
MouseButton, ParentElement, Pixels, Point, Render, SharedString, Size, Styled, TestAppContext,
VisualTestContext, Window, div, point, prelude::*, px, size,
};
use super::{Resizable, Rotatable, SelectionArea};
use super::{Resizable, Rotatable, RovingFocus, ScrollSync, SelectionArea};
use crate::{forms, primitives::FocusNext, theme::Theme};
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
@@ -298,3 +298,104 @@ fn the_keyboard_walks_and_picks_tiles(cx: &mut TestAppContext) {
tap("space", cx);
assert_eq!(picked(&host, cx), ["c"]);
}
/// A key pressed and let go, as a press on a focused item needs.
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
cx.simulate_event(gpui::KeyUpEvent {
keystroke: gpui::Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
fn roving(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let _ = bench;
["a", "b", "c"]
.into_iter()
.fold(RovingFocus::new("tools", Axis::Horizontal), |group, key| {
group.item(key, div().size(px(24.0)))
})
.on_press(move |key, _, cx| {
let key = key.clone();
owner.update(cx, |bench, cx| {
bench.selected.push(key);
cx.notify();
})
})
.into_any_element()
}
/// The group is one stop: Right walks focus on, End jumps to the last, and a Tab away and back returns to the item last focused.
#[gpui::test]
fn a_group_keeps_one_stop_and_the_arrows_walk_it(cx: &mut TestAppContext) {
let (host, cx) = bench(roving, cx);
tab(1, cx);
tap("right", cx);
press("space", cx);
tap("end", cx);
press("space", cx);
tab(1, cx);
press("space", cx);
assert_eq!(
picked(&host, cx),
["b", "c", "c"],
"one stop, and it stays on the last item focused"
);
}
fn synced(_: &Bench, _: Entity<Bench>) -> AnyElement {
let lines = |side: &'static str| {
div().flex().flex_col().children((0..40).map(move |n| {
div().h(px(30.0)).when(n == 0, |line| {
line.debug_selector(move || format!("{side}-first"))
})
}))
};
ScrollSync::new("sync", Axis::Vertical)
.w(px(300.0))
.h(px(200.0))
.pane(lines("left"))
.pane(lines("right"))
.into_any_element()
}
/// A wheel on the left pane takes the right one to the same place.
#[gpui::test]
fn a_wheel_on_one_pane_takes_the_other_along(cx: &mut TestAppContext) {
let (_, cx) = bench(synced, cx);
let before = cx
.debug_bounds("right-first")
.expect("the right pane")
.origin
.y;
cx.simulate_event(gpui::ScrollWheelEvent {
position: at(80.0, 100.0),
delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-300.0))),
modifiers: Modifiers::none(),
touch_phase: gpui::TouchPhase::Moved,
});
settle(cx);
let after = cx
.debug_bounds("right-first")
.expect("the right pane")
.origin
.y;
assert_eq!(
before - after,
px(300.0),
"the right pane moved with the left"
);
}
/// A press moves the stop to the item pressed and leaves focus where it was: items 26 wide from 20, so b spans 46 to 72.
#[gpui::test]
fn a_press_moves_the_stop_and_leaves_focus(cx: &mut TestAppContext) {
let (host, cx) = bench(roving, cx);
cx.simulate_click(at(59.0, 33.0), Modifiers::none());
settle(cx);
let focused = cx.update(|window, cx| window.focused(cx).is_some());
assert!(!focused, "a press takes no focus");
tab(1, cx);
press("space", cx);
assert_eq!(picked(&host, cx), ["b", "b"]);
}
+4
View File
@@ -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
View File
@@ -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"]);
}
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::{
theme::Theme,
};
mod release;
mod keys;
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
+10 -10
View File
@@ -235,16 +235,16 @@
## T38b Interaction · Keys, scroll and focus
- [ ] Hotkeys / KeyBinding — native `actions!`, `bind_keys`, `key_context`
- [ ] KeyChord — native multi-keystroke bindings
- [ ] Undo / Redo Manager
- [ ] InfiniteScroll Trigger → `lists::InfiniteList`
- [ ] VirtualScroller — native `list()` / `uniform_list()`
- [ ] ScrollSync
- [ ] Autofocus — native `window.focus()`
- [ ] RovingTabIndex
- [ ] TypeAhead
- [ ] FocusVisible → `primitives::FocusRing`
- [x] Hotkeys / KeyBinding — native `actions!`, `bind_keys`, `key_context`
- [x] KeyChord — native multi-keystroke bindings; `window.pending_input_keystrokes()` shows the first stroke
- [x] Undo / Redo Manager → `forms::History`, made public
- [x] InfiniteScroll Trigger → `lists::InfiniteList`
- [x] VirtualScroller — native `list()` / `uniform_list()`
- [x] ScrollSync
- [x] Autofocus — native `window.focus()`
- [x] RovingTabIndex → `interaction::RovingFocus`
- [x] TypeAhead → `lists::SelectableList`, whose letters move its cursor
- [x] FocusVisible → `primitives::FocusRing`
## T39 Theme — `src/theme`