diff --git a/src/buttons/button.rs b/src/buttons/button.rs index 7c90224..1cbab59 100644 --- a/src/buttons/button.rs +++ b/src/buttons/button.rs @@ -1,6 +1,7 @@ use gpui::{ App, ClickEvent, ElementId, FocusHandle, FontWeight, Hsla, IntoElement, MouseButton, - RenderOnce, SharedString, Window, div, prelude::*, transparent_black, + RenderOnce, Role, SharedString, StatefulInteractiveElement, Window, div, prelude::*, + transparent_black, }; use crate::{ @@ -232,6 +233,7 @@ impl RenderOnce for Button { let platform = cx.theme().platform; let hint = self.shortcut.map(|source| shortcut_text(&source, platform)); let spinner = (self.id.clone(), "spinner"); + let name = self.label.clone(); let parts = div() .flex() .items_center() @@ -251,6 +253,8 @@ impl RenderOnce for Button { div() .id(self.id) + .role(Role::Button) + .when(!name.is_empty(), |el| el.aria_label(name)) .flex() .flex_none() .items_center() diff --git a/src/buttons/icon_button.rs b/src/buttons/icon_button.rs index 2eac8c3..ff73720 100644 --- a/src/buttons/icon_button.rs +++ b/src/buttons/icon_button.rs @@ -1,6 +1,6 @@ use gpui::{ - App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, SharedString, Window, div, - prelude::*, + App, ClickEvent, ElementId, IntoElement, MouseButton, RenderOnce, Role, SharedString, Window, + div, prelude::*, }; use super::button::{ButtonVariant, ClickHandler, label_size, tone}; @@ -70,9 +70,15 @@ impl RenderOnce for IconButton { let tone = tone(self.variant, &theme.colors); let (_, icon_size) = label_size(self.size); let side = theme.control_height(self.size); + let name: SharedString = self + .tooltip + .clone() + .unwrap_or_else(|| self.icon.name().into()); div() .id(self.id) + .role(Role::Button) + .aria_label(name) .flex() .flex_none() .items_center() diff --git a/src/editor/symbols.rs b/src/editor/symbols.rs index 4200899..e6a8481 100644 --- a/src/editor/symbols.rs +++ b/src/editor/symbols.rs @@ -6,6 +6,7 @@ use gpui::{ use crate::{ buttons::SegmentedControl, + i18n, lists::{Tree, TreeNode}, navigation::{Group, Palette, Row, fuzzy, marked, query_field}, primitives::IconName, @@ -258,7 +259,12 @@ impl GoToSymbol { impl RenderOnce for GoToSymbol { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Go to a symbol", window, cx); + let input = query_field( + &self.id, + i18n::text(cx, "palette.symbol.placeholder", &[]), + window, + cx, + ); let query = input.read(cx).text().trim().to_string(); let colors = cx.theme().colors.clone(); let mut found: Vec<(i32, Row)> = flattened(&self.symbols) @@ -278,6 +284,7 @@ impl RenderOnce for GoToSymbol { }); let row = Row { value: symbol.line.to_string().into(), + name: symbol.name.clone(), icon: Some(symbol.kind.icon()), label: marked(symbol.name.clone(), hits, cx), detail, @@ -310,7 +317,7 @@ impl RenderOnce for GoToSymbol { rows: found.into_iter().map(|(_, row)| row).collect(), }], start: 0, - empty: "No matching symbols".into(), + empty: i18n::text(cx, "palette.symbol.empty", &[]), on_pick, on_close: self.on_close, } diff --git a/src/editor/welcome.rs b/src/editor/welcome.rs index fb52554..66af1d8 100644 --- a/src/editor/welcome.rs +++ b/src/editor/welcome.rs @@ -9,6 +9,7 @@ use gpui::{ use crate::{ buttons::{ButtonVariant, IconButton}, forms::Checkbox, + i18n, navigation::{Group, Palette, Row, fuzzy, marked, query_field}, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, @@ -83,7 +84,7 @@ impl RenderOnce for RecentProjects { ); let button = |key: &'static str, icon: IconName, - words: &'static str, + words: SharedString, action: Option| { IconButton::new((self.id.clone(), format!("{key}-{ix}")), icon) .variant(ButtonVariant::Ghost) @@ -96,6 +97,15 @@ impl RenderOnce for RecentProjects { }) }) }; + let (pin_icon, pin_tip) = if project.pinned { + ( + IconName::PinOff, + i18n::text(cx, "palette.project.unpin", &[]), + ) + } else { + (IconName::Pin, i18n::text(cx, "palette.project.pin", &[])) + }; + let remove_tip = i18n::text(cx, "palette.project.remove", &[]); div() .id((self.id.clone(), format!("project-{ix}"))) .flex() @@ -157,17 +167,8 @@ impl RenderOnce for RecentProjects { .text_color(colors.fg_subtle) .child(project.opened.clone()), ) - .child(button( - "pin", - if project.pinned { - IconName::PinOff - } else { - IconName::Pin - }, - if project.pinned { "Unpin" } else { "Pin" }, - pin, - )) - .child(button("remove", IconName::X, "Remove from recent", remove)) + .child(button("pin", pin_icon, pin_tip, pin)) + .child(button("remove", IconName::X, remove_tip, remove)) }); div() .flex() @@ -212,7 +213,12 @@ impl ProjectSwitcher { impl RenderOnce for ProjectSwitcher { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Switch to a project", window, cx); + let input = query_field( + &self.id, + i18n::text(cx, "palette.project.placeholder", &[]), + window, + cx, + ); let query = input.read(cx).text().trim().to_string(); let colors = cx.theme().colors.clone(); let mut found: Vec<(i32, Row)> = ordered(&self.projects) @@ -227,6 +233,7 @@ impl RenderOnce for ProjectSwitcher { score, Row { value: ix.to_string().into(), + name: project.name.clone(), icon: Some(if project.pinned { IconName::Pin } else { @@ -271,7 +278,7 @@ impl RenderOnce for ProjectSwitcher { rows: found.into_iter().map(|(_, row)| row).collect(), }], start: 0, - empty: "No matching projects".into(), + empty: i18n::text(cx, "palette.project.empty", &[]), on_pick, on_close: self.on_close, } diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs index d9df79d..ddddd03 100644 --- a/src/forms/text/actions.rs +++ b/src/forms/text/actions.rs @@ -1,7 +1,7 @@ use gpui::{ App, ClipboardItem, Context, CursorStyle, InteractiveElement, IntoElement, KeyBinding, - MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, Window, actions, - div, + MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Role, + StatefulInteractiveElement, Styled, Window, actions, div, prelude::*, }; use super::{ @@ -359,6 +359,11 @@ impl Render for TextInput { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { div() .id("ely-text-input") + .role(Role::TextInput) + .when(!self.placeholder.is_empty(), |el| { + el.aria_label(self.placeholder.clone()) + }) + .when(!self.masked, |el| el.aria_value(self.text.clone())) .key_context(CONTEXT) .track_focus(&self.focus) .cursor(if self.disabled { diff --git a/src/git/refs.rs b/src/git/refs.rs index f47fc9f..62e1efa 100644 --- a/src/git/refs.rs +++ b/src/git/refs.rs @@ -7,6 +7,7 @@ use gpui::{ use crate::{ buttons::{ButtonVariant, IconButton}, + i18n, navigation::{Group, Palette, Row, fuzzy, marked, query_field}, primitives::{Icon, IconName}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, @@ -87,7 +88,7 @@ impl RenderOnce for BranchSelector { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let input = query_field( &self.id, - "Switch to a branch, or name a new one", + i18n::text(cx, "palette.branch.placeholder", &[]), window, cx, ); @@ -104,6 +105,7 @@ impl RenderOnce for BranchSelector { score, Row { value: branch.name.clone(), + name: branch.name.clone(), icon: Some(if branch.current { IconName::Check } else { @@ -125,7 +127,7 @@ impl RenderOnce for BranchSelector { }, )) }; - let group = |title: &'static str, remote: bool, cx: &App| { + let group = |title: SharedString, remote: bool, cx: &App| { let mut rows: Vec<(i32, Row)> = self .branches .iter() @@ -136,26 +138,29 @@ impl RenderOnce for BranchSelector { rows.sort_by(|(a, _), (b, _)| b.cmp(a)); } Group { - title: Some(title.into()), + title: Some(title), rows: rows.into_iter().map(|(_, row)| row).collect(), } }; - let mut groups = vec![group("Local", false, cx), group("Remote", true, cx)]; + let mut groups = vec![ + group(i18n::text(cx, "palette.branch.local", &[]), false, cx), + group(i18n::text(cx, "palette.branch.remote", &[]), true, cx), + ]; let named = self .branches .iter() .any(|branch| branch.name.as_ref() == query); if !query.is_empty() && !named { + let create = i18n::text(cx, "palette.branch.create", &[("query", &query)]); groups.insert( 0, Group { title: None, rows: vec![Row { value: format!("{CREATE}{query}").into(), + name: create.clone(), icon: Some(IconName::Plus), - label: div() - .child(format!("Create branch “{query}”")) - .into_any_element(), + label: div().child(create).into_any_element(), detail: None, end: None, }], @@ -184,7 +189,7 @@ impl RenderOnce for BranchSelector { input, groups, start: 0, - empty: "No branch fits".into(), + empty: i18n::text(cx, "palette.branch.empty", &[]), on_pick: Some(on_pick), on_close: self.on_close, } @@ -242,7 +247,7 @@ pub(super) fn listed( pub(super) fn action( id: ElementId, icon: IconName, - words: &'static str, + words: impl Into, run: Option, ) -> AnyElement { IconButton::new(id, icon) @@ -320,13 +325,13 @@ impl RenderOnce for BranchList { action( (self.id.clone(), format!("switch-{}", branch.name)).into(), IconName::ArrowRight, - "Switch to it", + i18n::text(cx, "palette.branch.switch", &[]), bind(&self.on_switch), ), action( (self.id.clone(), format!("delete-{}", branch.name)).into(), IconName::Trash2, - "Delete", + i18n::text(cx, "palette.branch.delete", &[]), bind(&self.on_delete), ), ] diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs new file mode 100644 index 0000000..e3fa070 --- /dev/null +++ b/src/i18n/builtin.rs @@ -0,0 +1,87 @@ +//! Component copy in English and Chinese; `text` reads it when the app's catalogs lack the key. + +/// English, the canonical set; zh-CN holds the same keys, a test keeps parity. +const EN: &[(&str, &str)] = &[ + ("palette.command.placeholder", "Type a command"), + ("palette.command.empty", "No matching commands"), + ("palette.recent", "Recent"), + ("palette.open.placeholder", "Go to file"), + ("palette.open.idle", "Type part of a file name"), + ("palette.open.empty", "No matching files"), + ("palette.switch.placeholder", "Switch to"), + ("palette.switch.empty", "Nothing open by that name"), + ("palette.search.placeholder", "Search everything"), + ("palette.search.idle", "Type to search"), + ("palette.search.empty", "Nothing found for “{query}”"), + ("palette.symbol.placeholder", "Go to a symbol"), + ("palette.symbol.empty", "No matching symbols"), + ("palette.project.placeholder", "Switch to a project"), + ("palette.project.empty", "No matching projects"), + ("palette.project.pin", "Pin"), + ("palette.project.unpin", "Unpin"), + ("palette.project.remove", "Remove from recent"), + ( + "palette.branch.placeholder", + "Switch to a branch, or name a new one", + ), + ("palette.branch.local", "Local"), + ("palette.branch.remote", "Remote"), + ("palette.branch.create", "Create branch “{query}”"), + ("palette.branch.empty", "No branch fits"), + ("palette.branch.switch", "Switch to it"), + ("palette.branch.delete", "Delete"), + ("palette.history.placeholder", "Search past commands"), + ("palette.history.empty", "No past command fits"), +]; + +const ZH_CN: &[(&str, &str)] = &[ + ("palette.command.placeholder", "输入命令"), + ("palette.command.empty", "没有匹配的命令"), + ("palette.recent", "最近"), + ("palette.open.placeholder", "转到文件"), + ("palette.open.idle", "输入文件名的一部分"), + ("palette.open.empty", "没有匹配的文件"), + ("palette.switch.placeholder", "切换到"), + ("palette.switch.empty", "没有以此名字打开的"), + ("palette.search.placeholder", "搜索全部"), + ("palette.search.idle", "输入以搜索"), + ("palette.search.empty", "没有找到“{query}”"), + ("palette.symbol.placeholder", "转到符号"), + ("palette.symbol.empty", "没有匹配的符号"), + ("palette.project.placeholder", "切换项目"), + ("palette.project.empty", "没有匹配的项目"), + ("palette.project.pin", "置顶"), + ("palette.project.unpin", "取消置顶"), + ("palette.project.remove", "从最近中移除"), + ("palette.branch.placeholder", "切换分支,或输入新分支名"), + ("palette.branch.local", "本地"), + ("palette.branch.remote", "远程"), + ("palette.branch.create", "创建分支“{query}”"), + ("palette.branch.empty", "没有匹配的分支"), + ("palette.branch.switch", "切换过去"), + ("palette.branch.delete", "删除"), + ("palette.history.placeholder", "搜索历史命令"), + ("palette.history.empty", "没有匹配的历史命令"), +]; + +/// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key. +pub(crate) fn builtin(locale: &str, key: &str) -> &'static str { + let table = if locale == "zh-CN" { ZH_CN } else { EN }; + table + .iter() + .find_map(|(k, v)| (*k == key).then_some(*v)) + .or_else(|| EN.iter().find_map(|(k, v)| (*k == key).then_some(*v))) + .unwrap_or_else(|| panic!("no builtin message {key:?}")) +} + +#[cfg(test)] +mod tests { + use super::{EN, ZH_CN}; + + #[test] + fn zh_cn_holds_every_english_key() { + let zh: std::collections::HashSet<_> = ZH_CN.iter().map(|(key, _)| key).collect(); + assert_eq!(EN.len(), ZH_CN.len()); + assert!(EN.iter().all(|(key, _)| zh.contains(key))); + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs index 6c51a2d..22237a9 100644 --- a/src/i18n/mod.rs +++ b/src/i18n/mod.rs @@ -1,3 +1,4 @@ +mod builtin; mod locale; mod skip; #[cfg(test)] @@ -73,6 +74,14 @@ impl I18n { cx.refresh_windows(); } + /// The locale's own message for `key`, nothing when its catalog lacks it. + pub(crate) fn find(&self, key: &str) -> Option<&'static str> { + self.catalogs + .iter() + .find(|(tag, _)| *tag == self.locale.tag) + .and_then(|(_, catalog)| catalog.get(key).copied()) + } + /// The shown locale's message for `key`, each `{name}` filled from `args`; fails on a missing key or argument. gpui sets no paragraph direction, so a right-to-left message leads with a right-to-left mark: CoreText would lay out one that starts with Latin letters or a signed number left to right. pub fn text(&self, key: &str, args: &[(&str, &str)]) -> SharedString { let (_, catalog) = self @@ -92,6 +101,24 @@ impl I18n { } } +/// A component's message: the app's catalog first, Ely's builtin copy after. Without an `I18n` global it reads the English builtin. +pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString { + let (message, direction) = if cx.has_global::() { + let i18n = cx.global::(); + let message = i18n + .find(key) + .unwrap_or_else(|| builtin::builtin(i18n.locale().tag, key)); + (message, i18n.locale().direction) + } else { + (builtin::builtin("en-US", key), Direction::Ltr) + }; + let text = fill(message, args).unwrap_or_else(|error| panic!("{key:?}: {error}")); + match direction { + Direction::Ltr => text.into(), + Direction::Rtl => format!("\u{200f}{text}").into(), + } +} + /// `message` with each `{name}` replaced by its argument. fn fill(message: &str, args: &[(&str, &str)]) -> Result { let mut out = String::with_capacity(message.len()); diff --git a/src/navigation/editor_tabs.rs b/src/navigation/editor_tabs.rs index 318a337..7154220 100644 --- a/src/navigation/editor_tabs.rs +++ b/src/navigation/editor_tabs.rs @@ -2,8 +2,8 @@ use std::rc::Rc; use gpui::{ AnyElement, App, ElementId, EntityId, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString, StatefulInteractiveElement, - Styled, Window, canvas, div, prelude::*, + ParentElement, Pixels, RenderOnce, Role, ScrollHandle, SharedString, + StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, }; use super::TabOverflowMenu; @@ -246,6 +246,9 @@ impl RenderOnce for EditorTabs { let (title, icon, edge) = (tab.title.clone(), tab.icon, colors.focus); div() .id(tab.id.clone()) + .role(Role::Tab) + .aria_selected(on) + .aria_label(tab.title.clone()) .group(group) .relative() .flex() diff --git a/src/navigation/palette/kinds.rs b/src/navigation/palette/kinds.rs index eed541a..5a86e82 100644 --- a/src/navigation/palette/kinds.rs +++ b/src/navigation/palette/kinds.rs @@ -5,6 +5,7 @@ use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Window}; use super::{Fit, Group, Palette, Row, fuzzy, marked, query_field}; use crate::{ forms::{Choice, OnValue, Run}, + i18n, primitives::IconName, typography::{Highlight, KbdCombo}, }; @@ -60,6 +61,7 @@ impl Command { fn row(&self, hits: Vec>, cx: &App) -> Row { Row { value: self.value.clone(), + name: self.label.clone(), icon: self.icon, label: marked(self.label.clone(), hits, cx), detail: None, @@ -123,7 +125,8 @@ impl CommandPalette { impl RenderOnce for CommandPalette { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Type a command", window, cx); + let placeholder = i18n::text(cx, "palette.command.placeholder", &[]); + let input = query_field(&self.id, placeholder, window, cx); let query = input.read(cx).text().trim().to_string(); let groups = if query.is_empty() { let find = |value: &SharedString| { @@ -134,7 +137,7 @@ impl RenderOnce for CommandPalette { .unwrap_or_else(|| panic!("recent command {value} is not in the palette")) }; let recent = Group { - title: Some("Recent".into()), + title: Some(i18n::text(cx, "palette.recent", &[])), rows: self .recent .iter() @@ -182,7 +185,7 @@ impl RenderOnce for CommandPalette { input, groups, start: 0, - empty: "No matching commands".into(), + empty: i18n::text(cx, "palette.command.empty", &[]), on_pick: self.on_run, on_close: self.on_close, } @@ -246,6 +249,7 @@ fn file_row(path: &SharedString, hits: &[Range], cx: &App) -> Row { let folder = &path[..name_at.saturating_sub(1)]; Row { value: path.clone(), + name: path[name_at..].to_string().into(), icon: Some(IconName::File), label: marked( path[name_at..].to_string().into(), @@ -260,7 +264,8 @@ fn file_row(path: &SharedString, hits: &[Range], cx: &App) -> Row { impl RenderOnce for QuickOpen { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Go to file", window, cx); + let placeholder = i18n::text(cx, "palette.open.placeholder", &[]); + let input = query_field(&self.id, placeholder, window, cx); let query = input.read(cx).text().trim().to_string(); let (group, empty) = if query.is_empty() { let rows = self @@ -270,10 +275,10 @@ impl RenderOnce for QuickOpen { .collect(); ( Group { - title: Some("Recent".into()), + title: Some(i18n::text(cx, "palette.recent", &[])), rows, }, - "Type part of a file name", + i18n::text(cx, "palette.open.idle", &[]), ) } else { let rows = ranked(self.paths.iter(), &query, |path| &path[..]) @@ -281,14 +286,17 @@ impl RenderOnce for QuickOpen { .take(SHOWN) .map(|(path, fit)| file_row(path, &fit.hits, cx)) .collect(); - (Group { title: None, rows }, "No matching files") + ( + Group { title: None, rows }, + i18n::text(cx, "palette.open.empty", &[]), + ) }; Palette { id: self.id, input, groups: vec![group], start: 0, - empty: empty.into(), + empty, on_pick: self.on_open, on_close: self.on_close, } @@ -336,10 +344,12 @@ impl QuickSwitcher { impl RenderOnce for QuickSwitcher { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Switch to", window, cx); + let placeholder = i18n::text(cx, "palette.switch.placeholder", &[]); + let input = query_field(&self.id, placeholder, window, cx); let query = input.read(cx).text().trim().to_string(); let row = |item: &Choice, hits: Vec>| Row { value: item.value.clone(), + name: item.label.clone(), icon: item.icon, label: marked(item.label.clone(), hits, cx), detail: None, @@ -367,7 +377,7 @@ impl RenderOnce for QuickSwitcher { input, groups: vec![Group { title: None, rows }], start, - empty: "Nothing open by that name".into(), + empty: i18n::text(cx, "palette.switch.empty", &[]), on_pick: self.on_switch, on_close: self.on_close, } @@ -424,7 +434,8 @@ impl SearchPalette { } pub(crate) fn palette(self, window: &mut Window, cx: &mut App) -> Palette { - let input = query_field(&self.id, "Search everything", window, cx); + let placeholder = i18n::text(cx, "palette.search.placeholder", &[]); + let input = query_field(&self.id, placeholder, window, cx); let query = input.read(cx).text().trim().to_string(); let groups = (self.search)(&query) .into_iter() @@ -441,7 +452,8 @@ impl SearchPalette { ); }) .map(|result| Row { - value: result.value, + value: result.value.clone(), + name: result.label.clone(), icon: result.icon, label: Highlight::matching(result.label, &query).into_any_element(), detail: result @@ -458,9 +470,9 @@ impl SearchPalette { groups, start: 0, empty: if query.is_empty() { - "Type to search".into() + i18n::text(cx, "palette.search.idle", &[]) } else { - format!("Nothing found for “{query}”").into() + i18n::text(cx, "palette.search.empty", &[("query", &query)]) }, on_pick: self.on_open, on_close: self.on_close, diff --git a/src/navigation/palette/mod.rs b/src/navigation/palette/mod.rs index a5eb7ea..b580593 100644 --- a/src/navigation/palette/mod.rs +++ b/src/navigation/palette/mod.rs @@ -7,8 +7,8 @@ use std::{ops::Range, rc::Rc}; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, Entity, FontWeight, HighlightStyle, - InteractiveElement, IntoElement, MouseButton, ParentElement, Point, ScrollHandle, SharedString, - StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, + InteractiveElement, IntoElement, MouseButton, ParentElement, Point, Role, ScrollHandle, + SharedString, StatefulInteractiveElement, Styled, StyledText, Window, div, prelude::*, }; use crate::{ @@ -102,6 +102,8 @@ pub(crate) fn marked(text: SharedString, hits: Vec>, cx: &App) -> A /// One result: the value a pick hands back, then what the row shows. pub(crate) struct Row { pub value: SharedString, + /// The row's plain name, for assistive technology. + pub name: SharedString, pub icon: Option, pub label: AnyElement, pub detail: Option, @@ -125,7 +127,7 @@ struct Cursor { /// The query field kept for palette `id`. pub(crate) fn query_field( id: &ElementId, - placeholder: &'static str, + placeholder: SharedString, window: &mut Window, cx: &mut App, ) -> Entity { @@ -250,6 +252,9 @@ impl Palette { children.push( div() .id(("row", ix)) + .role(Role::ListBoxOption) + .aria_selected(ix == at) + .aria_label(row.name.clone()) .flex() .flex_none() .items_center() @@ -328,11 +333,9 @@ impl Palette { cx.stop_propagation(); down(1, cx); }) - .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) - .on_key_up(move |event, window, cx| { - let stroke = &event.keystroke; - if stroke.key == "enter" && !stroke.modifiers.modified() && count > 0 { - cx.stop_propagation(); + .capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + if count > 0 { enter(chosen.read(cx).at.min(count - 1), window, cx); } }) @@ -363,6 +366,7 @@ impl Palette { .child( div() .id((self.id.clone(), "list")) + .role(Role::ListBox) .flex() .flex_col() .flex_1() diff --git a/src/navigation/tabs.rs b/src/navigation/tabs.rs index 35c9580..9d8489d 100644 --- a/src/navigation/tabs.rs +++ b/src/navigation/tabs.rs @@ -2,8 +2,8 @@ use std::rc::Rc; use gpui::{ Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, InteractiveElement, - IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, - Styled, Window, div, prelude::*, + IntoElement, MouseButton, ParentElement, RenderOnce, Role, SharedString, + StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use crate::{ @@ -150,6 +150,9 @@ impl RenderOnce for Tabs { let pick = pick.clone(); div() .id(("tab", ix)) + .role(Role::Tab) + .aria_selected(on) + .aria_label(tab.label.clone()) .relative() .flex() .items_center() @@ -195,6 +198,7 @@ impl RenderOnce for Tabs { let (keys, key_pick) = (tabs.clone(), pick.clone()); let strip = div() .id((self.id.clone(), "strip")) + .role(Role::TabList) .track_focus(&focus) .relative() .flex() diff --git a/src/overlays/dialog.rs b/src/overlays/dialog.rs index b533227..c0b99c2 100644 --- a/src/overlays/dialog.rs +++ b/src/overlays/dialog.rs @@ -69,7 +69,7 @@ impl Dialog { self } - /// Runs on an unmodified Enter release while the `focus_first` field holds focus; `build` gets the dialog's close. + /// Runs on an unmodified Enter while the `focus_first` field holds focus; `build` gets the dialog's close. The field's key binding fires on key-down, so an Enter an IME takes never reaches it. pub(crate) fn on_enter(mut self, build: impl FnOnce(Close) -> Run + 'static) -> Self { self.enter = Some(Box::new(build)); self @@ -254,18 +254,12 @@ impl RenderOnce for Dialog { } }) .when_some(enter, |layer, (field, enter)| { - layer - .capture_action(|_: &Enter, _, cx| cx.stop_propagation()) - .on_key_up(move |event, window, cx| { - let stroke = &event.keystroke; - if stroke.key == "enter" - && !stroke.modifiers.modified() - && field.is_focused(window) - { - cx.stop_propagation(); - enter(window, cx); - } - }) + layer.capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + if field.is_focused(window) { + enter(window, cx); + } + }) }) .child( FocusScope::new(&focus) diff --git a/src/shell/bars.rs b/src/shell/bars.rs index 03e0f19..f734e26 100644 --- a/src/shell/bars.rs +++ b/src/shell/bars.rs @@ -1,7 +1,7 @@ use gpui::{ AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div, - prelude::*, + RenderOnce, Role, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, + div, prelude::*, }; use smallvec::SmallVec; @@ -220,6 +220,11 @@ impl RenderOnce for StatusBarItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let hover = theme.colors.hover; + let name = self + .label + .clone() + .or_else(|| self.tooltip.clone()) + .or_else(|| self.icon.map(|icon| icon.name().into())); div() .id(self.id) .flex() @@ -239,6 +244,11 @@ impl RenderOnce for StatusBarItem { .when_some(self.label, |item, label| item.child(label)) .when_some(self.tooltip, |item, text| item.tooltip(Tooltip::text(text))) .when_some(self.on_click, |item, handler| { + let item = item.role(Role::Button); + let item = match name { + Some(name) => item.aria_label(name), + None => item, + }; item.cursor_pointer() .hover(|style| style.bg(hover)) .on_click(handler) diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 75a07ec..c3ae3be 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -5,6 +5,7 @@ use gpui::{ }; use crate::{ + i18n, navigation::{Group, Palette, Row, fuzzy, marked, query_field}, primitives::IconName, theme::ActiveTheme, @@ -57,7 +58,12 @@ impl CommandHistory { impl RenderOnce for CommandHistory { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let input = query_field(&self.id, "Search past commands", window, cx); + let input = query_field( + &self.id, + i18n::text(cx, "palette.history.placeholder", &[]), + window, + cx, + ); let query = input.read(cx).text().trim().to_string(); let colors = cx.theme().colors.clone(); let mut found: Vec<(i32, Row)> = self @@ -79,6 +85,7 @@ impl RenderOnce for CommandHistory { score, Row { value: past.command.clone(), + name: past.command.clone(), icon: Some(icon), label: marked(past.command.clone(), hits, cx), detail: Some( @@ -108,7 +115,7 @@ impl RenderOnce for CommandHistory { rows: found.into_iter().map(|(_, row)| row).collect(), }], start: 0, - empty: "No past command fits".into(), + empty: i18n::text(cx, "palette.history.empty", &[]), on_pick: self.on_pick, on_close: self.on_close, }