Files
wordcraft/crates/engine/src/cmd/speech.rs
T
aiongg 08028c44c0 Engine: several open documents; file.new opens another
One session holds several open documents. The active document stays in
the Session's own fields, so commands and the UI keep working on it; the
others are parked as DocState values and swapped in field by field
(std::mem::swap, no clones or relayout). Session and DocState are
destructured exhaustively, so a new field must be classified as per
document or shared before it builds.

- Session::documents, activate, add_document, close_document,
  is_untouched_blank, with_document, find_open, is_open; DocInfo.
- Document ids come from a process-wide counter.
- file.new opens beside the open documents; file.open replaces an
  untouched blank document, opens beside the others otherwise, and
  activates a file that is already open ("alreadyOpen": true).
- file.closeDocument {id?, force?} closes a document without a window
  (refuses unsaved changes unless forced); file.close is unchanged.
- view.switchWindows {id?} lists the open documents or activates one.
- Envelopes, Labels, Finish & Merge and New from Template open their
  result as a new document; the main document stays open, with no undo
  step for the command.
- Each document keeps its own recovery worker and copy; Read Aloud
  readings name their document; with_document tags UI requests with the
  document they came from.
2026-10-22 14:41:20 +00:00

284 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Read Aloud (Review › Speech) and moving the caret past citations.
//!
//! The player lives in [`crate::speech`]; these commands drive it, so the player window, the
//! ribbon, the CLI and agents all control reading the same way.
use serde_json::Value;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::{InlineObject, Pos};
use super::sel_result;
use crate::speech::{self, State};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("review.readAloud", "Read Aloud", "Review › Speech", read_aloud)
.key("Mod+Alt+Space")
.params(r#"{"skipCitations"?: bool}"#)
.pure(),
CommandSpec::new("readAloud.playPause", "Play/Pause", "Review › Speech", play_pause).pure(),
CommandSpec::new("readAloud.next", "Next Sentence", "Review › Speech", next).pure(),
CommandSpec::new("readAloud.previous", "Previous Sentence", "Review › Speech", previous).pure(),
CommandSpec::new("readAloud.stop", "Stop Reading", "Review › Speech", stop).pure(),
CommandSpec::new("readAloud.speed", "Reading Speed", "Review › Speech", speed).params(r#"{"value": number (0.5…3, 1 = normal)}"#).pure(),
CommandSpec::new("readAloud.skipCitations", "Skip Citations and Bibliography", "Review › Speech", skip_citations)
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("readAloud.status", "Read Aloud Status", "Review › Speech", status).pure(),
CommandSpec::new("caret.pastCitation", "Move Past Citation", "Zotero › Citations", past_citation).pure(),
]
}
/// Start reading from the caret (or the selection); while reading, pause or carry on.
fn read_aloud(s: &mut Session, v: &Value) -> CmdResult {
if let Some(b) = p::bool(v, "skipCitations") {
s.read_aloud.skip_citations = b;
}
// A reading of this document pauses or carries on; one of another document gives way.
if s.read_aloud.state() != State::Stopped && s.read_aloud.document == Some(s.document_id()) {
s.read_aloud.play_pause();
return status(s, v);
}
let skip = s.read_aloud.skip_citations;
let mut queue = if s.sel.is_collapsed() {
speech::sentences(&s.doc, &s.sel.focus, None, skip)
} else {
let (a, b) = s.sel.ordered();
speech::sentences(&s.doc, &a, Some(&b), skip)
};
// At the end of the document: read it from the top.
if queue.is_empty() && s.sel.is_collapsed() {
let top = s.doc.start_of(s.sel.focus.story);
queue = speech::sentences(&s.doc, &top, None, skip);
}
if queue.is_empty() {
return Err(CmdError::Failed("there's nothing to read".into()));
}
s.read_aloud.document = Some(s.document_id());
s.read_aloud.start(queue);
status(s, v)
}
fn play_pause(s: &mut Session, v: &Value) -> CmdResult {
if s.read_aloud.progress().1 == 0 || s.read_aloud.document != Some(s.document_id()) {
return read_aloud(s, v);
}
s.read_aloud.play_pause();
status(s, v)
}
fn next(s: &mut Session, v: &Value) -> CmdResult {
s.read_aloud.skip(1);
status(s, v)
}
fn previous(s: &mut Session, v: &Value) -> CmdResult {
s.read_aloud.skip(-1);
status(s, v)
}
fn stop(s: &mut Session, v: &Value) -> CmdResult {
s.read_aloud.stop();
status(s, v)
}
fn speed(s: &mut Session, v: &Value) -> CmdResult {
let r = p::f32(v, "value").or_else(|| p::f32(v, "rate")).ok_or_else(|| CmdError::Params("`value` (number, 0.5…3) is required".into()))?;
s.read_aloud.set_rate(r);
status(s, v)
}
/// Turn skipping citations on or off; a reading in progress carries on from the same sentence.
fn skip_citations(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(!s.read_aloud.skip_citations);
if on != s.read_aloud.skip_citations {
s.read_aloud.skip_citations = on;
if let Some(cur) = s.read_aloud.current().filter(|_| s.read_aloud.document == Some(s.document_id())) {
let paused = s.read_aloud.state() == State::Paused;
let queue = speech::sentences(&s.doc, &cur.start, None, on);
if !queue.is_empty() {
s.read_aloud.start(queue);
if paused {
s.read_aloud.play_pause();
}
}
}
}
status(s, v)
}
fn status(s: &mut Session, _: &Value) -> CmdResult {
Ok(s.read_aloud.status())
}
/// Move the caret to just after the citation it's in or before (a run of adjacent citations
/// counts as one); with no such citation in the paragraph, after the next one in the document.
fn past_citation(s: &mut Session, _: &Value) -> CmdResult {
let caret = s.sel.ordered().1;
let story = caret.story;
let obj = OBJ.len_utf8();
// Every citation as (first position, position just after it).
let mut spans: Vec<(Pos, Pos)> = s
.doc
.field_ranges(story)
.into_iter()
.filter(|r| speech::is_citation_code(&r.instr))
.map(|r| (r.start, Pos::new(story, r.end.path.clone(), r.end.off + obj)))
.collect();
for path in s.doc.para_paths(story) {
let Some(para) = s.doc.para(story, &path) else { continue };
for off in para.object_offsets() {
if let Some(InlineObject::Field { instr, .. }) = para.object_at(off)
&& speech::is_citation_code(instr)
{
spans.push((Pos::new(story, path.clone(), off), Pos::new(story, path.clone(), off + obj)));
}
}
}
spans.sort();
// The citation the caret is in or right before, else the next one.
let Some(mut i) = spans.iter().position(|(_, end)| *end > caret) else {
return Err(CmdError::Failed("there's no citation after the caret".into()));
};
let mut after = spans.get(i).map(|x| x.1.clone()).unwrap_or(caret);
// Swallow citations that follow directly ("(A)(B)" or "(A) (B)").
while let Some((start, end)) = spans.get(i + 1) {
let gap = if start.path == after.path { s.doc.para(story, &after.path).and_then(|p| p.text.get(after.off..start.off)) } else { None };
if !gap.is_some_and(|g| g.chars().all(|c| c.is_whitespace() || c == ';' || c == ',')) {
break;
}
after = end.clone();
i += 1;
}
s.sel = crate::Selection::caret(s.doc.clamp(&after));
s.goal_x = None;
sel_result(s)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
use crate::Session;
use crate::speech::State;
fn cite(d: &mut Document, block: usize, off: usize, text: &str) {
let c = CharProps::default();
let p = d.para_mut(StoryRef::Body, &Path::top(block)).unwrap();
p.insert_object(off, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c)
.unwrap();
let n = p.insert_text(off + 3, text, &c).unwrap();
p.insert_object(off + 3 + n, InlineObject::FieldEnd, &c).unwrap();
}
/// "Cats purr (Smith 2020). Dogs bark (Jones 2021; Lee 2019).\nReferences\nSmith J. Cats.\nJones K. Dogs."
/// with Zotero citations and a Zotero bibliography over the last two paragraphs.
fn doc() -> Document {
let mut d = Document::from_text("Cats purr . Dogs bark .\nReferences\nSmith J. Cats.\nJones K. Dogs.");
cite(&mut d, 0, 22, "(Jones 2021; Lee 2019)");
cite(&mut d, 0, 10, "(Smith 2020)");
let c = CharProps::default();
d.para_mut(StoryRef::Body, &Path::top(2))
.unwrap()
.insert_object(0, InlineObject::FieldStart { instr: "ADDIN ZOTERO_BIBL {} CSL_BIBLIOGRAPHY".into(), locked: false, dirty: false }, &c)
.unwrap();
let p3 = d.para_mut(StoryRef::Body, &Path::top(3)).unwrap();
let end = p3.len();
p3.insert_object(end, InlineObject::FieldEnd, &c).unwrap();
d
}
fn texts(s: &Session) -> Vec<String> {
crate::speech::sentences(&s.doc, &Pos::body(0, 0), None, s.read_aloud.skip_citations).into_iter().map(|u| u.text).collect()
}
#[test]
fn citations_and_bibliography_are_skipped() {
let mut s = Session::new(doc());
assert_eq!(s.doc.plain_text(StoryRef::Body).lines().next(), Some("Cats purr (Smith 2020). Dogs bark (Jones 2021; Lee 2019)."));
assert_eq!(texts(&s), ["Cats purr.", "Dogs bark.", "References"]);
s.run("readAloud.skipCitations", &json!({"value": false})).unwrap();
assert_eq!(texts(&s), ["Cats purr (Smith 2020).", "Dogs bark (Jones 2021; Lee 2019).", "References", "Smith J. Cats.", "Jones K. Dogs."]);
}
#[test]
fn read_aloud_commands_drive_the_player() {
let mut s = Session::new(doc());
s.sel = crate::Selection::caret(Pos::body(0, 0));
let r = s.run("review.readAloud", &json!({})).unwrap();
assert_eq!(r["state"], "playing");
assert_eq!(r["count"], 3);
assert_eq!(r["current"]["text"], "Cats purr.");
assert_eq!(s.run("readAloud.next", &json!({})).unwrap()["current"]["text"], "Dogs bark.");
assert_eq!(s.run("readAloud.previous", &json!({})).unwrap()["index"], 0);
assert_eq!(s.run("readAloud.speed", &json!({"value": 1.5})).unwrap()["rate"], 1.5);
assert!(s.run("readAloud.speed", &json!({})).is_err());
// Pressing Read Aloud again pauses, then carries on.
assert_eq!(s.run("review.readAloud", &json!({})).unwrap()["state"], "paused");
assert_eq!(s.run("readAloud.playPause", &json!({})).unwrap()["state"], "playing");
// Turning skipping off mid-reading keeps the place.
s.run("readAloud.next", &json!({})).unwrap();
let r = s.run("readAloud.skipCitations", &json!({"value": false})).unwrap();
assert_eq!(r["current"]["text"], "Dogs bark (Jones 2021; Lee 2019).");
assert_eq!(r["count"], 4);
let r = s.run("readAloud.stop", &json!({})).unwrap();
assert_eq!(r["state"], "stopped");
assert_eq!(r["open"], false);
assert_eq!(s.read_aloud.state(), State::Stopped);
}
#[test]
fn read_aloud_reads_the_selection_only() {
let mut s = Session::new(Document::from_text("One. Two. Three."));
s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(0, 9) };
let r = s.run("review.readAloud", &json!({})).unwrap();
assert_eq!(r["count"], 1);
assert_eq!(r["current"]["text"], "Two.");
}
#[test]
fn read_aloud_at_the_end_starts_from_the_top() {
let mut s = Session::new(Document::from_text("One. Two."));
s.sel = crate::Selection::caret(Pos::body(0, 9));
assert_eq!(s.run("review.readAloud", &json!({})).unwrap()["current"]["text"], "One.");
let mut s = Session::new(Document::from_text(""));
assert!(s.run("review.readAloud", &json!({})).is_err());
}
#[test]
fn move_past_citation() {
let mut s = Session::new(doc());
// Before the first citation → just after it.
s.sel = crate::Selection::caret(Pos::body(0, 5));
s.run("caret.pastCitation", &json!({})).unwrap();
let first_end = s.sel.focus.clone();
let p = s.doc.para_at(&first_end).unwrap();
assert_eq!(p.text.get(first_end.off..), Some(". Dogs bark \u{FFFC}(Jones 2021; Lee 2019)\u{FFFC}."));
// Inside the second → after it.
let inside = p.text.find("Lee").unwrap();
s.sel = crate::Selection::caret(Pos::body(0, inside));
s.run("caret.pastCitation", &json!({})).unwrap();
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!(p.text.get(s.sel.focus.off..), Some("."));
// After the last citation in the body text: on to the end of the bibliography.
s.run("caret.pastCitation", &json!({})).unwrap();
assert_eq!(s.sel.focus.path, Path::top(3));
assert!(s.run("caret.pastCitation", &json!({})).is_err());
}
#[test]
fn adjacent_citations_are_skipped_together() {
let mut d = Document::from_text("A b.");
cite(&mut d, 0, 2, "(Lee)");
cite(&mut d, 0, 1, "(Kim)");
let mut s = Session::new(d);
s.sel = crate::Selection::caret(Pos::body(0, 1));
s.run("caret.pastCitation", &json!({})).unwrap();
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!(p.text.get(s.sel.focus.off..), Some(" b."));
}
}