Skip to content

Commit 33fc4f0

Browse files
authored
feat: Implement text attributes for AT-SPI (#695)
1 parent b5979a4 commit 33fc4f0

7 files changed

Lines changed: 204 additions & 25 deletions

File tree

Cargo.lock

Lines changed: 52 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

platforms/atspi-common/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@ simplified-api = []
1818
accesskit = { version = "0.24.0", path = "../../common" }
1919
accesskit_consumer = { version = "0.34.0", path = "../../consumer" }
2020
atspi-common = { version = "0.13", default-features = false }
21+
phf = { version = "0.13.1", features = ["macros"] }
2122
serde = "1.0"
2223
zvariant = { version = "5.4", default-features = false }

platforms/atspi-common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ mod node;
1414
mod rect;
1515
#[cfg(feature = "simplified-api")]
1616
pub mod simplified;
17+
mod text_attributes;
1718
mod util;
1819

1920
pub use accesskit_consumer::NodeId;

platforms/atspi-common/src/node.rs

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use crate::{
2828
adapter::Adapter,
2929
context::{AppContext, Context},
3030
filters::filter,
31+
text_attributes::ATTRIBUTE_GETTERS,
3132
util::*,
3233
Action as AtspiAction, Error, ObjectEvent, Property, Rect as AtspiRect, Result,
3334
};
@@ -1309,19 +1310,33 @@ impl PlatformNode {
13091310
})
13101311
}
13111312

