Files
wordcraft/crates/engine/src/session/query.rs
T
8c57b0775c Status bar: the live count shows words, characters, or characters without spaces (#3187)
Lands #3187.

Co-Authored-By: vangel76 <271991477+vangel76@users.noreply.github.com>
Co-Authored-By: SlavenDj <53652906+SlavenDj@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-06 00:27:28 +01:00

303 lines
14 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.
use super::doc_state::{CellRange, EDIT_MARKS, Selection};
use super::{CharProps, Document, PartKind, Path, Pos, Session, StoryRef, ViewMode, untitled_name};
impl Session {
/// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as
/// the last one (typing on) update that place rather than adding one.
pub(super) fn note_edit(&mut self) {
let at = self.sel.focus.clone();
match self.edit_marks.last_mut() {
Some(last) if last.story == at.story && last.path == at.path => *last = at,
_ => {
self.edit_marks.push(at);
if self.edit_marks.len() > EDIT_MARKS {
self.edit_marks.remove(0);
}
}
}
self.go_back_step = 0;
}
/// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place
/// the caret is already at is skipped. `None` before any edit.
pub fn go_back(&mut self) -> Option<Pos> {
let n = self.edit_marks.len();
for _ in 0..n {
let i = n - 1 - self.go_back_step % n;
self.go_back_step = self.go_back_step.wrapping_add(1);
let Some(p) = self.edit_marks.get(i) else { continue };
let p = self.doc.clamp(p);
if p != self.sel.focus || n == 1 {
return Some(p);
}
}
self.edit_marks.last().map(|p| self.doc.clamp(p))
}
/// The line pieces of the current column selection, if there is one (the selection hasn't
/// moved since the block was made).
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
// Cells that are one run of the text are an ordinary stream selection.
self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice())
}
/// The whole table cells selected, if the selection is that (see [`CellRange`]).
pub fn cell_range(&self) -> Option<&CellRange> {
self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref())
}
/// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where
/// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds
/// those).
pub fn sel_ends(&self) -> &Selection {
self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends)
}
pub fn selects_whole_story(&self) -> bool {
let st = self.sel.focus.story;
let (a, b) = self.sel.ordered();
a == self.doc.start_of(st) && b == self.doc.end_of(st)
}
/// Whether the caret is in a header or footer.
pub fn in_header_footer(&self) -> bool {
let StoryRef::Part(id) = self.sel.focus.story else { return false };
self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer))
}
/// Document revision (bumped by every change).
pub fn rev(&self) -> u64 {
self.rev
}
/// Which document is open (see `document_id`): lets a caller tell an edit from a replacement.
pub fn document_id(&self) -> u64 {
self.document_id
}
/// How many times the body was replaced under this [`Self::document_id`].
///
/// Front ends use it as the body's IME replacement epoch and to tell a body swap from an
/// ordinary edit; it advances for `document.setText`, a version restore, a compare and
/// Undo/Redo, never for typing.
pub fn ime_body_epoch(&self) -> u64 {
self.ime_body_epoch
}
/// Note that a command replaced the document or its whole body under this document id.
///
/// The counter fails closed: if it were ever exhausted (unreachable in practice), the
/// document owner id advances too, so a cached IME context still cannot be reused.
pub fn note_body_replaced(&mut self) {
self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) {
Some(epoch) => epoch,
None => {
self.document_id = self.document_id.saturating_add(1);
u64::MAX
}
};
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
}
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
if !self.find.highlight {
return &[];
}
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
self.find.highlights = Some((self.rev, found));
}
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
}
/// Whether the markup area beside the page can show balloons: markup shown in Print Layout
/// (not Read Mode or pages side by side), comments not listed in the Comments pane instead,
/// and Track Changes Options not set to show every revision inline.
pub fn balloons_shown(&self) -> bool {
let v = &self.view;
v.show_markup
&& !v.comments_pane
&& !v.read_mode
&& !v.multi_page
&& v.mode == ViewMode::Print
&& self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline
}
/// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave
/// the text for balloons in the markup area. Where no balloons can be shown they stay inline,
/// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons.
pub fn revisions_in_balloons(&self) -> bool {
let m = &self.prefs.markup;
self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions
}
/// Initials for new comments: the ones set in File › Options, else the user name's.
pub fn user_initials(&self) -> String {
let set = self.initials.trim();
if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() }
}
/// The document's file name and full path for fields (`FILENAME`) and layout context.
/// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default
/// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used
/// as a filename fallback (#3449).
pub fn resolved_filenames(&self) -> (String, String) {
if let Some(path) = &self.path {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string());
let full = path.to_string_lossy().to_string();
(name, full)
} else if let Some(name) = &self.file_name {
(name.clone(), name.clone())
} else {
let name = untitled_name(self.untitled);
(name.clone(), name)
}
}
/// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the
/// system's.
pub fn region(&self) -> wordcraft_geom::Region {
match self.prefs.region.as_deref() {
Some(tag) => wordcraft_geom::Region::from_tag(tag),
None => self.system_region.clone(),
}
}
/// The English of the region in effect (`en-GB` for a British region), or `None` when the
/// region's language isn't English, it names no territory, or it is the United States (US
/// English is the default anyway). An English interface writes new documents in it, as Word
/// does on a British or Canadian system (#1045).
pub fn region_english(&self) -> Option<String> {
let r = self.region();
(r.language == "en" && !r.territory.is_empty() && r.territory != "US")
.then(|| format!("en-{}", r.territory))
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
}
/// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan
/// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's
/// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the
/// writer's Spanish from the first word (#3360).
pub fn region_spanish(&self) -> String {
let r = self.region();
(matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty())
.then(|| format!("es-{}", r.territory))
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
.unwrap_or_else(|| "es-ES".into())
}
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
pub fn units(&self) -> wordcraft_geom::Unit {
self.prefs.units.unwrap_or_else(|| self.region().units())
}
/// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland).
pub fn format_length(&self, pt: f32) -> String {
self.region().format(self.units(), pt)
}
/// A new blank document (the Normal template) on the region's paper: Letter in the US and
/// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round.
pub fn blank_document(&self) -> Document {
blank_document(&self.region())
}
/// The paragraph path the caret is in.
pub fn caret_path(&self) -> &Path {
&self.sel.focus.path
}
pub fn story(&self) -> StoryRef {
self.sel.focus.story
}
/// Formatting typing would use at the caret: the caret's, except run direction. New text
/// takes its direction from its own letters, as on screen, so Latin typed or inserted into
/// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces
/// itself (#1543). The bidi language stays (it only applies to complex-script text).
/// Over a selection, as in Word, typing takes the first selected character's formatting,
/// whichever way the selection was made (#2879).
pub fn typing_props(&self) -> CharProps {
let (a, _) = self.sel.ordered();
let first = match self.doc.para_at(&a) {
Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)),
_ => None,
};
let mut c = match first {
Some(props) => {
// The selection is replaced, so the new text isn't part of a link or a note number.
let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() };
if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) {
out.style = None;
}
out
}
None => self.caret_props(),
};
c.rtl = None;
c
}
/// The caret's formatting, as Reveal Formatting shows it.
pub fn caret_props(&self) -> CharProps {
if let Some(p) = &self.pending {
return p.clone();
}
let f = &self.sel.focus;
let Some(p) = self.doc.para_at(f) else { return CharProps::default() };
let props = p.props_at(f.off);
// As in Word, text typed at either end of a hyperlink isn't part of it; only typing
// inside the link extends it.
let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link;
// Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`).
let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol();
// A footnote or endnote number is raised by its character style; text typed after it is body text.
if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) {
out.style = None;
}
out
}
/// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]).
pub fn word_count(&self) -> usize {
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
}
/// The stories the Word Count dialog and the status bar count: the body plus notes and text
/// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]).
pub fn counted_stories(&self) -> Vec<StoryRef> {
if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] }
}
/// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`]
/// names, joined with paragraph breaks, as the Word Count dialog counts them.
pub fn counted_text(&self) -> String {
self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
}
/// Selected plain text.
pub fn selected_text(&self) -> String {
// A column block or whole cells that aren't one run of the text: each piece's own text,
// a line each, so the status bar and Word Count count those cells only.
if let Some(segs) = self.column_segments() {
return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::<Vec<_>>().join("\n");
}
let (a, b) = self.sel.ordered();
self.doc.copy_range(&a, &b).plain_text()
}
}
/// A new blank document on `region`'s paper (see [`Session::blank_document`]).
pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
let mut d = Document::new();
let (_, w, h) = region.paper();
d.last_section.page_w = w;
d.last_section.page_h = h;
d
}