use std::collections::HashMap;
use intuituive::Driver;
use intuituive::a11y::{AccessNode, Announcement, Role};
use intuituive::rich::{Color, ColorTriplet, Style, TerminalTheme};
use serde_json::{Value, json};
pub const PROTOCOL: u32 = 1;
pub const THEME: TerminalTheme = TerminalTheme {
background: ColorTriplet::new(0x10, 0x10, 0x10),
foreground: ColorTriplet::new(0xd0, 0xd0, 0xd0),
ansi: [
ColorTriplet::new(0x2e, 0x34, 0x36),
ColorTriplet::new(0xcc, 0x00, 0x00),
ColorTriplet::new(0x4e, 0x9a, 0x06),
ColorTriplet::new(0xc4, 0xa0, 0x00),
ColorTriplet::new(0x34, 0x65, 0xa4),
ColorTriplet::new(0x75, 0x50, 0x7b),
ColorTriplet::new(0x06, 0x98, 0x9a),
ColorTriplet::new(0xd3, 0xd7, 0xcf),
ColorTriplet::new(0x55, 0x57, 0x53),
ColorTriplet::new(0xef, 0x29, 0x29),
ColorTriplet::new(0x8a, 0xe2, 0x34),
ColorTriplet::new(0xfc, 0xe9, 0x4f),
ColorTriplet::new(0x72, 0x9f, 0xcf),
ColorTriplet::new(0xad, 0x7f, 0xa8),
ColorTriplet::new(0x34, 0xe2, 0xe2),
ColorTriplet::new(0xee, 0xee, 0xec),
],
};
const ITEM_STATES: [&str; 5] = [
"aria-selected",
"aria-expanded",
"aria-checked",
"aria-posinset",
"aria-setsize",
];
#[derive(Debug, Default)]
pub struct Dom {
styles: HashMap<String, u32>,
lines: Vec<Value>,
size: (u16, u16),
cursor: Option<(u16, u16)>,
tree: Option<Vec<AccessNode>>,
frame: Option<u64>,
}
impl Dom {
pub fn new() -> Dom {
Dom::default()
}
pub fn hello(&self) -> String {
json!({"t": "hello", "protocol": PROTOCOL}).to_string()
}
pub fn update(&mut self, driver: &Driver) -> Vec<String> {
let mut out = Vec::new();
let frames = driver.stats().frames;
if let Some(frame) = self.frame(driver) {
out.push(frame.to_string());
}
self.frame = Some(frames);
let nodes = driver.accessibility();
if self.tree.as_ref() != Some(&nodes) {
out.push(tree(&nodes).to_string());
self.tree = Some(nodes);
}
out
}
fn frame(&mut self, driver: &Driver) -> Option<Value> {
let screen = driver.screen();
let area = screen.area();
let size = (area.width, area.height);
if size != self.size {
self.size = size;
self.lines.clear();
}
let mut rows = vec![self.lines.len() < area.height as usize; area.height as usize];
match self.frame.and_then(|frame| driver.damage_since(frame)) {
Some(damage) => {
for rect in damage {
let rect = rect.intersection(area);
for y in rect.y..rect.bottom() {
rows[y as usize] = true;
}
}
}
None => rows.iter_mut().for_each(|row| *row = true),
}
let mut new_styles = Vec::new();
let mut known = HashMap::new();
let mut changed = Vec::new();
for y in (0..area.height).filter(|&y| rows[y as usize]) {
let mut runs: Vec<Value> = Vec::new();
let mut run = String::new();
let mut current = 0;
for x in 0..area.width {
let cell = screen.cell(x, y);
if cell.is_continuation() {
continue;
}
let id = *known.entry(cell.style).or_insert_with(|| {
let css = screen.style(cell.style).map(css).unwrap_or_default();
self.style_number(css, &mut new_styles)
});
if cell.width >= 2 {
if !run.is_empty() {
runs.push(json!([std::mem::take(&mut run), current]));
}
runs.push(json!([cell.text(), id, 2]));
continue;
}
if id != current && !run.is_empty() {
runs.push(json!([std::mem::take(&mut run), current]));
}
current = id;
run.push_str(cell.text());
}
if !run.is_empty() {
runs.push(json!([run, current]));
}
let line = Value::Array(runs);
let y = y as usize;
if self.lines.get(y) != Some(&line) {
changed.push(json!([y, line.clone()]));
if y < self.lines.len() {
self.lines[y] = line;
} else {
self.lines.push(line);
}
}
}
let cursor = driver.cursor();
if changed.is_empty() && new_styles.is_empty() && cursor == self.cursor {
return None;
}
self.cursor = cursor;
Some(json!({
"t": "frame",
"cols": size.0,
"rows": size.1,
"styles": new_styles,
"lines": changed,
"cursor": cursor.map(|(x, y)| json!([x, y])),
}))
}
fn style_number(&mut self, css: String, new_styles: &mut Vec<Value>) -> u32 {
if css.is_empty() {
return 0;
}
if let Some(&id) = self.styles.get(&css) {
return id;
}
let id = self.styles.len() as u32 + 1;
new_styles.push(json!([id, css]));
self.styles.insert(css, id);
id
}
}
pub fn css(style: &Style) -> String {
let reverse = style.attr(6) == Some(true);
let mut style = style.clone();
if reverse {
let rgb = |t: ColorTriplet| Color::from_rgb(t.red, t.green, t.blue);
if style.color().is_none() {
style = style.with_color(rgb(THEME.foreground));
}
if style.bgcolor().is_none() {
style = style.with_bgcolor(rgb(THEME.background));
}
}
style.get_html_style(&THEME)
}
fn item_role(role: Role) -> Option<&'static str> {
Some(match role {
Role::List => "listitem",
Role::Table => "row",
Role::Tree => "treeitem",
Role::TabList => "tab",
Role::Menu | Role::MenuBar => "menuitem",
Role::Grid => "gridcell",
Role::RadioGroup => "radio",
Role::ComboBox => "option",
_ => return None,
})
}
fn pairs(attrs: &[(&'static str, String)]) -> Value {
Value::Array(
attrs
.iter()
.map(|(name, value)| json!([name, value]))
.collect(),
)
}
pub fn node(node: &AccessNode) -> Value {
let attrs = node.aria_attributes();
let item = item_role(node.role).zip(node.value.as_ref());
let text = match node.role {
Role::Text | Role::Status => node.describe(),
Role::TextBox => node.value.clone().unwrap_or_default(),
_ => String::new(),
};
let rect = node.rect;
let mut out = json!({
"id": node.id,
"depth": node.depth,
"rect": [rect.x, rect.y, rect.width, rect.height],
"text": text,
});
match item {
Some((role, value)) => {
let combo = node.role == Role::ComboBox;
let (mut on_item, own): (Vec<_>, Vec<_>) = attrs.into_iter().partition(|(name, _)| {
ITEM_STATES.contains(name) && !(combo && *name == "aria-expanded")
});
if combo {
on_item.insert(0, ("aria-selected", "true".to_string()));
}
out["attrs"] = pairs(&own);
out["item"] = json!({"role": role, "attrs": pairs(&on_item), "text": value});
}
None => out["attrs"] = pairs(&attrs),
}
out
}
pub fn tree(nodes: &[AccessNode]) -> Value {
let focus = nodes.iter().find(|n| n.focused).map(|n| n.id);
json!({
"t": "tree",
"focus": focus,
"nodes": nodes.iter().map(node).collect::<Vec<_>>(),
})
}
pub fn say(announcement: &Announcement) -> String {
json!({"t": "say", "text": announcement.text, "urgent": announcement.urgent}).to_string()
}
pub fn copy(number: u64, text: &str) -> String {
json!({"t": "copy", "n": number, "text": text}).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use intuituive::rich::Style;
#[test]
fn styles_become_css() {
let parse = |s: &str| Style::parse(s).unwrap();
assert_eq!(css(&Style::new()), "");
assert_eq!(
css(&parse("bold red")),
"color: #cc0000; text-decoration-color: #cc0000; font-weight: bold"
);
assert_eq!(
css(&parse("reverse")),
"color: #101010; text-decoration-color: #101010; background-color: #d0d0d0"
);
assert_eq!(css(&parse("on #123456")), "background-color: #123456");
}
#[test]
fn items_take_the_item_states() {
use intuituive::a11y::AccessState;
use intuituive::screen::Rect;
let list = AccessNode {
depth: 0,
id: 7,
role: Role::List,
name: "Files".into(),
value: Some("b.txt".into()),
focused: true,
disabled: false,
state: AccessState::item(1, 3),
rect: Rect::new(0, 1, 10, 3),
error: None,
};
let json = node(&list);
assert_eq!(
json["attrs"],
json!([["role", "list"], ["aria-label", "Files"]])
);
assert_eq!(
json["item"],
json!({
"role": "listitem",
"attrs": [["aria-selected", "true"], ["aria-posinset", "2"], ["aria-setsize", "3"]],
"text": "b.txt",
})
);
assert_eq!(json["rect"], json!([0, 1, 10, 3]));
let group = AccessNode {
role: Role::RadioGroup,
name: "Size".into(),
value: Some("Medium".into()),
state: AccessState {
checked: Some(true),
position: Some((2, 3)),
..AccessState::default()
},
..list.clone()
};
let json = node(&group);
assert_eq!(json["item"]["role"], "radio");
assert_eq!(
json["item"]["attrs"],
json!([
["aria-checked", "true"],
["aria-posinset", "2"],
["aria-setsize", "3"]
])
);
let combo = AccessNode {
role: Role::ComboBox,
name: "Theme".into(),
value: Some("Dark".into()),
state: AccessState {
expanded: Some(false),
position: Some((2, 3)),
..AccessState::default()
},
..list.clone()
};
let json = node(&combo);
assert_eq!(json["item"]["role"], "option");
assert_eq!(json["item"]["text"], "Dark");
assert_eq!(
json["item"]["attrs"],
json!([
["aria-selected", "true"],
["aria-posinset", "2"],
["aria-setsize", "3"]
])
);
assert!(
json["attrs"]
.as_array()
.unwrap()
.contains(&json!(["aria-expanded", "false"]))
);
let button = AccessNode {
role: Role::Button,
name: "Save".into(),
value: None,
state: AccessState::default(),
..list.clone()
};
let json = node(&button);
assert_eq!(
json["attrs"],
json!([["role", "button"], ["aria-label", "Save"]])
);
assert!(json.get("item").is_none());
assert_eq!(json["text"], "");
let text = AccessNode {
role: Role::Text,
name: "Hello".into(),
..button
};
assert_eq!(node(&text)["text"], "Hello");
}
}