Files
ElyOffice/crates/ely-lok/src/events/parse.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

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");
}
}