aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/a11y.rs292
-rw-r--r--src/input/mod.rs3
-rw-r--r--src/lib.rs2
-rw-r--r--src/main.rs5
-rw-r--r--src/niri.rs17
-rw-r--r--src/ui/config_error_notification.rs15
-rw-r--r--src/ui/exit_confirm_dialog.rs31
-rw-r--r--src/ui/hotkey_overlay.rs61
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 => {
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<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 {