Files
wordcraft/crates/engine/src/session/documents.rs
T
aiongg e900513104 Merge branch 'w3' into w6
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/lib.rs
#	crates/engine/src/session.rs
#	crates/ui-egui/Cargo.toml
#	crates/ui-egui/src/lib.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-26 03:22:33 +00:00

658 lines
25 KiB
Rust

//! Several open documents in one session. The active document lives in the session's own fields,
//! where every command works on it; the others wait as [`DocState`] values and are swapped in,
//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid
//! out again or thrown away, so a host may switch documents as often as it likes.
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use serde_json::json;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Pos, StoryRef};
use wordcraft_layout::LayoutCache;
use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style};
use crate::CmdError;
/// Document ids are unique in the process, so an id an agent or a window holds never names
/// another document, even across sessions.
static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1);
pub(super) fn next_document_id() -> u64 {
NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed)
}
/// The id the next document will get: every document made after this call has an id at least
/// this big.
pub(super) fn document_id_mark() -> u64 {
NEXT_DOCUMENT_ID.load(Ordering::Relaxed)
}
/// An untitled document's name: `Document1`, `Document2`…
pub(super) fn untitled_name(n: u32) -> String {
format!("Document{}", n.max(1))
}
/// An open document that isn't the active one: every per-document field of [`Session`], waiting
/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick
/// Parts, the custom dictionary, Read Aloud…) is not here.
pub struct DocState {
pub(super) doc: Document,
pub(super) sel: Selection,
pub(super) view: ViewState,
pub(super) pending: Option<CharProps>,
pub(super) path: Option<PathBuf>,
pub(super) file_name: Option<String>,
pub(super) dirty: bool,
pub(super) pending_move: Option<crate::cmd::moves::PendingMove>,
pub(super) find: FindState,
pub(super) goal_x: Option<f32>,
pub(super) page_hint: usize,
pub(super) new_header_parts: Vec<u32>,
pub(super) hf_return: Option<Selection>,
pub(super) history: Vec<Arc<Undo>>,
pub(super) undo_evicted: u64,
pub(super) redo: Vec<Arc<Undo>>,
pub(super) typing_open: bool,
pub(super) join_next: bool,
pub(super) equation_join_next: bool,
pub(super) ime_text_join: Option<Selection>,
pub(super) join_anchor: bool,
pub(super) autocap: Option<crate::cmd::tools::AutoCap>,
pub(super) rev: u64,
pub(super) document_id: u64,
pub(super) untitled: u32,
pub(super) made_rev: u64,
pub(super) ime_body_epoch: u64,
pub(super) cache: LayoutCache,
pub(super) layout: LayoutSlots,
pub(super) typing: Option<TypedWord>,
pub(super) originals: std::collections::HashMap<String, String>,
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
pub(super) versions: Vec<(String, String, Document)>,
pub(super) autosave: bool,
pub(super) bib_style: String,
pub(super) merge: crate::cmd::mailings::MergeState,
pub(super) math: Option<MathEdit>,
pub(super) password: Option<crate::io::Password>,
#[cfg(not(target_arch = "wasm32"))]
pub(super) recovery_slot: crate::recovery::Slot,
pub(super) also_selected: Vec<Pos>,
pub(super) column: Option<ColumnBlock>,
pub(super) column_mode: bool,
pub(super) widened: Option<Widened>,
pub(super) edit_marks: Vec<Pos>,
pub(super) go_back_step: usize,
}
impl DocState {
/// A fresh document state: caret at the start, no history, not dirty, a new id.
pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
DocState {
doc,
sel: Selection::caret(start),
view,
pending: None,
path: None,
file_name: None,
dirty: false,
pending_move: None,
find: FindState::default(),
goal_x: None,
page_hint: 0,
new_header_parts: Vec::new(),
hf_return: None,
history: Vec::new(),
undo_evicted: 0,
redo: Vec::new(),
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
join_anchor: false,
autocap: None,
rev: 1,
document_id: next_document_id(),
untitled,
made_rev: 1,
ime_body_epoch: 0,
cache: LayoutCache::new(),
layout: Default::default(),
typing: None,
originals: Default::default(),
picture_looks: Default::default(),
versions: Vec::new(),
autosave: true,
bib_style,
merge: Default::default(),
math: None,
password: None,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: Default::default(),
also_selected: Vec::new(),
column: None,
column_mode: false,
widened: None,
edit_marks: Vec::new(),
go_back_step: 0,
}
}
/// The document's id ([`Session::document_id`] while it is active).
pub fn id(&self) -> u64 {
self.document_id
}
fn info(&self) -> DocInfo {
DocInfo {
id: self.document_id,
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
path: self.path.clone(),
dirty: self.dirty,
active: false,
}
}
}
/// One open document, as [`Session::documents`] lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocInfo {
/// The document's id: unique in the process, kept while the document is open.
pub id: u64,
/// The name the title bar shows: the file's name without its extension, else the name given
/// with Rename, else the document's Title property, else `Document1`, `Document2`… (English;
/// a front end names untitled documents in its own language).
pub title: String,
pub path: Option<PathBuf>,
/// Changed since it was opened or last saved.
pub dirty: bool,
/// The active document, the one commands work on.
pub active: bool,
}
fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String {
match (path, file_name) {
(Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()),
(None, Some(name)) => name.to_string(),
(None, None) if !doc.core.title.is_empty() => doc.core.title.clone(),
(None, None) => untitled_name(untitled),
}
}
impl Session {
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
fn swap_doc(&mut self, d: &mut DocState) {
use std::mem::swap;
// Every field is listed, so a new one doesn't build until someone decides whether each
// open document has its own (then it goes in `DocState` and is swapped below) or the
// session shares it.
let Session {
// Per document.
doc: _,
sel: _,
view: _,
pending: _,
path: _,
file_name: _,
dirty: _,
pending_move: _,
find: _,
goal_x: _,
page_hint: _,
new_header_parts: _,
hf_return: _,
history: _,
undo_evicted: _,
redo: _,
typing_open: _,
join_next: _,
equation_join_next: _,
ime_text_join: _,
join_anchor: _,
autocap: _,
rev: _,
document_id: _,
untitled: _,
made_rev: _,
ime_body_epoch: _,
cache: _,
layout: _,
typing: _,
originals: _,
picture_looks: _,
versions: _,
autosave: _,
bib_style: _,
merge: _,
math: _,
password: _,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: _,
also_selected: _,
column: _,
column_mode: _,
widened: _,
edit_marks: _,
go_back_step: _,
// Shared by every open document.
user_dict_path: _,
clipboard: _,
clipboard_text: _,
clipboard_html: _,
clip_history: _,
registry: _,
author: _,
initials: _,
painter: _,
status: _,
last_command: _,
recording: _,
macros: _,
building_blocks: _,
ui_requests: _,
read_aloud: _,
prefs: _,
system_region: _,
keymap: _,
ribbon: _,
editing_lang: _,
math_latex: _,
math_normal_text: _,
last_highlight: _,
// Keyed by document id and revision, so one cache serves every document.
protect_facts: _,
// The input method talks to the window, whichever document it shows; the IME
// context is keyed by `document_id`.
ime_state_revision: _,
ime_state_seen: _,
ime_edit_outcome: _,
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
others: _,
order: _,
untitled_count: _,
#[cfg(not(target_arch = "wasm32"))]
recovery: _,
} = self;
let DocState {
doc,
sel,
view,
pending,
path,
file_name,
dirty,
pending_move,
find,
goal_x,
page_hint,
new_header_parts,
hf_return,
history,
undo_evicted,
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
autocap,
rev,
document_id,
untitled,
made_rev,
ime_body_epoch,
cache,
layout,
typing,
originals,
picture_looks,
versions,
autosave,
bib_style,
merge,
math,
password,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot,
also_selected,
column,
column_mode,
widened,
edit_marks,
go_back_step,
} = d;
swap(&mut self.doc, doc);
swap(&mut self.sel, sel);
swap(&mut self.view, view);
swap(&mut self.pending, pending);
swap(&mut self.path, path);
swap(&mut self.file_name, file_name);
swap(&mut self.dirty, dirty);
swap(&mut self.pending_move, pending_move);
swap(&mut self.find, find);
swap(&mut self.goal_x, goal_x);
swap(&mut self.page_hint, page_hint);
swap(&mut self.new_header_parts, new_header_parts);
swap(&mut self.hf_return, hf_return);
swap(&mut self.history, history);
swap(&mut self.undo_evicted, undo_evicted);
swap(&mut self.redo, redo);
swap(&mut self.typing_open, typing_open);
swap(&mut self.join_next, join_next);
swap(&mut self.equation_join_next, equation_join_next);
swap(&mut self.ime_text_join, ime_text_join);
swap(&mut self.join_anchor, join_anchor);
swap(&mut self.autocap, autocap);
swap(&mut self.rev, rev);
swap(&mut self.document_id, document_id);
swap(&mut self.untitled, untitled);
swap(&mut self.made_rev, made_rev);
swap(&mut self.ime_body_epoch, ime_body_epoch);
swap(&mut self.cache, cache);
swap(&mut self.layout, layout);
swap(&mut self.typing, typing);
swap(&mut self.originals, originals);
swap(&mut self.picture_looks, picture_looks);
swap(&mut self.versions, versions);
swap(&mut self.autosave, autosave);
swap(&mut self.bib_style, bib_style);
swap(&mut self.merge, merge);
swap(&mut self.math, math);
swap(&mut self.password, password);
#[cfg(not(target_arch = "wasm32"))]
swap(&mut self.recovery_slot, recovery_slot);
swap(&mut self.also_selected, also_selected);
swap(&mut self.column, column);
swap(&mut self.column_mode, column_mode);
swap(&mut self.widened, widened);
swap(&mut self.edit_marks, edit_marks);
swap(&mut self.go_back_step, go_back_step);
}
/// Make document `id` active without anything else a switch by the user does.
pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> {
if id == self.document_id {
return Ok(());
}
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
self.others.push(d);
Ok(())
}
/// Take parked document `id` out of the session; false when it isn't parked here.
fn remove_parked(&mut self, id: u64) -> bool {
let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false };
let d = self.others.swap_remove(at);
self.order.retain(|d| *d != id);
self.forget_recovery_copy(&d);
true
}
/// Whether document `id` is open in this session (active or not).
pub fn is_open(&self, id: u64) -> bool {
id == self.document_id || self.others.iter().any(|d| d.document_id == id)
}
/// Every open document in the order they were opened, the active one included.
pub fn documents(&self) -> Vec<DocInfo> {
self.order
.iter()
.filter_map(|&id| {
if id == self.document_id {
Some(DocInfo {
id,
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
path: self.path.clone(),
dirty: self.dirty,
active: true,
})
} else {
self.others.iter().find(|d| d.document_id == id).map(DocState::info)
}
})
.collect()
}
/// Make document `id` the active one, the one commands work on (nothing happens when it
/// already is). A Read Aloud reading of another document stops. An id that isn't open is an
/// error.
pub fn activate(&mut self, id: u64) -> Result<(), CmdError> {
self.switch_to(id)?;
self.stop_reading_elsewhere();
Ok(())
}
/// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud
/// reading of another document carries on: for a front end that shows each document in a
/// window of its own, and makes each window's document active to draw it. An id that isn't
/// open is an error.
pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> {
self.switch_to(id)
}
/// Open `doc` as another document and make it active; the one that was active stays open.
/// The new document starts as a replaced one does ([`Session::set_document`]): caret at the
/// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view
/// the active document had. Returns its id.
pub fn add_document(&mut self, doc: Document) -> u64 {
self.untitled_count = self.untitled_count.saturating_add(1);
self.add(doc, self.untitled_count)
}
/// [`Session::add_document`] for a document opened from a file: it doesn't use up an
/// untitled name.
pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 {
self.add(doc, 0)
}
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
let mut d = DocState::new(doc, untitled, view);
let id = d.document_id;
self.order.push(id);
self.swap_doc(&mut d);
self.others.push(d);
self.stop_reading_elsewhere();
id
}
/// A closed document's recovery copy goes with it, as its unsaved changes do.
fn forget_recovery_copy(&mut self, d: &DocState) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) {
r.close(id.clone());
}
#[cfg(target_arch = "wasm32")]
let _ = d;
}
/// What recovery reads from every open document, the active one first, with each
/// document's slot to update. Nothing is cloned or laid out.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn recovery_docs(&mut self) -> impl Iterator<Item = crate::recovery::DocRecovery<'_>> {
let active = crate::recovery::DocRecovery {
id: self.document_id,
wants_copy: self.dirty && self.password.is_none(),
rev: self.rev,
doc: &self.doc,
path: self.path.as_deref(),
slot: &mut self.recovery_slot,
};
std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery {
id: d.document_id,
wants_copy: d.dirty && d.password.is_none(),
rev: d.rev,
doc: &d.doc,
path: d.path.as_deref(),
slot: &mut d.recovery_slot,
}))
}
/// Every open document's recovery slot, with whether it should have a current copy and
/// its revision.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn recovery_slots(&self) -> impl Iterator<Item = (bool, u64, &crate::recovery::Slot)> {
std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot))
.chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot)))
}
/// Close document `id` without asking: its unsaved changes are lost (callers ask first),
/// and so is its recovery copy.
/// Closing the active document makes the one opened before it active (else the one after).
/// Closing the last open document leaves a new blank one, so a session always has an
/// active document; what a window does then is the front end's choice. An id that isn't
/// open is an error.
pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> {
if id != self.document_id {
if !self.remove_parked(id) {
return Err(unknown(id));
}
return Ok(());
}
let at = self.order.iter().position(|d| *d == id);
let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied();
match next {
Some(n) => {
self.switch_to(n)?;
self.remove_parked(id);
}
None => {
let mut doc = crate::cmd::file::normal_document(self);
doc.ensure_nonempty();
self.untitled_count = self.untitled_count.saturating_add(1);
let mut view = self.view.clone();
view.outline.collapsed.clear();
let mut fresh = DocState::new(doc, self.untitled_count, view);
self.order = vec![fresh.document_id];
self.swap_doc(&mut fresh);
self.forget_recovery_copy(&fresh);
}
}
self.stop_reading_elsewhere();
Ok(())
}
/// The active document stands in for no document at all (a Start screen's, or what is left
/// after closing the last one): an untouched blank document gives back its untitled name
/// when it is the newest, so the first document a person sees is `Document1`, the next
/// `Document2`.
pub fn release_untitled_name(&mut self) {
if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() {
self.untitled_count = self.untitled_count.saturating_sub(1);
self.untitled = 0;
}
}
/// Whether the active document is a blank one nobody has used: no file and no name, never
/// changed, nothing to undo or redo and no text. Opening a file replaces such a document
/// rather than opening beside it.
pub fn is_untouched_blank(&self) -> bool {
self.used_because().is_none()
}
/// Why the active document doesn't count as an untouched blank one, if it doesn't.
pub(crate) fn used_because(&self) -> Option<&'static str> {
if self.path.is_some() {
Some("it has a file")
} else if self.file_name.is_some() {
Some("it has a name")
} else if self.dirty {
Some("it has unsaved changes")
} else if !self.history.is_empty() || !self.redo.is_empty() {
Some("it has undo or redo steps")
} else if self.rev != self.made_rev {
Some("it changed since it was made")
} else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() {
Some("it has text")
} else {
None
}
}
/// Run `f` with document `id` active, then make the document that was active before active
/// again (unless `f` closed it). For work addressed to a document other than the one a
/// window shows: an agent's command, a result that arrives later. Read Aloud carries on.
/// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the
/// document that was active. An id that isn't open is an error and `f` doesn't run.
pub fn with_document<R>(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result<R, CmdError> {
let home = self.document_id;
self.switch_to(id)?;
// The queue drops its oldest entries when full, so `f`'s requests are told apart by
// collecting them on their own rather than by position.
let earlier = std::mem::take(&mut self.ui_requests);
let r = f(self);
let made = std::mem::replace(&mut self.ui_requests, earlier);
for mut q in made {
if id != home
&& let Some(o) = q.as_object_mut()
{
o.entry("document").or_insert(json!(id));
}
self.push_ui_request(q);
}
if self.document_id != home && self.is_open(home) {
self.switch_to(home)?;
}
Ok(r)
}
/// The open document saved at `path`, if any: the same file reached by another spelling
/// (`./a.docx`, a symbolic link) counts on desktop.
pub fn find_open(&self, path: &FsPath) -> Option<u64> {
let wanted = crate::io::canonical(path);
let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted;
self.order.iter().copied().find(|&id| {
if id == self.document_id {
self.path.as_deref().is_some_and(same)
} else {
self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same)
}
})
}
/// Stop a Read Aloud reading of a document that isn't the active one (it highlights its
/// sentences in the document shown).
fn stop_reading_elsewhere(&mut self) {
if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped {
self.read_aloud.stop();
}
}
/// Take the undo step `run` made for a command back out of the document it started in, when
/// the command ended up in another document (it opened or made one) and left the first
/// document's text as it was: opening a document is no edit to undo there.
pub(super) fn put_back_parked(&mut self, id: u64, before: Option<super::RunSnapshot>) {
let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return };
if !self.is_open(id) {
return;
}
let _ = self.with_document(id, |s| {
if s.doc != doc {
return;
}
s.sel = sel;
s.history = history;
s.redo = redo;
s.typing_open = typing_open;
s.undo_evicted = evicted;
s.column = column;
});
}
}
fn unknown(id: u64) -> CmdError {
CmdError::Params(format!("no open document has id {id}"))
}