1312-
pub fn text_attribute_value(&self, _offset: i32, _attribute_name: &str) -> Result<String> {
1313-
// TODO: Implement rich text.
1314-
Err(Error::UnsupportedInterface)
1313+
pub fn text_attribute_value(&self, offset: i32, attribute_name: &str) -> Result<String> {
1314+
self.resolve_for_text(|node| {
1315+
let pos = text_position_from_offset(&node, offset).ok_or(Error::IndexOutOfRange)?;
1316+
Ok(ATTRIBUTE_GETTERS
1317+
.get(attribute_name)
1318+
.and_then(|getter| (*getter)(pos.inner_node()))
1319+
.unwrap_or_default())
1320+
})
13151321
}
13161322

1317-
pub fn text_attributes(&self, _offset: i32) -> Result<(HashMap<String, String>, i32, i32)> {
1318-
// TODO: Implement rich text.
1319-
Err(Error::UnsupportedInterface)
1323+
pub fn text_attributes(
1324+
&self,
1325+
offset: i32,
1326+
) -> Result<(HashMap<&'static str, String>, i32, i32)> {
1327+
self.text_attribute_run(offset, false)
13201328
}
13211329

1322-
pub fn default_text_attributes(&self) -> Result<HashMap<String, String>> {
1323-
// TODO: Implement rich text.
1324-
Err(Error::UnsupportedInterface)
1330+
pub fn default_text_attributes(&self) -> Result<HashMap<&'static str, String>> {
1331+
self.resolve_for_text(|node| {
1332+
let mut result = HashMap::with_capacity(ATTRIBUTE_GETTERS.len());
1333+
for (name, getter) in ATTRIBUTE_GETTERS.entries() {
1334+
if let Some(value) = (*getter)(&node) {
1335+
result.insert(*name, value);
1336+
}
1337+
}
1338+
Ok(result)
1339+
})
13251340
}
13261341

13271342
pub fn character_extents(&self, offset: i32, coord_type: CoordType) -> Result<AtspiRect> {
@@ -1469,14 +1484,41 @@ impl PlatformNode {
14691484

14701485
pub fn text_attribute_run(
14711486
&self,
1472-
_offset: i32,
1473-
_include_defaults: bool,
1474-
) -> Result<(HashMap<String, String>, i32, i32)> {
1475-
// TODO: Implement rich text.
1476-
// For now, just report a range spanning the entire text with no attributes,
1477-
// this is required by Orca to announce selection content and caret movements.
1478-
let character_count = self.character_count()?;
1479-
Ok((HashMap::new(), 0, character_count))
1487+
offset: i32,
1488+
include_defaults: bool,
1489+
) -> Result<(HashMap<&'static str, String>, i32, i32)> {
1490+
self.resolve_for_text(|node| {
1491+
let pos = text_position_from_offset(&node, offset).ok_or(Error::IndexOutOfRange)?;
1492+
let mut result = HashMap::with_capacity(ATTRIBUTE_GETTERS.len());
1493+
for (name, getter) in ATTRIBUTE_GETTERS.entries() {
1494+
if let Some(value) = (*getter)(pos.inner_node()) {
1495+
if !include_defaults {
1496+
if let Some(default) = (*getter)(&node) {
1497+
if value == default {
1498+
continue;
1499+
}
1500+
}
1501+
}
1502+
result.insert(*name, value);
1503+
}
1504+
}
1505+
let start = if pos.is_format_start() {
1506+
pos
1507+
} else {
1508+
pos.backward_to_format_start()
1509+
};
1510+
let end = pos.forward_to_format_end();
1511+
Ok((
1512+
result,
1513+
start
1514+
.to_global_usv_index()
1515+
.try_into()
1516+
.map_err(|_| Error::TooManyCharacters)?,
1517+
end.to_global_usv_index()
1518+
.try_into()
1519+
.map_err(|_| Error::TooManyCharacters)?,
1520+
))
1521+
})
14801522
}
14811523

14821524
pub fn scroll_substring_to(

platforms/atspi-common/src/simplified.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -407,14 +407,17 @@ impl Accessible {
407407
}
408408
}
409409

410-
pub fn text_attributes(&self, offset: i32) -> Result<(HashMap<String, String>, i32, i32)> {
410+
pub fn text_attributes(
411+
&self,
412+
offset: i32,
413+
) -> Result<(HashMap<&'static str, String>, i32, i32)> {
411414
match self {
412415
Self::Node(node) => node.text_attributes(offset),
413416
Self::Root(_) => Err(Error::UnsupportedInterface),
414417
}
415418
}
416419

417-
pub fn default_text_attributes(&self) -> Result<HashMap<String, String>> {
420+
pub fn default_text_attributes(&self) -> Result<HashMap<&'static str, String>> {
418421
match self {
419422
Self::Node(node) => node.default_text_attributes(),
420423
Self::Root(_) => Err(Error::UnsupportedInterface),
@@ -491,7 +494,7 @@ impl Accessible {
491494
&self,
492495
offset: i32,
493496
include_defaults: bool,
494-
) -> Result<(HashMap<String, String>, i32, i32)> {
497+
) -> Result<(HashMap<&'static str, String>, i32, i32)> {
495498
match self {
496499
Self::Node(node) => node.text_attribute_run(offset, include_defaults),
497500
Self::Root(_) => Err(Error::UnsupportedInterface),
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2026 The AccessKit Authors. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0 (found in
3+
// the LICENSE-APACHE file) or the MIT license (found in
4+
// the LICENSE-MIT file), at your option.
5+
6+
use accesskit::{Color, TextAlign, TextDecorationStyle};
7+
use accesskit_consumer::Node;
8+
use phf::phf_map;
9+
10+
fn color_to_string(color: Color) -> String {
11+
format!("{},{},{}", color.red, color.green, color.blue)
12+
}
13+
14+
fn family_name(node: &Node) -> Option<String> {
15+
node.font_family().map(String::from)
16+
}
17+
18+
fn size(node: &Node) -> Option<String> {
19+
node.font_size().map(|value| value.to_string())
20+
}
21+
22+
fn weight(node: &Node) -> Option<String> {
23+
node.font_weight().map(|value| value.to_string())
24+
}
25+
26+
fn style(node: &Node) -> Option<String> {
27+
node.is_italic().then(|| "italic".into())
28+
}
29+
30+
fn strikethrough(node: &Node) -> Option<String> {
31+
node.strikethrough().map(|_| "true".into())
32+
}
33+
34+
fn underline(node: &Node) -> Option<String> {
35+
node.underline().map(|deco| {
36+
match deco.style {
37+
TextDecorationStyle::Double => "double",
38+
_ => "single",
39+
}
40+
.into()
41+
})
42+
}
43+
44+
fn bg_color(node: &Node) -> Option<String> {
45+
node.background_color().map(color_to_string)
46+
}
47+
48+
fn fg_color(node: &Node) -> Option<String> {
49+
node.foreground_color().map(color_to_string)
50+
}
51+
52+
fn language(node: &Node) -> Option<String> {
53+
node.language().map(String::from)
54+
}
55+
56+
fn justification(node: &Node) -> Option<String> {
57+
node.text_align().map(|align| {
58+
match align {
59+
TextAlign::Left => "left",
60+
TextAlign::Center => "center",
61+
TextAlign::Right => "right",
62+
TextAlign::Justify => "fill",
63+
}
64+
.into()
65+
})
66+
}
67+
68+
pub(crate) const ATTRIBUTE_GETTERS: phf::Map<&'static str, fn(&Node) -> Option<String>> = phf_map! {
69+
"family-name" => family_name,
70+
"size" => size,
71+
"weight" => weight,
72+
"style" => style,
73+
"strikethrough" => strikethrough,
74+
"underline" => underline,
75+
"bg-color" => bg_color,
76+
"fg-color" => fg_color,
77+
"language" => language,
78+
"justification" => justification,
79+
};

platforms/unix/src/atspi/interfaces/text.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,14 @@ impl TextInterface {
6060
.map_err(self.map_error())
6161
}
6262

63-
fn get_attributes(&self, offset: i32) -> fdo::Result<(HashMap<String, String>, i32, i32)> {
63+
fn get_attributes(
64+
&self,
65+
offset: i32,
66+
) -> fdo::Result<(HashMap<&'static str, String>, i32, i32)> {
6467
self.node.text_attributes(offset).map_err(self.map_error())
6568
}
6669

67-
fn get_default_attributes(&self) -> fdo::Result<HashMap<String, String>> {
70+
fn get_default_attributes(&self) -> fdo::Result<HashMap<&'static str, String>> {
6871
self.node
6972
.default_text_attributes()
7073
.map_err(self.map_error())
@@ -128,7 +131,7 @@ impl TextInterface {
128131
&self,
129132
offset: i32,
130133
include_defaults: bool,
131-
) -> fdo::Result<(HashMap<String, String>, i32, i32)> {
134+
) -> fdo::Result<(HashMap<&'static str, String>, i32, i32)> {
132135
self.node
133136
.text_attribute_run(offset, include_defaults)
134137
.map_err(self.map_error())

0 commit comments

Comments
 (0)