ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
295 lines
10 KiB
Rust
295 lines
10 KiB
Rust
//! Callback payload parsers.
|
|
|
|
use serde_json::Value;
|
|
|
|
use super::Rect;
|
|
|
|
/// The focused paragraph or cell for assistive tech (§7 tail).
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct A11y {
|
|
pub text: String,
|
|
/// Caret and selection in chars; LOK's -1 is None.
|
|
pub caret: Option<usize>,
|
|
pub selection: Option<(usize, usize)>,
|
|
}
|
|
|
|
/// A kernel window event that matters to the guard.
|
|
pub enum Dialog {
|
|
/// A dialog is up; close it.
|
|
Opened { window: u32, title: Option<String> },
|
|
/// A window got its title.
|
|
Titled { window: u32, title: String },
|
|
}
|
|
|
|
/// Parses "x, y, w, h" plus optional fields.
|
|
pub fn rect_of(text: &str) -> Option<Rect> {
|
|
let parts: Vec<i32> = text
|
|
.split(',')
|
|
.filter_map(|part| part.trim().parse().ok())
|
|
.collect();
|
|
match parts.as_slice() {
|
|
[x, y, w, h, ..] => Some((*x, *y, *w, *h)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// A search's matches, each by its first rectangle.
|
|
pub fn found_of(text: &str) -> Option<Vec<Rect>> {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
json["searchResultSelection"]
|
|
.as_array()?
|
|
.iter()
|
|
.map(|found| rect_of(found["rectangles"].as_str()?.split(';').next()?))
|
|
.collect()
|
|
}
|
|
|
|
/// The missing font families of a FONTS_MISSING payload.
|
|
pub fn fonts_of(text: &str) -> Option<Vec<String>> {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
json["fontsmissing"]
|
|
.as_array()?
|
|
.iter()
|
|
.map(|font| font.as_str().map(str::to_string))
|
|
.collect()
|
|
}
|
|
|
|
/// A STATE_CHANGED payload: ".uno:X=v" or JSON with commandName.
|
|
pub fn state_of(text: &str) -> Option<(String, String)> {
|
|
if text.starts_with('{') {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
let name = json["commandName"].as_str()?.to_string();
|
|
if json["dontcare"] == true {
|
|
return Some((name, super::MIXED.to_string()));
|
|
}
|
|
let state = match &json["state"] {
|
|
Value::String(state) => state.clone(),
|
|
other => other.to_string(),
|
|
};
|
|
return Some((name, state));
|
|
}
|
|
let (name, state) = text.split_once('=')?;
|
|
Some((name.to_string(), state.to_string()))
|
|
}
|
|
|
|
/// The text Calc's input line shows, from its JSDialog.
|
|
pub fn formula_text(text: &str) -> Option<String> {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
let data = &json["data"];
|
|
let line = json["jsontype"] == "formulabar"
|
|
&& data["control_id"] == "sc_input_window"
|
|
&& data["action_type"] == "setText";
|
|
line.then(|| data["text"].as_str().map(str::to_string))?
|
|
}
|
|
|
|
/// A ruler's custom tab stops; style 4 is default.
|
|
pub fn tabs_of(text: &str) -> Vec<i32> {
|
|
let Some(tabs) = text.split("\"tabs\"").nth(1) else {
|
|
return Vec::new();
|
|
};
|
|
// The keys repeat, so read the pairs as text.
|
|
let number = |field: &str, from: &str| -> Option<i32> {
|
|
let rest = from.split(field).nth(1)?;
|
|
let digits: String = rest
|
|
.trim_start_matches([':', ' ', '"'])
|
|
.chars()
|
|
.take_while(|c| c.is_ascii_digit() || *c == '-')
|
|
.collect();
|
|
digits.parse().ok()
|
|
};
|
|
tabs.split('{')
|
|
.skip(2)
|
|
.filter_map(|entry| Some((number("\"position\"", entry)?, number("\"style\"", entry)?)))
|
|
.filter(|(_, style)| *style != 4)
|
|
.map(|(position, _)| position)
|
|
.collect()
|
|
}
|
|
|
|
/// A completion payload: command name and success.
|
|
pub fn result_of(text: &str) -> Option<(String, bool)> {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
Some((
|
|
json["commandName"].as_str()?.to_string(),
|
|
json["success"].as_bool()?,
|
|
))
|
|
}
|
|
|
|
/// A number field sent as text or number.
|
|
fn number(value: &Value) -> Option<u64> {
|
|
value
|
|
.as_u64()
|
|
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))
|
|
}
|
|
|
|
/// The dialog event in a WINDOW or JSDIALOG payload.
|
|
pub fn dialog_of(jsdialog: bool, text: &str) -> Option<Dialog> {
|
|
let json: Value = serde_json::from_str(text).ok()?;
|
|
let action = json["action"].as_str().unwrap_or("");
|
|
let window = u32::try_from(number(&json["id"])?).ok()?;
|
|
let title = json["title"]
|
|
.as_str()
|
|
.or_else(|| json["text"].as_str())
|
|
.map(str::to_string);
|
|
if !jsdialog && action == "title_changed" {
|
|
return Some(Dialog::Titled {
|
|
window,
|
|
title: title?,
|
|
});
|
|
}
|
|
let opens = if jsdialog {
|
|
json["jsontype"] == "dialog" && action != "close"
|
|
} else {
|
|
// A tooltip is no dialog.
|
|
action == "created" && json["type"] != "tooltip"
|
|
};
|
|
opens.then_some(Dialog::Opened { window, title })
|
|
}
|
|
|
|
/// A signed LOK position; -1 and absent are None.
|
|
fn position(value: &Value) -> Option<usize> {
|
|
let signed = value
|
|
.as_i64()
|
|
.or_else(|| value.as_str().and_then(|text| text.parse().ok()))?;
|
|
usize::try_from(signed).ok()
|
|
}
|
|
|
|
/// Folds a focus, caret or selection payload into `a11y`.
|
|
fn fold_paragraph(a11y: &mut A11y, json: &Value) {
|
|
if let Some(text) = json["content"].as_str() {
|
|
a11y.text = text.to_string();
|
|
}
|
|
if !json["position"].is_null() {
|
|
a11y.caret = position(&json["position"]);
|
|
}
|
|
if !json["start"].is_null() {
|
|
a11y.selection = position(&json["start"]).zip(position(&json["end"]));
|
|
}
|
|
}
|
|
|
|
/// Folds an A11Y callback of LOK kind `kind` (62..69).
|
|
pub fn a11y_update(a11y: &mut A11y, kind: i32, text: &str) {
|
|
let Ok(json) = serde_json::from_str::<Value>(text) else {
|
|
log::warn!("lok: an a11y payload is not JSON");
|
|
return;
|
|
};
|
|
match kind {
|
|
// Focused cell, editing state: a nested paragraph.
|
|
67 | 68 => fold_paragraph(a11y, &json["paragraph"]),
|
|
// Object or cell selection.
|
|
69 => {
|
|
*a11y = A11y::default();
|
|
if json["action"] != "remove" {
|
|
a11y.text = json["text"].as_str().unwrap_or_default().to_string();
|
|
}
|
|
}
|
|
_ => fold_paragraph(a11y, &json),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn dont_care_states_read_as_mixed() {
|
|
let mixed = state_of(r#"{"commandName":".uno:Bold","dontcare":true}"#);
|
|
assert_eq!(
|
|
mixed,
|
|
Some((".uno:Bold".into(), super::super::MIXED.into()))
|
|
);
|
|
assert_eq!(
|
|
state_of(".uno:Bold=false"),
|
|
Some((".uno:Bold".into(), "false".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ruler_tabs_keep_the_custom_stops() {
|
|
let ruler = r#"{ "margin1": 0, "tabs": { "": { "position": 1251, "style": 4}, "": { "position": 2000, "style": 0}, "": { "position": 3500, "style": 3} }, "unit": "\""}"#;
|
|
assert_eq!(tabs_of(ruler), [2000, 3500]);
|
|
assert_eq!(tabs_of(r#"{"tabs": { }}"#), Vec::<i32>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn states_parse_both_forms() {
|
|
assert_eq!(
|
|
state_of(".uno:Bold=true"),
|
|
Some((".uno:Bold".into(), "true".into()))
|
|
);
|
|
assert_eq!(
|
|
state_of(r#"{"commandName":".uno:FontHeight","state":"12"}"#),
|
|
Some((".uno:FontHeight".into(), "12".into()))
|
|
);
|
|
assert_eq!(state_of("garbage"), None);
|
|
}
|
|
|
|
fn opened(event: Option<Dialog>) -> Option<(u32, Option<String>)> {
|
|
match event? {
|
|
Dialog::Opened { window, title } => Some((window, title)),
|
|
Dialog::Titled { .. } => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dialogs_open_on_created_or_jsdialog() {
|
|
let created = dialog_of(false, r#"{"id":"7","action":"created","title":"Font"}"#);
|
|
assert_eq!(opened(created), Some((7, Some("Font".into()))));
|
|
assert!(dialog_of(false, r#"{"id":"7","action":"invalidate"}"#).is_none());
|
|
let tip = r#"{"id":"2","action":"created","type":"tooltip","parentId":"0"}"#;
|
|
assert!(dialog_of(false, tip).is_none(), "a tooltip is no dialog");
|
|
let titled = dialog_of(
|
|
false,
|
|
r#"{"id":"2","action":"title_changed","title":"Delete"}"#,
|
|
);
|
|
assert!(matches!(titled, Some(Dialog::Titled { window: 2, .. })));
|
|
let js = r#"{"id":"Delete","text":"Delete Contents","id":2,"jsontype":"dialog"}"#;
|
|
assert_eq!(
|
|
opened(dialog_of(true, js)),
|
|
Some((2, Some("Delete Contents".into())))
|
|
);
|
|
let update = dialog_of(true, r#"{"jsontype":"dialog","action":"update","id":2}"#);
|
|
assert_eq!(opened(update), Some((2, None)));
|
|
assert!(dialog_of(true, r#"{"jsontype":"formulabar","id":0}"#).is_none());
|
|
assert!(dialog_of(true, r#"{"jsontype":"dialog","action":"close","id":2}"#).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_calc_input_line_reads_out() {
|
|
let line = r#"{"jsontype":"formulabar","action":"action","id":0,"data":{"control_id":"sc_input_window","text":"Hello","action_type":"setText"}}"#;
|
|
assert_eq!(formula_text(line).as_deref(), Some("Hello"));
|
|
let address = r#"{"jsontype":"addressinputfield","data":{"control_id":"pos_window","text":"A1","action_type":"setText"}}"#;
|
|
assert_eq!(formula_text(address), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a11y_folds_focus_caret_and_selection() {
|
|
let mut a11y = A11y::default();
|
|
let focus = r#"{"content":"Hello","position":"2","start":"1","end":"3"}"#;
|
|
a11y_update(&mut a11y, 62, focus);
|
|
let want = A11y {
|
|
text: "Hello".into(),
|
|
caret: Some(2),
|
|
selection: Some((1, 3)),
|
|
};
|
|
assert_eq!(a11y, want);
|
|
a11y_update(&mut a11y, 63, r#"{"position":"5"}"#);
|
|
assert_eq!(a11y.caret, Some(5));
|
|
a11y_update(&mut a11y, 64, r#"{"start":"-1","end":"-1"}"#);
|
|
assert_eq!(a11y.selection, None, "-1 clears the selection");
|
|
let cell = r#"{"paragraph":{"content":"B1","position":"0"}}"#;
|
|
a11y_update(&mut a11y, 67, cell);
|
|
assert_eq!(a11y.text, "B1");
|
|
a11y_update(
|
|
&mut a11y,
|
|
69,
|
|
r#"{"cell":"1","action":"create","text":"A1 text"}"#,
|
|
);
|
|
assert_eq!(a11y.text, "A1 text");
|
|
a11y_update(
|
|
&mut a11y,
|
|
69,
|
|
r#"{"cell":"1","action":"remove","text":"A1 text"}"#,
|
|
);
|
|
assert_eq!(a11y, A11y::default(), "a removed selection clears");
|
|
}
|
|
}
|