From 80c46b13d49bb4ce07b71b9097b43b1b7a850f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 13:13:09 -0400 Subject: [PATCH] =?UTF-8?q?Documents=20=C2=B7=20TemplatePicker:=20one=20Ta?= =?UTF-8?q?b=20stop,=20arrows=20and=20Enter,=20list=20semantics,=20builtin?= =?UTF-8?q?=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + src/documents/templates.rs | 86 ++++++++++++++++++++++++++++++-------- src/i18n/builtin.rs | 10 +++++ 3 files changed, 79 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c17caf9..28ca1a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/documents/templates.rs b/src/documents/templates.rs index 5c2e1b7..743ddec 100644 --- a/src/documents/templates.rs +++ b/src/documents/templates.rs @@ -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 { - 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 = 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() diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index cf323c0..700dc35 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -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.