diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/a11y.rs | 292 | ||||
| -rw-r--r-- | src/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/lib.rs | 2 | ||||
| -rw-r--r-- | src/main.rs | 5 | ||||
| -rw-r--r-- | src/niri.rs | 17 | ||||
| -rw-r--r-- | src/ui/config_error_notification.rs | 15 | ||||
| -rw-r--r-- | src/ui/exit_confirm_dialog.rs | 31 | ||||
| -rw-r--r-- | src/ui/hotkey_overlay.rs | 61 |
8 files changed, 405 insertions, 21 deletions
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<WorkspaceId>, + last_announcement: String, + to_accesskit: Option<mpsc::SyncSender<TreeUpdate>>, +} + +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::<TreeUpdate>(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<Msg>, +} + +impl ActivationHandler for Handler { + fn request_initial_tree(&mut self) -> Option<TreeUpdate> { + 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 => { @@ -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<dyn std::error::Error>> { #[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<crate::dbus::freedesktop_a11y::KeyboardMonitor>, #[cfg(feature = "dbus")] + pub a11y: A11y, + #[cfg(feature = "dbus")] pub inhibit_power_key_fd: Option<zbus::zvariant::OwnedFd>, pub ipc_server: Option<IpcServer>, @@ -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 <span face='monospace' bgcolor='#000000'>niri validate</span> \ - 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 { + "<span face='monospace' bgcolor='#000000'>niri validate</span>" + } 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 <span face='mono' bgcolor='#2C2C2C'> Enter </span> 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<MemoryBuffer> { 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<MemoryBuffer> { 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<MemoryBuffer> { 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<MemoryBuffer> { Ok(buffer) } + +fn text(markup: bool) -> String { + let key = if markup { + format!("<span face='mono' bgcolor='#2C2C2C'> {KEY_NAME} </span>") + } 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<Key>, 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 { |
