mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-02 06:47:11 -06:00
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.
284 lines
12 KiB
Rust
284 lines
12 KiB
Rust
//! 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."));
|
||
}
|
||
}
|