From 1f76dce345153d7da95262773c317446c1f6fc32 Mon Sep 17 00:00:00 2001 From: Ivan Molodetskikh Date: Thu, 21 Aug 2025 15:02:25 +0300 Subject: Implement screen reader announcements via AccessKit --- Cargo.lock | 158 ++++++++++++++++++- Cargo.toml | 6 +- src/a11y.rs | 292 ++++++++++++++++++++++++++++++++++++ src/input/mod.rs | 3 + src/lib.rs | 2 + src/main.rs | 5 + src/niri.rs | 17 +++ src/ui/config_error_notification.rs | 15 +- src/ui/exit_confirm_dialog.rs | 31 +++- src/ui/hotkey_overlay.rs | 61 ++++++-- 10 files changed, 566 insertions(+), 24 deletions(-) create mode 100644 src/a11y.rs diff --git a/Cargo.lock b/Cargo.lock index 89545d5e..bc428697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,54 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "accesskit" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c0690ad6e6f9597b8439bd3c95e8c6df5cd043afd950c6d68f3b37df641e27c" + +[[package]] +name = "accesskit_atspi_common" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb511e093896d3cae0efba40322087dff59ea322308a3e6edf70f28d22f2607" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "serde", + "thiserror 1.0.69", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec27574c1baeb7747c802a194566b46b602461e81dc4957949580ea8da695038" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_unix" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2abbfb16144cca5bb2ea6acad5865b7c1e70d4fa171ceba1a52ea8e78a7515f4" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + [[package]] name = "adler2" version = "2.0.1" @@ -323,6 +371,56 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "628d228f918ac3b82fe590352cc719d30664a0c13ca3a60266fe02c7132d480a" +[[package]] +name = "atspi" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +dependencies = [ + "atspi-common", + "atspi-connection", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-connection" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4193d51303d8332304056ae0004714256b46b6635a5c556109b319c0d3784938" +dependencies = [ + "atspi-common", + "atspi-proxies", + "futures-lite", + "zbus", +] + +[[package]] +name = "atspi-proxies" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1059,6 +1157,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1584,6 +1688,9 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -2246,6 +2353,8 @@ dependencies = [ name = "niri" version = "25.5.1" dependencies = [ + "accesskit", + "accesskit_unix", "anyhow", "approx 0.5.1", "arrayvec", @@ -3030,6 +3139,16 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -4215,7 +4334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.37.5", "quote", ] @@ -4929,6 +5048,30 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29e96e38ded30eeab90b6ba88cb888d70aef4e7489b6cd212c5e5b5ec38045b6" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.9.0" @@ -4956,6 +5099,19 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus_xml" +version = "5.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +dependencies = [ + "quick-xml 0.36.2", + "serde", + "static_assertions", + "zbus_names", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.26" diff --git a/Cargo.toml b/Cargo.toml index 5b6e9051..869a9a43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,8 @@ readme = "README.md" keywords = ["wayland", "compositor", "tiling", "smithay", "wm"] [dependencies] +accesskit = { version = "0.21.0", optional = true } +accesskit_unix = { version = "0.17.0", optional = true } anyhow.workspace = true arrayvec = "0.7.6" async-channel = "2.5.0" @@ -124,8 +126,8 @@ xshell = "0.2.7" [features] default = ["dbus", "systemd", "xdp-gnome-screencast"] -# Enables D-Bus support (serve various freedesktop and GNOME interfaces, power button handling). -dbus = ["dep:zbus", "dep:async-io", "dep:url"] +# Enables D-Bus support (serve various freedesktop and GNOME interfaces, accessibility tree, power button handling). +dbus = ["dep:zbus", "dep:async-io", "dep:url", "dep:accesskit", "dep:accesskit_unix"] # Enables systemd integration (global environment, apps in transient scopes). systemd = ["dbus"] # Enables screencasting support through xdg-desktop-portal-gnome. diff --git a/src/a11y.rs b/src/a11y.rs new file mode 100644 index 00000000..04b92dbf --- /dev/null +++ b/src/a11y.rs @@ -0,0 +1,292 @@ +use std::sync::mpsc; +use std::thread; + +use accesskit::{ + ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, Live, Node, NodeId, Role, + Tree, TreeUpdate, +}; +use accesskit_unix::Adapter; +use calloop::LoopHandle; + +use crate::layout::workspace::WorkspaceId; +use crate::niri::{KeyboardFocus, Niri, State}; + +const ID_ROOT: NodeId = NodeId(0); +const ID_ANNOUNCEMENT: NodeId = NodeId(1); +const ID_SCREENSHOT_UI: NodeId = NodeId(2); +const ID_EXIT_CONFIRM_DIALOG: NodeId = NodeId(3); +const ID_OVERVIEW: NodeId = NodeId(4); + +pub struct A11y { + event_loop: LoopHandle<'static, State>, + focus: NodeId, + workspace_id: Option, + last_announcement: String, + to_accesskit: Option>, +} + +enum Msg { + InitialTree, + Deactivate, + Action(ActionRequest), +} + +impl A11y { + pub fn new(event_loop: LoopHandle<'static, State>) -> Self { + Self { + event_loop, + focus: ID_ROOT, + workspace_id: None, + last_announcement: String::new(), + to_accesskit: None, + } + } + + pub fn start(&mut self) { + let (tx, rx) = calloop::channel::channel(); + let (to_accesskit, from_main) = mpsc::sync_channel::(8); + + // The adapter has a tendency to deadlock, so put it on a thread for now... + let handler = Handler { tx }; + let res = thread::Builder::new() + .name("AccessKit Adapter".to_owned()) + .spawn(move || { + let mut adapter = Adapter::new(handler.clone(), handler.clone(), handler); + while let Ok(tree) = from_main.recv() { + let is_focused = tree.focus != ID_ROOT; + adapter.update_if_active(move || tree); + adapter.update_window_focus_state(is_focused); + } + }); + + match res { + Ok(_handle) => {} + Err(err) => { + warn!("error spawning the AccessKit adapter thread: {err:?}"); + return; + } + } + + self.event_loop + .insert_source(rx, |e, _, state| match e { + calloop::channel::Event::Msg(msg) => state.niri.on_a11y_msg(msg), + calloop::channel::Event::Closed => (), + }) + .unwrap(); + + self.to_accesskit = Some(to_accesskit); + } + + fn update_tree(&mut self, tree: TreeUpdate) { + trace!("updating tree: {tree:?}"); + self.focus = tree.focus; + + let Some(tx) = &mut self.to_accesskit else { + return; + }; + match tx.try_send(tree) { + Ok(()) => {} + Err(mpsc::TrySendError::Full(_)) => { + warn!("AccessKit channel is full, it probably deadlocked; disconnecting"); + self.to_accesskit = None; + } + Err(mpsc::TrySendError::Disconnected(_)) => { + warn!("AccessKit channel disconnected"); + self.to_accesskit = None; + } + } + } +} + +impl Niri { + pub fn refresh_a11y(&mut self) { + if self.a11y.to_accesskit.is_none() { + return; + } + + let _span = tracy_client::span!("refresh_a11y"); + + let mut announcement = None; + let ws_id = self.layout.active_workspace().map(|ws| ws.id()); + if let Some(ws_id) = ws_id { + if self.a11y.workspace_id != Some(ws_id) { + let (_, idx, ws) = self + .layout + .workspaces() + .find(|(_, _, ws)| ws.id() == ws_id) + .unwrap(); + + let mut buf = format!("Workspace {}", idx + 1); + if let Some(name) = ws.name() { + buf.push(' '); + buf.push_str(name); + } + + announcement = Some(buf); + } + } + self.a11y.workspace_id = ws_id; + + let focus = self.a11y_focus(); + let update_focus = self.a11y.focus != focus; + + if !(announcement.is_some() || update_focus) { + return; + } + + let mut nodes = Vec::new(); + + if let Some(mut announcement) = announcement { + // Work around having to change node value for it to get announced. + if announcement == self.a11y.last_announcement { + announcement.push(' '); + } + self.a11y.last_announcement = announcement.clone(); + + let mut node = Node::new(Role::Label); + node.set_value(announcement); + node.set_live(Live::Polite); + nodes.push((ID_ANNOUNCEMENT, node)); + } + + let update = TreeUpdate { + nodes, + tree: None, + focus, + }; + + self.a11y.update_tree(update); + } + + pub fn a11y_announce(&mut self, mut announcement: String) { + if self.a11y.to_accesskit.is_none() { + return; + } + + let _span = tracy_client::span!("a11y_announce"); + + // Work around having to change node value for it to get announced. + if announcement == self.a11y.last_announcement { + announcement.push(' '); + } + self.a11y.last_announcement = announcement.clone(); + + let mut node = Node::new(Role::Label); + node.set_value(announcement); + node.set_live(Live::Polite); + + let update = TreeUpdate { + nodes: vec![(ID_ANNOUNCEMENT, node)], + tree: None, + focus: self.a11y.focus, + }; + + self.a11y.update_tree(update); + } + + pub fn a11y_announce_config_error(&mut self) { + if self.a11y.to_accesskit.is_none() { + return; + } + + self.a11y_announce(crate::ui::config_error_notification::error_text(false)); + } + + pub fn a11y_announce_hotkey_overlay(&mut self) { + if self.a11y.to_accesskit.is_none() { + return; + } + + self.a11y_announce(self.hotkey_overlay.a11y_text()); + } + + fn a11y_focus(&self) -> NodeId { + match self.keyboard_focus { + KeyboardFocus::ScreenshotUi => ID_SCREENSHOT_UI, + KeyboardFocus::ExitConfirmDialog => ID_EXIT_CONFIRM_DIALOG, + KeyboardFocus::Overview => ID_OVERVIEW, + _ => ID_ROOT, + } + } + + fn on_a11y_msg(&mut self, msg: Msg) { + match msg { + Msg::InitialTree => { + let tree = self.a11y_build_full_tree(); + trace!("sending initial tree: {tree:?}"); + self.a11y.update_tree(tree); + } + Msg::Deactivate => { + trace!("deactivate"); + } + Msg::Action(request) => { + trace!("request: {request:?}"); + } + } + } + + fn a11y_build_full_tree(&self) -> TreeUpdate { + let mut node = Node::new(Role::Label); + node.set_live(Live::Polite); + + let mut screenshot_ui = Node::new(Role::Group); + screenshot_ui.set_label("Screenshot UI"); + + let exit_confirm_dialog = crate::ui::exit_confirm_dialog::a11y_node(); + + let mut overview = Node::new(Role::Group); + overview.set_label("Overview"); + + let mut root = Node::new(Role::Window); + root.set_children(vec![ + ID_ANNOUNCEMENT, + ID_SCREENSHOT_UI, + ID_EXIT_CONFIRM_DIALOG, + ID_OVERVIEW, + ]); + + let tree = Tree { + root: ID_ROOT, + toolkit_name: Some(String::from("niri")), + toolkit_version: None, + }; + + let focus = self.a11y_focus(); + + TreeUpdate { + nodes: vec![ + (ID_ROOT, root), + (ID_ANNOUNCEMENT, node), + (ID_SCREENSHOT_UI, screenshot_ui), + (ID_EXIT_CONFIRM_DIALOG, exit_confirm_dialog), + (ID_OVERVIEW, overview), + ], + tree: Some(tree), + focus, + } + } +} + +#[derive(Clone)] +struct Handler { + tx: calloop::channel::Sender, +} + +impl ActivationHandler for Handler { + fn request_initial_tree(&mut self) -> Option { + let _ = self.tx.send(Msg::InitialTree); + None + } +} + +impl DeactivationHandler for Handler { + fn deactivate_accessibility(&mut self) { + let _ = self.tx.send(Msg::Deactivate); + } +} + +impl ActionHandler for Handler { + fn do_action(&mut self, request: ActionRequest) { + let _ = self.tx.send(Msg::Action(request)); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 6921eaa1..a2fc3e05 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1842,6 +1842,9 @@ impl State { Action::ShowHotkeyOverlay => { if self.niri.hotkey_overlay.show() { self.niri.queue_redraw_all(); + + #[cfg(feature = "dbus")] + self.niri.a11y_announce_hotkey_overlay(); } } Action::MoveWorkspaceToMonitorLeft => { diff --git a/src/lib.rs b/src/lib.rs index 62d6eebf..cdad360e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,8 @@ #[macro_use] extern crate tracing; +#[cfg(feature = "dbus")] +pub mod a11y; pub mod animation; pub mod backend; pub mod cli; diff --git a/src/main.rs b/src/main.rs index cd94cd4e..caceebfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -218,6 +218,11 @@ fn main() -> Result<(), Box> { #[cfg(feature = "dbus")] dbus::DBusServers::start(&mut state, cli.session); + #[cfg(feature = "dbus")] + if cli.session { + state.niri.a11y.start(); + } + if env::var_os("NIRI_DISABLE_SYSTEM_MANAGER_NOTIFY").map_or(true, |x| x != "1") { // Notify systemd we're ready. if let Err(err) = sd_notify::notify(true, &[NotifyState::Ready]) { diff --git a/src/niri.rs b/src/niri.rs index c97153f1..504afe59 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -110,6 +110,8 @@ use smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState; use smithay::wayland::xdg_activation::XdgActivationState; use smithay::wayland::xdg_foreign::XdgForeignState; +#[cfg(feature = "dbus")] +use crate::a11y::A11y; use crate::animation::Clock; use crate::backend::tty::SurfaceDmabufFeedback; use crate::backend::{Backend, Headless, RenderResult, Tty, Winit}; @@ -390,6 +392,8 @@ pub struct Niri { #[cfg(feature = "dbus")] pub a11y_keyboard_monitor: Option, #[cfg(feature = "dbus")] + pub a11y: A11y, + #[cfg(feature = "dbus")] pub inhibit_power_key_fd: Option, pub ipc_server: Option, @@ -760,6 +764,10 @@ impl State { self.refresh_ipc_outputs(); self.ipc_refresh_layout(); self.ipc_refresh_keyboard_layout_index(); + + // Needs to be called after updating the keyboard focus. + #[cfg(feature = "dbus")] + self.niri.refresh_a11y(); } fn notify_blocker_cleared(&mut self) { @@ -1326,6 +1334,10 @@ impl State { Err(()) => { self.niri.config_error_notification.show(); self.niri.queue_redraw_all(); + + #[cfg(feature = "dbus")] + self.niri.a11y_announce_config_error(); + return; } }; @@ -2462,6 +2474,9 @@ impl Niri { let exit_confirm_dialog = ExitConfirmDialog::new(animation_clock.clone(), config.clone()); + #[cfg(feature = "dbus")] + let a11y = A11y::new(event_loop.clone()); + event_loop .insert_source( Timer::from_duration(Duration::from_secs(1)), @@ -2661,6 +2676,8 @@ impl Niri { #[cfg(feature = "dbus")] a11y_keyboard_monitor: None, #[cfg(feature = "dbus")] + a11y, + #[cfg(feature = "dbus")] inhibit_power_key_fd: None, ipc_server, diff --git a/src/ui/config_error_notification.rs b/src/ui/config_error_notification.rs index 20667172..c6928b6b 100644 --- a/src/ui/config_error_notification.rs +++ b/src/ui/config_error_notification.rs @@ -20,9 +20,6 @@ use crate::render_helpers::renderer::NiriRenderer; use crate::render_helpers::texture::{TextureBuffer, TextureRenderElement}; use crate::utils::{output_size, to_physical_precise_round}; -const TEXT: &str = "Failed to parse the config file. \ - Please run niri validate \ - to see the errors."; const PADDING: i32 = 8; const FONT: &str = "sans 14px"; const BORDER: i32 = 4; @@ -186,7 +183,7 @@ fn render( let padding: i32 = to_physical_precise_round(scale, PADDING); - let mut text = String::from(TEXT); + let mut text = error_text(true); let mut border_color = (1., 0.3, 0.3); if let Some(path) = created_path { text = format!( @@ -249,3 +246,13 @@ fn render( Ok(buffer) } + +pub fn error_text(markup: bool) -> String { + let command = if markup { + "niri validate" + } else { + "niri validate" + }; + + format!("Failed to parse the config file. Please run {command} to see the errors.") +} diff --git a/src/ui/exit_confirm_dialog.rs b/src/ui/exit_confirm_dialog.rs index e0cc9398..0147cf39 100644 --- a/src/ui/exit_confirm_dialog.rs +++ b/src/ui/exit_confirm_dialog.rs @@ -23,8 +23,7 @@ use crate::render_helpers::solid_color::{SolidColorBuffer, SolidColorRenderEleme use crate::render_helpers::texture::{TextureBuffer, TextureRenderElement}; use crate::utils::{output_size, to_physical_precise_round}; -const TEXT: &str = "Are you sure you want to exit niri?\n\n\ - Press Enter to confirm."; +const KEY_NAME: &str = "Enter"; const PADDING: i32 = 16; const FONT: &str = "sans 14px"; const BORDER: i32 = 8; @@ -228,6 +227,8 @@ impl ExitConfirmDialog { fn render(scale: f64) -> anyhow::Result { let _span = tracy_client::span!("exit_confirm_dialog::render"); + let markup = text(true); + let padding: i32 = to_physical_precise_round(scale, PADDING); let mut font = FontDescription::from_string(FONT); @@ -239,7 +240,7 @@ fn render(scale: f64) -> anyhow::Result { layout.context().set_round_glyph_positions(false); layout.set_font_description(Some(&font)); layout.set_alignment(Alignment::Center); - layout.set_markup(TEXT); + layout.set_markup(&markup); let (mut width, mut height) = layout.pixel_size(); width += padding * 2; @@ -255,7 +256,7 @@ fn render(scale: f64) -> anyhow::Result { layout.context().set_round_glyph_positions(false); layout.set_font_description(Some(&font)); layout.set_alignment(Alignment::Center); - layout.set_markup(TEXT); + layout.set_markup(&markup); cr.set_source_rgb(1., 1., 1.); pangocairo::functions::show_layout(&cr, &layout); @@ -282,3 +283,25 @@ fn render(scale: f64) -> anyhow::Result { Ok(buffer) } + +fn text(markup: bool) -> String { + let key = if markup { + format!(" {KEY_NAME} ") + } else { + String::from(KEY_NAME) + }; + + format!( + "Are you sure you want to exit niri?\n\n\ + Press {key} to confirm." + ) +} + +#[cfg(feature = "dbus")] +pub fn a11y_node() -> accesskit::Node { + let mut node = accesskit::Node::new(accesskit::Role::AlertDialog); + node.set_label("Exit niri"); + node.set_description(text(false)); + node.set_modal(); + node +} diff --git a/src/ui/hotkey_overlay.rs b/src/ui/hotkey_overlay.rs index 247f44f6..6b7a4cd4 100644 --- a/src/ui/hotkey_overlay.rs +++ b/src/ui/hotkey_overlay.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::cmp::max; use std::collections::HashMap; +use std::fmt::Write as _; use std::iter::zip; use std::rc::Rc; @@ -123,6 +124,32 @@ impl HotkeyOverlay { Some(PrimaryGpuTextureRenderElement(elem)) } + + pub fn a11y_text(&self) -> String { + let config = self.config.borrow(); + let actions = collect_actions(&config); + + let mut buf = String::new(); + writeln!(&mut buf, "{TITLE}").unwrap(); + + for action in actions { + let Some((key, action)) = format_bind(&config.binds.0, action) else { + continue; + }; + + let key = key.map(|key| key_name(true, self.mod_key, &key)); + let key = key.as_deref().unwrap_or("not bound"); + + let action = match pango::parse_markup(&action, '\0') { + Ok((_attrs, text, _accel)) => text, + Err(_) => action.into(), + }; + + writeln!(&mut buf, "{key} {action}").unwrap(); + } + + buf + } } fn format_bind(binds: &[Bind], action: &Action) -> Option<(Option, String)> { @@ -298,7 +325,7 @@ fn render( .into_iter() .filter_map(|action| format_bind(&config.binds.0, action)) .map(|(key, action)| { - let key = key.map(|key| key_name(mod_key, &key)); + let key = key.map(|key| key_name(false, mod_key, &key)); let key = key.as_deref().unwrap_or("(not bound)"); let key = format!(" {key} "); (key, action) @@ -466,7 +493,7 @@ fn action_name(action: &Action) -> String { } } -fn key_name(mod_key: ModKey, key: &Key) -> String { +fn key_name(screen_reader: bool, mod_key: ModKey, key: &Key) -> String { let mut name = String::new(); let has_comp_mod = key.modifiers.contains(Modifiers::COMPOSITOR); @@ -519,7 +546,7 @@ fn key_name(mod_key: ModKey, key: &Key) -> String { } let pretty = match key.trigger { - Trigger::Keysym(keysym) => prettify_keysym_name(&keysym_get_name(keysym)), + Trigger::Keysym(keysym) => prettify_keysym_name(screen_reader, &keysym_get_name(keysym)), Trigger::MouseLeft => String::from("Mouse Left"), Trigger::MouseRight => String::from("Mouse Right"), Trigger::MouseMiddle => String::from("Mouse Middle"), @@ -539,16 +566,24 @@ fn key_name(mod_key: ModKey, key: &Key) -> String { name } -fn prettify_keysym_name(name: &str) -> String { +fn prettify_keysym_name(screen_reader: bool, name: &str) -> String { + let name = if screen_reader { + name + } else { + match name { + "slash" => "/", + "comma" => ",", + "period" => ".", + "minus" => "-", + "equal" => "=", + "grave" => "`", + "bracketleft" => "[", + "bracketright" => "]", + _ => name, + } + }; + let name = match name { - "slash" => "/", - "comma" => ",", - "period" => ".", - "minus" => "-", - "equal" => "=", - "grave" => "`", - "bracketleft" => "[", - "bracketright" => "]", "Next" => "Page Down", "Prior" => "Page Up", "Print" => "PrtSc", @@ -574,7 +609,7 @@ mod tests { fn check(config: &str, action: Action) -> String { let config = Config::parse("test.kdl", config).unwrap(); if let Some((key, title)) = format_bind(&config.binds.0, &action) { - let key = key.map(|key| key_name(ModKey::Super, &key)); + let key = key.map(|key| key_name(false, ModKey::Super, &key)); let key = key.as_deref().unwrap_or("(not bound)"); format!(" {key} : {title}") } else { -- cgit