diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 0bf19a4878ba80eda3eca02f355b2419f022621e..428612fa37dee1d1901d55cc90b0657dd1d843d7 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -172,6 +172,10 @@ naga.workspace = true +[[example]] +name = "a11y" +path = "examples/a11y.rs" + [[example]] name = "hello_world" path = "examples/hello_world.rs" diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs new file mode 100644 index 0000000000000000000000000000000000000000..aded85d3696752b49413f65d87b0a7abf19c445f --- /dev/null +++ b/crates/gpui/examples/a11y.rs @@ -0,0 +1,984 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, FocusHandle, KeyBinding, Orientation, Role, SharedString, Toggled, + Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!(a11y_example, [Tab, TabPrev, ToggleDarkMode]); + +// --- Data tables demo --- + +struct FileEntry { + name: &'static str, + kind: &'static str, + size: &'static str, +} + +const FILES: &[FileEntry] = &[ + FileEntry { + name: "README.md", + kind: "Markdown", + size: "4 KB", + }, + FileEntry { + name: "main.rs", + kind: "Rust", + size: "12 KB", + }, + FileEntry { + name: "Cargo.toml", + kind: "TOML", + size: "1 KB", + }, + FileEntry { + name: "lib.rs", + kind: "Rust", + size: "8 KB", + }, +]; + +// --- Tree data --- + +struct TreeNode { + label: &'static str, + depth: usize, + children: &'static [TreeNode], +} + +const FILE_TREE: &[TreeNode] = &[ + TreeNode { + label: "src", + depth: 1, + children: &[ + TreeNode { + label: "main.rs", + depth: 2, + children: &[], + }, + TreeNode { + label: "lib.rs", + depth: 2, + children: &[], + }, + ], + }, + TreeNode { + label: "tests", + depth: 1, + children: &[TreeNode { + label: "integration.rs", + depth: 2, + children: &[], + }], + }, + TreeNode { + label: "README.md", + depth: 1, + children: &[], + }, +]; + +// --- App state --- + +struct A11yExample { + focus_handle: FocusHandle, + dark_mode: bool, + notifications_enabled: bool, + auto_save: bool, + selected_tab: usize, + progress: f64, + expanded_tree_nodes: Vec, + selected_tree_node: Option, + selected_file_row: Option, + status_message: SharedString, +} + +impl A11yExample { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + window.focus(&focus_handle, cx); + + Self { + focus_handle, + dark_mode: false, + notifications_enabled: true, + auto_save: false, + selected_tab: 0, + progress: 0.65, + expanded_tree_nodes: vec![true, true, false], + selected_tree_node: None, + selected_file_row: None, + status_message: "Welcome! This demo showcases GPUI accessibility features.".into(), + } + } + + fn on_tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { + window.focus_next(cx); + } + + fn on_tab_prev(&mut self, _: &TabPrev, window: &mut Window, cx: &mut Context) { + window.focus_prev(cx); + } + + fn bg(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x1e1e2e).into() + } else { + rgb(0xf5f5f5).into() + } + } + + fn fg(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0xcdd6f4).into() + } else { + rgb(0x1e1e2e).into() + } + } + + fn subtle(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x45475a).into() + } else { + rgb(0xd0d0d0).into() + } + } + + fn surface(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x313244).into() + } else { + rgb(0xffffff).into() + } + } + + fn accent(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0x89b4fa).into() + } else { + rgb(0x1a73e8).into() + } + } + + fn accent_fg(&self) -> gpui::Hsla { + rgb(0xffffff).into() + } + + fn success(&self) -> gpui::Hsla { + if self.dark_mode { + rgb(0xa6e3a1).into() + } else { + rgb(0x2e7d32).into() + } + } + + // --- Section builders --- + + fn render_heading(&self, text: &str) -> impl IntoElement { + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .mb_1() + .child(text.to_string()) + } + + fn render_tab_bar(&self, cx: &mut Context) -> impl IntoElement { + let tabs = ["Overview", "Settings", "Data"]; + let selected = self.selected_tab; + + div() + .id("tab-bar") + .role(Role::TabList) + .aria_label("Main sections") + .aria_orientation(Orientation::Horizontal) + .flex() + .flex_row() + .gap_1() + .mb_4() + .children(tabs.iter().enumerate().map(|(index, label)| { + let is_selected = index == selected; + div() + .id(("tab", index)) + .role(Role::Tab) + .aria_label(SharedString::from(*label)) + .aria_selected(is_selected) + .aria_position_in_set(index + 1) + .aria_size_of_set(tabs.len()) + .px_4() + .py_1() + .cursor_pointer() + .rounded_t_md() + .font_weight(if is_selected { + gpui::FontWeight::BOLD + } else { + gpui::FontWeight::NORMAL + }) + .text_color(if is_selected { + self.accent() + } else { + self.fg() + }) + .border_b_2() + .border_color(if is_selected { + self.accent() + } else { + gpui::transparent_black() + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tab = index; + this.status_message = + SharedString::from(format!("Switched to {} tab.", tabs[index])); + cx.notify(); + })) + .child(label.to_string()) + })) + } + + fn render_overview_panel(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("overview-panel") + .role(Role::TabPanel) + .aria_label("Overview") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("Buttons")) + .child(self.render_buttons(cx)) + .child(self.render_heading("Progress")) + .child(self.render_progress_bar(cx)) + .child(self.render_heading("File Tree")) + .child(self.render_tree(cx)) + } + + fn render_buttons(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("button-group") + .role(Role::Group) + .aria_label("Actions") + .flex() + .flex_row() + .gap_2() + .child( + div() + .id("btn-primary") + .role(Role::Button) + .aria_label("Run build") + .px_4() + .py_1() + .rounded_md() + .bg(self.accent()) + .text_color(self.accent_fg()) + .cursor_pointer() + .hover(|s| s.opacity(0.85)) + .on_click(cx.listener(|this, _, _, cx| { + this.status_message = "Build started!".into(); + this.progress = 0.0; + cx.notify(); + })) + .child("Run Build"), + ) + .child( + div() + .id("btn-increment") + .role(Role::Button) + .aria_label("Increment progress by 10%") + .px_4() + .py_1() + .rounded_md() + .border_1() + .border_color(self.accent()) + .text_color(self.accent()) + .cursor_pointer() + .hover(|s| s.bg(self.accent().opacity(0.1))) + .on_click(cx.listener(|this, _, _, cx| { + this.progress = (this.progress + 0.1).min(1.0); + let pct = (this.progress * 100.0) as u32; + this.status_message = + SharedString::from(format!("Progress: {}%", pct)); + cx.notify(); + })) + .child("+10%"), + ) + .child( + div() + .id("btn-reset") + .role(Role::Button) + .aria_label("Reset progress") + .px_4() + .py_1() + .rounded_md() + .border_1() + .border_color(self.subtle()) + .text_color(self.fg()) + .cursor_pointer() + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(|this, _, _, cx| { + this.progress = 0.0; + this.status_message = "Progress reset.".into(); + cx.notify(); + })) + .child("Reset"), + ) + } + + fn render_progress_bar(&self, cx: &mut Context) -> impl IntoElement { + let pct = (self.progress * 100.0) as u32; + let bar_color = if self.progress >= 1.0 { + self.success() + } else { + self.accent() + }; + + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .id("progress-bar") + .role(Role::ProgressIndicator) + .aria_label("Build progress") + .aria_numeric_value(self.progress * 100.0) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(100.0) + .h(px(12.0)) + .w_full() + .rounded_full() + .bg(self.subtle().opacity(0.5)) + .overflow_hidden() + .child( + div() + .h_full() + .w(gpui::relative(self.progress as f32)) + .rounded_full() + .bg(bar_color), + ), + ) + .child( + div() + .text_xs() + .text_color(self.fg().opacity(0.7)) + .child(format!("{}% complete", pct)), + ) + .child( + div() + .flex() + .flex_row() + .gap_2() + .mt_1() + .children((0..5).map(|index| { + let step_progress = (index as f64 + 1.0) * 0.2; + let is_done = self.progress >= step_progress; + div() + .id(("progress-step", index)) + .role(Role::ListItem) + .aria_label(SharedString::from(format!("Step {}", index + 1))) + .aria_position_in_set(index + 1) + .aria_size_of_set(5) + .size_6() + .rounded_full() + .flex() + .justify_center() + .items_center() + .text_xs() + .bg(if is_done { + bar_color + } else { + self.subtle().opacity(0.5) + }) + .text_color(if is_done { + self.accent_fg() + } else { + self.fg().opacity(0.5) + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, _, cx| { + this.progress = step_progress; + let pct = (step_progress * 100.0) as u32; + this.status_message = + SharedString::from(format!("Progress set to {}%.", pct)); + cx.notify(); + })) + .child(format!("{}", index + 1)) + })), + ) + } + + fn render_tree(&self, cx: &mut Context) -> impl IntoElement { + let mut flat_index = 0usize; + + div() + .id("file-tree") + .role(Role::Tree) + .aria_label("Project files") + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .p_2() + .children(FILE_TREE.iter().enumerate().flat_map( + |(root_index, node)| { + let mut items = Vec::new(); + let current_index = flat_index; + let is_expanded = self + .expanded_tree_nodes + .get(root_index) + .copied() + .unwrap_or(false); + let is_selected = self.selected_tree_node == Some(current_index); + let has_children = !node.children.is_empty(); + + items.push( + div() + .id(("tree-node", current_index)) + .role(Role::TreeItem) + .aria_label(SharedString::from(node.label)) + .aria_level(node.depth) + .aria_selected(is_selected) + .aria_position_in_set(root_index + 1) + .aria_size_of_set(FILE_TREE.len()) + .when(has_children, |this| this.aria_expanded(is_expanded)) + .pl(px(node.depth as f32 * 16.0)) + .py(px(2.0)) + .px_2() + .rounded_sm() + .cursor_pointer() + .text_color(self.fg()) + .when(is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tree_node = Some(current_index); + if has_children { + if let Some(val) = + this.expanded_tree_nodes.get_mut(root_index) + { + *val = !*val; + } + } + this.status_message = SharedString::from(format!( + "Selected: {}", + node.label + )); + cx.notify(); + })) + .child(format!( + "{} {}", + if has_children { + if is_expanded { + "▾" + } else { + "▸" + } + } else { + " " + }, + node.label + )), + ); + flat_index += 1; + + if has_children && is_expanded { + for (child_index, child) in node.children.iter().enumerate() { + let child_flat_index = flat_index; + let child_is_selected = + self.selected_tree_node == Some(child_flat_index); + + items.push( + div() + .id(("tree-node", child_flat_index)) + .role(Role::TreeItem) + .aria_label(SharedString::from(child.label)) + .aria_level(child.depth) + .aria_selected(child_is_selected) + .aria_position_in_set(child_index + 1) + .aria_size_of_set(node.children.len()) + .pl(px(child.depth as f32 * 16.0)) + .py(px(2.0)) + .px_2() + .rounded_sm() + .cursor_pointer() + .text_color(self.fg()) + .when(child_is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_tree_node = Some(child_flat_index); + this.status_message = SharedString::from(format!( + "Selected: {}", + child.label + )); + cx.notify(); + })) + .child(format!(" {}", child.label)), + ); + flat_index += 1; + } + } + + items + }, + )) + } + + fn render_settings_panel(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("settings-panel") + .role(Role::TabPanel) + .aria_label("Settings") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("Preferences")) + .child( + div() + .id("settings-group") + .role(Role::Group) + .aria_label("Application preferences") + .flex() + .flex_col() + .gap_3() + .child(self.render_toggle( + "dark-mode", + "Dark mode", + self.dark_mode, + Role::Switch, + cx, + |this, _, _, cx| { + this.dark_mode = !this.dark_mode; + this.status_message = if this.dark_mode { + "Dark mode enabled.".into() + } else { + "Dark mode disabled.".into() + }; + cx.notify(); + }, + )) + .child(self.render_toggle( + "notifications", + "Enable notifications", + self.notifications_enabled, + Role::Switch, + cx, + |this, _, _, cx| { + this.notifications_enabled = !this.notifications_enabled; + this.status_message = if this.notifications_enabled { + "Notifications enabled.".into() + } else { + "Notifications disabled.".into() + }; + cx.notify(); + }, + )) + .child(self.render_toggle( + "auto-save", + "Auto-save files", + self.auto_save, + Role::CheckBox, + cx, + |this, _, _, cx| { + this.auto_save = !this.auto_save; + this.status_message = if this.auto_save { + "Auto-save enabled.".into() + } else { + "Auto-save disabled.".into() + }; + cx.notify(); + }, + )), + ) + } + + fn render_toggle( + &self, + id: &'static str, + label: &'static str, + value: bool, + role: Role, + cx: &mut Context, + on_click: impl Fn(&mut Self, &gpui::ClickEvent, &mut Window, &mut Context) + 'static, + ) -> impl IntoElement { + let toggled = if value { + Toggled::True + } else { + Toggled::False + }; + + let is_switch = role == Role::Switch; + + div() + .flex() + .flex_row() + .items_center() + .gap_3() + .child( + div() + .id(id) + .role(role) + .aria_label(SharedString::from(label)) + .aria_toggled(toggled) + .cursor_pointer() + .on_click(cx.listener(on_click)) + .when(is_switch, |this| { + this.w(px(40.0)) + .h(px(22.0)) + .rounded_full() + .bg(if value { + self.accent() + } else { + self.subtle() + }) + .p(px(2.0)) + .child( + div() + .size(px(18.0)) + .rounded_full() + .bg(gpui::white()) + .when(value, |this| this.ml(px(18.0))), + ) + }) + .when(!is_switch, |this| { + this.size(px(18.0)) + .rounded_sm() + .border_2() + .border_color(if value { + self.accent() + } else { + self.subtle() + }) + .bg(if value { + self.accent() + } else { + gpui::transparent_black() + }) + .flex() + .justify_center() + .items_center() + .text_xs() + .text_color(self.accent_fg()) + .when(value, |this| this.child("✓")) + }), + ) + .child( + div() + .text_color(self.fg()) + .child(label.to_string()), + ) + } + + fn render_data_panel(&self, cx: &mut Context) -> impl IntoElement { + let column_count = 3; + let row_count = FILES.len(); + + div() + .id("data-panel") + .role(Role::TabPanel) + .aria_label("Data") + .flex() + .flex_col() + .gap_4() + .child(self.render_heading("File Table")) + .child( + div() + .id("file-table") + .role(Role::Table) + .aria_label("Project files") + .aria_row_count(row_count + 1) + .aria_column_count(column_count) + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .overflow_hidden() + .child( + div() + .id("table-header") + .role(Role::Row) + .aria_row_index(1) + .flex() + .flex_row() + .bg(self.subtle().opacity(0.3)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .child(self.render_cell("header-name", "Name", 1, column_count, true)) + .child(self.render_cell("header-type", "Type", 2, column_count, true)) + .child(self.render_cell("header-size", "Size", 3, column_count, true)), + ) + .children(FILES.iter().enumerate().map(|(row_index, file)| { + let is_selected = self.selected_file_row == Some(row_index); + + div() + .id(("table-row", row_index)) + .role(Role::Row) + .aria_row_index(row_index + 2) + .aria_selected(is_selected) + .flex() + .flex_row() + .cursor_pointer() + .text_color(self.fg()) + .when(is_selected, |this| { + this.bg(self.accent().opacity(0.15)) + }) + .when(row_index % 2 == 1, |this| { + this.bg(self.subtle().opacity(0.1)) + }) + .hover(|s| s.bg(self.accent().opacity(0.1))) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_file_row = Some(row_index); + this.status_message = SharedString::from(format!( + "Selected file: {}", + FILES[row_index].name + )); + cx.notify(); + })) + .child(self.render_cell( + ("cell-name", row_index), + file.name, + 1, + column_count, + false, + )) + .child(self.render_cell( + ("cell-type", row_index), + file.kind, + 2, + column_count, + false, + )) + .child(self.render_cell( + ("cell-size", row_index), + file.size, + 3, + column_count, + false, + )) + })), + ) + .child(self.render_heading("Item List")) + .child(self.render_list()) + } + + fn render_cell( + &self, + id: impl Into, + text: &str, + column: usize, + total_columns: usize, + is_header: bool, + ) -> impl IntoElement { + div() + .id(id.into()) + .role(if is_header { + Role::ColumnHeader + } else { + Role::Cell + }) + .aria_label(SharedString::from(text.to_string())) + .aria_column_index(column) + .aria_column_count(total_columns) + .flex_1() + .px_3() + .py_2() + .child(text.to_string()) + } + + fn render_list(&self) -> impl IntoElement { + let items = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]; + + div() + .id("demo-list") + .role(Role::List) + .aria_label("Greek letters") + .flex() + .flex_col() + .border_1() + .border_color(self.subtle()) + .rounded_md() + .children(items.iter().enumerate().map(|(index, label)| { + div() + .id(("list-item", index)) + .role(Role::ListItem) + .aria_label(SharedString::from(*label)) + .aria_position_in_set(index + 1) + .aria_size_of_set(items.len()) + .px_3() + .py_1() + .text_color(self.fg()) + .when(index % 2 == 1, |this| { + this.bg(self.subtle().opacity(0.1)) + }) + .child(format!("{}. {}", index + 1, label)) + })) + } + + fn render_status_bar(&self) -> impl IntoElement { + div() + .id("status-bar") + .role(Role::Status) + .aria_label(self.status_message.clone()) + .w_full() + .px_4() + .py_2() + .bg(self.subtle().opacity(0.3)) + .rounded_md() + .text_sm() + .text_color(self.fg().opacity(0.8)) + .child(self.status_message.clone()) + } +} + +impl Render for A11yExample { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let tab_content: gpui::AnyElement = match self.selected_tab { + 0 => self.render_overview_panel(cx).into_any_element(), + 1 => self.render_settings_panel(cx).into_any_element(), + 2 => self.render_data_panel(cx).into_any_element(), + _ => div().child("Unknown tab").into_any_element(), + }; + + div() + .id("app-root") + .role(Role::Application) + .aria_label("Accessibility Demo") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::on_tab)) + .on_action(cx.listener(Self::on_tab_prev)) + .size_full() + .flex() + .flex_col() + .bg(self.bg()) + .font_family("sans-serif") + .child( + div() + .id("header") + .role(Role::Banner) + .aria_label("Application header") + .w_full() + .px_6() + .py_3() + .bg(self.surface()) + .border_b_1() + .border_color(self.subtle()) + .flex() + .flex_row() + .items_center() + .justify_between() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + div() + .text_xl() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.accent()) + .child("♿"), + ) + .child( + div() + .text_lg() + .font_weight(gpui::FontWeight::BOLD) + .text_color(self.fg()) + .child("GPUI Accessibility Demo"), + ), + ) + .child( + div() + .id("theme-toggle") + .role(Role::Button) + .aria_label(if self.dark_mode { + "Switch to light mode" + } else { + "Switch to dark mode" + }) + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .border_1() + .border_color(self.subtle()) + .text_color(self.fg()) + .hover(|s| s.bg(self.subtle().opacity(0.3))) + .on_click(cx.listener(|this, _, _, cx| { + this.dark_mode = !this.dark_mode; + this.status_message = if this.dark_mode { + "Dark mode enabled.".into() + } else { + "Dark mode disabled.".into() + }; + cx.notify(); + })) + .child(if self.dark_mode { "☀ Light" } else { "🌙 Dark" }), + ), + ) + .child( + div() + .id("main-content") + .role(Role::Main) + .aria_label("Main content") + .flex_1() + .overflow_y_scroll() + .px_6() + .py_4() + .flex() + .flex_col() + .gap_2() + .child(self.render_tab_bar(cx)) + .child(tab_content), + ) + .child( + div() + .id("footer") + .role(Role::ContentInfo) + .aria_label("Status") + .px_6() + .py_2() + .border_t_1() + .border_color(self.subtle()) + .child(self.render_status_bar()), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + cx.bind_keys([ + KeyBinding::new("tab", Tab, None), + KeyBinding::new("shift-tab", TabPrev, None), + ]); + + let bounds = Bounds::centered(None, size(px(800.), px(700.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| A11yExample::new(window, cx)), + ) + .unwrap(); + + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +}