Documents · TemplatePicker: one Tab stop, arrows and Enter, list semantics, builtin copy

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 13:13:09 -04:00
1 parent 718617939e
commit 80c46b13d4
3 files changed
+79 -18

No files matched your search

+1
View File
@@ -263,3 +263,4 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- 2026-10-05: an overlay its owner removes without calling `Close` still hands focus back: `take_focus` records each holder and what it took focus from, `give_back` forgets it, and when the focused element leaves the tree the root scope walks back past every gone holder to the focus before them all, else takes it; nested overlays removed together return to what the outer one took focus from. Before, a dialog whose action set its owner's state to closed left focus on the root, and keys reached nothing until a click.
- 2026-10-05: `Select::label` and `TextInput::label` name the control for assistive technology without drawing anything; a text input with neither label nor placeholder had no name, and a select read only its value, so a field named by a description list beside it was anonymous in the AX tree.
- 2026-10-05: `NumberInput::label` names the field for assistive technology without drawing it, through `TextInput::set_label` on the field's inner input, which lives across frames; a number field named only by a description list beside it was anonymous in the AX tree.
- 2026-10-05: `TemplatePicker`'s list is one Tab stop: up, down, Home and End move the highlight, Enter uses it; rows are list items with name, selection, place and count; its search placeholder, All chip, Use button and empty line read from the builtin catalog in English and Chinese. Before, the rows were reachable by mouse alone and anonymous in the AX tree.
+68 -18
View File
@@ -2,7 +2,7 @@ use std::rc::Rc;
use gpui::{
App, AppContext as _, Context, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
MouseButton, ParentElement, RenderOnce, Role, SharedString, StatefulInteractiveElement, Styled,
Window, div, prelude::*,
};
@@ -10,7 +10,8 @@ use super::render::MarkdownRenderer;
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, ChoiceChips, Input, Pick, TextInput},
primitives::{Icon, IconName},
i18n,
primitives::{FocusRing as _, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
@@ -82,7 +83,9 @@ impl RenderOnce for TemplatePicker {
(self.id.clone(), "picking"),
cx,
|window, cx: &mut Context<Picking>| Picking {
search: cx.new(|cx| TextInput::new(window, cx).placeholder("Find a template")),
search: cx.new(|cx| {
TextInput::new(window, cx).placeholder(i18n::text(cx, "templates.find", &[]))
}),
category: ALL.into(),
highlighted: 0,
},
@@ -102,6 +105,8 @@ impl RenderOnce for TemplatePicker {
.copied()
.find(|ix| *ix == highlighted)
.or(shown.first().copied());
// One stop for the list; arrows move, Enter uses.
let focus = tab_stop((self.id.clone(), "list-focus").into(), true, window, cx);
let mut categories: Vec<SharedString> = vec![ALL.into()];
for template in self.templates.iter() {
if !categories.contains(&template.category) {
@@ -114,9 +119,13 @@ impl RenderOnce for TemplatePicker {
let picking = picking.clone();
ChoiceChips::new(
(self.id.clone(), "categories"),
categories
.iter()
.map(|name| Choice::new(name.clone(), name.clone())),
categories.iter().map(|name| {
let shown = match name.as_ref() {
ALL => i18n::text(cx, "templates.all", &[]),
_ => name.clone(),
};
Choice::new(name.clone(), shown)
}),
)
.selected([category.clone()])
.on_change(move |values, _, cx| {
@@ -128,12 +137,18 @@ impl RenderOnce for TemplatePicker {
});
})
};
let rows = shown.iter().map(|&ix| {
let count = shown.len();
let rows = shown.iter().enumerate().map(|(at, &ix)| {
let template = &self.templates[ix];
let lit = current == Some(ix);
let pick = picking.clone();
div()
.id((self.id.clone(), format!("template-{ix}")))
.role(Role::ListItem)
.aria_label(template.name.clone())
.aria_selected(lit)
.aria_position_in_set(at + 1)
.aria_size_of_set(count)
.flex()
.gap_2()
.p_2()
@@ -168,6 +183,33 @@ impl RenderOnce for TemplatePicker {
),
)
});
// Arrows move, Enter uses.
let keys = {
let (picking, take, shown) = (picking.clone(), self.on_use.clone(), shown.clone());
move |event: &gpui::KeyDownEvent, window: &mut Window, cx: &mut App| {
let at = current.and_then(|ix| shown.iter().position(|each| *each == ix));
let to = match (event.keystroke.key.as_str(), at) {
("down", Some(at)) => shown.get(at + 1).copied(),
("up", Some(at)) => at.checked_sub(1).map(|at| shown[at]),
("home", _) => shown.first().copied(),
("end", _) => shown.last().copied(),
("enter", Some(_)) => {
if let (Some(take), Some(ix)) = (&take, current) {
log::info!("template picker: use {ix}");
take(ix, window, cx);
}
return;
}
_ => return,
};
if let Some(ix) = to {
picking.update(cx, |picking, cx| {
picking.highlighted = ix;
cx.notify();
});
}
}
};
let preview = match current {
Some(ix) => {
let take = self.on_use.clone();
@@ -191,16 +233,19 @@ impl RenderOnce for TemplatePicker {
.child(self.templates[ix].name.clone()),
)
.child(
Button::new((self.id.clone(), "use"), "Use template")
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.disabled(take.is_none())
.when_some(take, |button, take| {
button.on_click(move |_, window, cx| {
log::info!("template picker: use {ix}");
take(ix, window, cx)
})
}),
Button::new(
(self.id.clone(), "use"),
i18n::text(cx, "templates.use", &[]),
)
.variant(ButtonVariant::Primary)
.size(ControlSize::Sm)
.disabled(take.is_none())
.when_some(take, |button, take| {
button.on_click(move |_, window, cx| {
log::info!("template picker: use {ix}");
take(ix, window, cx)
})
}),
),
)
.child(
@@ -222,7 +267,7 @@ impl RenderOnce for TemplatePicker {
.items_center()
.justify_center()
.text_color(colors.fg_subtle)
.child("No template matches")
.child(i18n::text(cx, "templates.none", &[]))
.into_any_element(),
};
div()
@@ -253,6 +298,11 @@ impl RenderOnce for TemplatePicker {
.child(
div()
.id((self.id.clone(), "list"))
.role(Role::List)
.aria_label(i18n::text(cx, "templates.list", &[]))
.track_focus(&focus)
.focus_ring(cx)
.on_key_down(keys)
.flex_1()
.overflow_y_scroll()
.flex()
+10
View File
@@ -35,6 +35,11 @@ const EN: &[(&str, &str)] = &[
("dialog.cancel", "Cancel"),
("dialog.close", "Close"),
("state.unavailable", "Unavailable"),
("templates.find", "Find a template"),
("templates.all", "All"),
("templates.list", "Templates"),
("templates.use", "Use template"),
("templates.none", "No template matches"),
];
const ZH_CN: &[(&str, &str)] = &[
@@ -68,6 +73,11 @@ const ZH_CN: &[(&str, &str)] = &[
("dialog.cancel", "取消"),
("dialog.close", "关闭"),
("state.unavailable", "不可用"),
("templates.find", "查找模板"),
("templates.all", "全部"),
("templates.list", "模板"),
("templates.use", "使用模板"),
("templates.none", "没有匹配的模板"),
];
/// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.