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
@@ -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;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user