Files
wordcraft/crates/engine/src/session/documents.rs
T
aiongg f2c21cd62c W6: View: New Window opens another window on the document
Each window on a document keeps its own view (selection, zoom, view
mode, panes) as an engine WindowView, moved in and out of the session as
the windows draw and clamped to edits made through another window. The
document, undo history and save state are shared. Two windows on one
document are titled Name:1 and Name:2; closing one of them asks nothing,
the last asks, and quitting asks once per document. Switch Windows lists
windows. The layout memo keeps two layouts and keys on every view option,
so windows with different views don't lay out again each frame.
2026-10-22 17:30:33 +00:00

610 lines
23 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) 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) 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,
autocap: None,
rev: 1,
document_id: next_document_id(),
untitled,
made_rev: 1,
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: _,
autocap: _,
rev: _,
document_id: _,
untitled: _,
made_rev: _,
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: _,
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,
autocap,
rev,
document_id,
untitled,
made_rev,
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.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.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.path.is_none()
&& self.file_name.is_none()
&& !self.dirty
&& self.history.is_empty()
&& self.redo.is_empty()
&& self.rev == self.made_rev
&& self.doc.paragraph_count() <= 1
&& self.doc.plain_text(StoryRef::Body).trim().is_empty()
}
/// 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)?;
let queued = self.ui_requests.len();
let r = f(self);
if id != home {
for q in self.ui_requests.iter_mut().skip(queued) {
if let Some(o) = q.as_object_mut() {
o.entry("document").or_insert(json!(id));
}
}
}
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}"))
}