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

+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> {