mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 12:12:50 +00:00
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>
This commit is contained in:
co-authored by
vangel76
SlavenDj
Claude Opus 5.5
Codex
parent
28925fac8c
commit
8c57b0775c
+3
-1
File diff suppressed because one or more lines are too long
@@ -162,6 +162,8 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -90,8 +90,8 @@ pub use settings::{
|
||||
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
|
||||
THEME_COLORS, Watermark, Zoom, ZoomFit,
|
||||
};
|
||||
pub use text_walk::count_words;
|
||||
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
|
||||
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -19,6 +19,25 @@ fn word_count() {
|
||||
assert_eq!(count_words(" -- "), 0);
|
||||
}
|
||||
|
||||
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
|
||||
/// with spaces leave the paragraph breaks out, characters without spaces leave every
|
||||
/// whitespace character out.
|
||||
#[test]
|
||||
fn character_counts() {
|
||||
assert_eq!(count_chars(""), 0);
|
||||
assert_eq!(count_chars("Hello"), 5);
|
||||
// Paragraph breaks join the paragraphs but aren't characters.
|
||||
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
|
||||
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
|
||||
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
|
||||
assert_eq!(count_chars_no_spaces("Hello world"), 10);
|
||||
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
|
||||
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
|
||||
// Accented letters and emoji count as one character each, as they are.
|
||||
assert_eq!(count_chars("café 🚀"), 6);
|
||||
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
|
||||
}
|
||||
|
||||
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
|
||||
#[test]
|
||||
fn word_count_counts_each_chinese_and_japanese_character() {
|
||||
|
||||
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
|
||||
) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
|
||||
/// paragraph breaks aren't characters (#2797).
|
||||
pub fn count_chars(s: &str) -> usize {
|
||||
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
|
||||
}
|
||||
|
||||
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
|
||||
/// ("Characters (no spaces)"; #2797).
|
||||
pub fn count_chars_no_spaces(s: &str) -> usize {
|
||||
s.chars().filter(|c| !c.is_whitespace()).count()
|
||||
}
|
||||
|
||||
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
|
||||
pub(crate) const PARA_WALK_DEPTH: usize = 16;
|
||||
|
||||
|
||||
@@ -4,20 +4,16 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "includeTextBoxes") {
|
||||
s.prefs.count_notes = b;
|
||||
}
|
||||
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
let stories = s.counted_stories();
|
||||
// A selected picture, shape or text box isn't a text selection: count the document.
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
|
||||
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -268,6 +268,18 @@ impl Session {
|
||||
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,
|
||||
|
||||
@@ -54,7 +54,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -169,7 +169,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
|
||||
@@ -658,6 +658,17 @@ impl WordApp {
|
||||
self.read_system_animations();
|
||||
json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations})
|
||||
}
|
||||
"ui.statusCount" => {
|
||||
// `words` (the default), `characters` or `charactersNoSpaces`: what the status
|
||||
// bar's live count shows; no value reads it (#2797).
|
||||
if let Some(v) = s("value") {
|
||||
match crate::StatusCount::from_code(v) {
|
||||
Some(c) => self.ui.status_count = c,
|
||||
None => return Some(Err(format!("unknown status count {v:?}; use words, characters or charactersNoSpaces"))),
|
||||
}
|
||||
}
|
||||
json!({"statusCount": self.ui.status_count.code()})
|
||||
}
|
||||
"ui.language" => {
|
||||
// `auto` (follow the system) or a language code; anything else is an error.
|
||||
if let Some(v) = s("value") {
|
||||
@@ -1049,6 +1060,7 @@ pub(crate) fn works_without_document(id: &str) -> bool {
|
||||
| "ui.caretAnimation"
|
||||
| "ui.startScreen"
|
||||
| "ui.displayServer"
|
||||
| "ui.statusCount"
|
||||
| "ui.checkForUpdates"
|
||||
) || id.starts_with("help.")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::json;
|
||||
use wordcraft_doc::{PartKind, StoryRef};
|
||||
|
||||
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
|
||||
use crate::{WordApp, icons};
|
||||
use crate::{StatusCount, StatusCounts, WordApp, icons};
|
||||
|
||||
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
|
||||
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
|
||||
@@ -627,15 +627,29 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
// The live count: words (the default, as Word's status bar counts), characters
|
||||
// with spaces, or characters without spaces — whichever the person chose (#2797).
|
||||
let unit = app.ui.status_count;
|
||||
let counts = app.cached_status_counts();
|
||||
// A selected picture/shape/text box isn't a text selection: show the total.
|
||||
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
|
||||
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
|
||||
status_count_text(unit, counts.in_unit(unit), None)
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
|
||||
let selected = crate::count_in_unit(&app.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let counted = st(ui, &wtxt);
|
||||
// Right-click, as Word's status bar offers: choose what the count shows (#2797).
|
||||
counted.context_menu(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
for c in crate::StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if counted.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
|
||||
@@ -903,14 +917,43 @@ impl WordApp {
|
||||
wordcraft_engine::cmd::font::warns(font, &shown).then_some(shown)
|
||||
}
|
||||
|
||||
/// Word count, recomputed only when the document changes.
|
||||
pub fn cached_word_count(&mut self) -> usize {
|
||||
// The count option changes the count without a document change.
|
||||
/// The document's counts, recomputed only when the document (or the count option) changes:
|
||||
/// words as the status bar and the Word Count dialog count them, and characters with and
|
||||
/// without spaces as the dialog does (#2797).
|
||||
pub fn cached_status_counts(&mut self) -> StatusCounts {
|
||||
// The count option changes the counts without a document change.
|
||||
let key = self.session.rev().wrapping_mul(2) | u64::from(self.session.prefs.count_notes);
|
||||
if self.word_count.0 != key {
|
||||
self.word_count = (key, self.session.word_count());
|
||||
if self.status_counts.0 != key {
|
||||
let text = self.session.counted_text();
|
||||
self.status_counts = (
|
||||
key,
|
||||
StatusCounts {
|
||||
words: self.session.word_count(),
|
||||
chars: wordcraft_doc::count_chars(&text),
|
||||
chars_no_spaces: wordcraft_doc::count_chars_no_spaces(&text),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.status_counts.1
|
||||
}
|
||||
}
|
||||
|
||||
/// The status bar's count line (#2797): the whole document's `total`, or "selected of total" when
|
||||
/// part of it is selected, worded as the Word Count dialog words the same counts.
|
||||
pub(crate) fn status_count_text(unit: StatusCount, total: usize, selected: Option<usize>) -> String {
|
||||
match (unit, selected) {
|
||||
(StatusCount::Words, None) => crate::i18n::fmt(tl!("{words} words"), &[("words", &total.to_string())]),
|
||||
(StatusCount::Words, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &n.to_string()), ("words", &total.to_string())])
|
||||
}
|
||||
(StatusCount::Characters, None) => crate::i18n::fmt(tl!("{chars} characters"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::Characters, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
}
|
||||
(StatusCount::CharactersNoSpaces, None) => crate::i18n::fmt(tl!("{chars} characters (no spaces)"), &[("chars", &total.to_string())]),
|
||||
(StatusCount::CharactersNoSpaces, Some(n)) => {
|
||||
crate::i18n::fmt(tl!("{selected} of {chars} characters (no spaces)"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
|
||||
}
|
||||
self.word_count.1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,113 @@ fn status_bar_controls_hold_still_across_zoom_levels() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the count line's wording per unit — words (the default), characters with spaces,
|
||||
/// characters without spaces — whole document or "selected of total".
|
||||
#[test]
|
||||
fn the_count_line_words_each_unit() {
|
||||
for (unit, total, selected, expected) in [
|
||||
(StatusCount::Words, 12, None, "12 words"),
|
||||
(StatusCount::Words, 12, Some(3), "3 of 12 words"),
|
||||
(StatusCount::Characters, 23, None, "23 characters"),
|
||||
(StatusCount::Characters, 23, Some(6), "6 of 23 characters"),
|
||||
(StatusCount::CharactersNoSpaces, 21, None, "21 characters (no spaces)"),
|
||||
(StatusCount::CharactersNoSpaces, 21, Some(6), "6 of 21 characters (no spaces)"),
|
||||
] {
|
||||
assert_eq!(status_count_text(unit, total, selected), expected, "{unit:?} with {selected:?} selected");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2797: the status bar's totals are the Word Count dialog's numbers (words, characters with
|
||||
/// spaces, characters without spaces), and its selection line counts the selection the same way.
|
||||
#[test]
|
||||
fn the_status_bar_totals_are_the_word_count_dialogs_numbers() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
// Straight from the engine: `a.run` would only open the Word Count dialog (#240).
|
||||
let stats = a.session.run("review.wordCount", &json!({})).unwrap();
|
||||
let counts = a.cached_status_counts();
|
||||
assert_eq!(counts.words, stats["words"].as_u64().unwrap_or_default() as usize, "words, as the dialog counts them");
|
||||
assert_eq!(counts.chars, stats["charactersWithSpaces"].as_u64().unwrap_or_default() as usize, "characters with spaces");
|
||||
assert_eq!(counts.chars_no_spaces, stats["characters"].as_u64().unwrap_or_default() as usize, "characters without spaces");
|
||||
// "world." selected: its own count of the chosen unit over the document's total.
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
let line = |unit: StatusCount| {
|
||||
let selected = crate::count_in_unit(&a.session.selected_text(), unit);
|
||||
status_count_text(unit, counts.in_unit(unit), Some(selected))
|
||||
};
|
||||
assert_eq!(line(StatusCount::Words), "1 of 4 words");
|
||||
assert_eq!(line(StatusCount::Characters), "6 of 23 characters");
|
||||
assert_eq!(line(StatusCount::CharactersNoSpaces), "6 of 21 characters (no spaces)");
|
||||
}
|
||||
|
||||
/// #2797: the status bar draws the unit the person chose, and right-clicking the count offers
|
||||
/// the three units, the choice taking effect at once and kept between runs.
|
||||
#[test]
|
||||
fn the_status_bar_count_is_chosen_from_its_context_menu() {
|
||||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
|
||||
let ctx = ctx();
|
||||
ctx.enable_accesskit();
|
||||
let mut n = 0;
|
||||
// What the status bar drew last frame: each widget's label and where it sits.
|
||||
let drawn = |a: &mut WordApp, n: &mut u32, events: Vec<egui::Event>| -> Vec<(String, egui::Rect)> {
|
||||
*n += 1;
|
||||
let input = egui::RawInput {
|
||||
time: Some(f64::from(*n) / 60.0),
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| status_bar(a, ui));
|
||||
out.textures_delta.clear();
|
||||
let update = out.platform_output.accesskit_update.take().unwrap();
|
||||
update
|
||||
.nodes
|
||||
.into_iter()
|
||||
.filter_map(|(_, node)| {
|
||||
let b = node.bounds()?;
|
||||
Some((node.label()?.to_string(), egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let at =
|
||||
|seen: &[(String, egui::Rect)], label: &str| seen.iter().find(|(l, _)| l == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
|
||||
let button = |at: egui::Pos2, button: egui::PointerButton, pressed: bool| egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button,
|
||||
pressed,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
// Words are counted: the default unit.
|
||||
let mut seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "4 words"), "{seen:?}");
|
||||
// Right-click the count: the menu lists the three units.
|
||||
let count = at(&seen, "4 words");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(count), button(count, egui::PointerButton::Secondary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(count, egui::PointerButton::Secondary, false)]);
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
for label in ["Words", "Characters (with spaces)", "Characters (no spaces)"] {
|
||||
assert!(seen.iter().any(|(l, _)| l == label), "the menu lists {label}: {seen:?}");
|
||||
}
|
||||
// Pick characters with spaces: the count changes at once, with no document change.
|
||||
let pick = at(&seen, "Characters (with spaces)");
|
||||
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(pick), button(pick, egui::PointerButton::Primary, true)]);
|
||||
drawn(&mut a, &mut n, vec![button(pick, egui::PointerButton::Primary, false)]);
|
||||
assert_eq!(a.ui.status_count, StatusCount::Characters, "the menu's choice is taken");
|
||||
assert_eq!(a.run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "and answered to scripts");
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "23 characters"), "{seen:?}");
|
||||
// With a selection: "selected of total".
|
||||
a.run("select.text", json!({"text": "world."})).unwrap();
|
||||
seen = drawn(&mut a, &mut n, vec![]);
|
||||
assert!(seen.iter().any(|(l, _)| l == "6 of 23 characters"), "{seen:?}");
|
||||
// The choice comes back after a restart.
|
||||
let mut b = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
b.apply_prefs(crate::UiState::from_json(&saved).unwrap());
|
||||
assert_eq!(b.ui.status_count, StatusCount::Characters, "kept between runs");
|
||||
}
|
||||
|
||||
/// #2402: a long message (the path of a file that isn't there) is shortened so that it stops
|
||||
/// before the controls on the right, instead of being drawn over the Focus button.
|
||||
#[test]
|
||||
|
||||
@@ -75,7 +75,7 @@ impl WordApp {
|
||||
self.read_aloud_at = None;
|
||||
self.info_editing = None;
|
||||
// Caches keyed by the revision alone: another document's revision can be the same number.
|
||||
self.word_count = crate::window_ui::NOT_COUNTED;
|
||||
self.status_counts = crate::window_ui::NOT_COUNTED;
|
||||
self.find_fonts_for = None;
|
||||
self.previews.document_changed();
|
||||
// The Start screen's placeholder (#1129) goes once a document is open beside it.
|
||||
|
||||
@@ -178,6 +178,47 @@ impl DisplayServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (the default, as Word's status bar
|
||||
/// counts), characters with spaces, or characters without spaces — the same three the Word Count
|
||||
/// dialog reports, worded the same way. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display, to choose; kept between runs in `ui.json`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "String", into = "String")]
|
||||
pub enum StatusCount {
|
||||
#[default]
|
||||
Words,
|
||||
Characters,
|
||||
CharactersNoSpaces,
|
||||
}
|
||||
|
||||
impl StatusCount {
|
||||
/// In menu order.
|
||||
pub const ALL: [StatusCount; 3] = [StatusCount::Words, StatusCount::Characters, StatusCount::CharactersNoSpaces];
|
||||
|
||||
/// The value used in `ui.json` and by the `ui.statusCount` command.
|
||||
pub fn code(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "words",
|
||||
StatusCount::Characters => "characters",
|
||||
StatusCount::CharactersNoSpaces => "charactersNoSpaces",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_code(code: &str) -> Option<StatusCount> {
|
||||
StatusCount::ALL.into_iter().find(|c| c.code().eq_ignore_ascii_case(code.trim()))
|
||||
}
|
||||
|
||||
/// The English label (translated with `tl!` where shown): the Word Count dialog's own words
|
||||
/// for these counts.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
StatusCount::Words => "Words",
|
||||
StatusCount::Characters => "Characters (with spaces)",
|
||||
StatusCount::CharactersNoSpaces => "Characters (no spaces)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for DisplayServer {
|
||||
fn from(s: String) -> Self {
|
||||
DisplayServer::from_code(&s).unwrap_or_default()
|
||||
@@ -190,6 +231,49 @@ impl From<DisplayServer> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
|
||||
impl From<String> for StatusCount {
|
||||
fn from(s: String) -> Self {
|
||||
StatusCount::from_code(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StatusCount> for String {
|
||||
fn from(c: StatusCount) -> Self {
|
||||
c.code().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-document counts the status bar shows (#2797), computed together whenever the document
|
||||
/// changes: words ([`wordcraft_engine::Session::word_count`]), characters and characters without
|
||||
/// spaces (the Word Count dialog's [`wordcraft_doc::count_chars`] and [`wordcraft_doc::count_chars_no_spaces`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StatusCounts {
|
||||
pub words: usize,
|
||||
pub chars: usize,
|
||||
pub chars_no_spaces: usize,
|
||||
}
|
||||
|
||||
impl StatusCounts {
|
||||
/// The total in the unit the status bar is showing.
|
||||
pub fn in_unit(self, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => self.words,
|
||||
StatusCount::Characters => self.chars,
|
||||
StatusCount::CharactersNoSpaces => self.chars_no_spaces,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` (a selection, say) counted in the unit the status bar is showing.
|
||||
pub fn count_in_unit(s: &str, unit: StatusCount) -> usize {
|
||||
match unit {
|
||||
StatusCount::Words => wordcraft_doc::count_words(s),
|
||||
StatusCount::Characters => wordcraft_doc::count_chars(s),
|
||||
StatusCount::CharactersNoSpaces => wordcraft_doc::count_chars_no_spaces(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// UI state that persists between runs (and that agents can read and set).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -338,6 +422,10 @@ pub struct UiState {
|
||||
/// File › Options › Advanced › Linux display server (#2184): read by the desktop app before
|
||||
/// the window opens.
|
||||
pub display_server: DisplayServer,
|
||||
/// What the status bar's live count shows (#2797): words (the default), characters with
|
||||
/// spaces, or characters without spaces. Right-click the count in the status bar, or File ›
|
||||
/// Options › Display. Junk saved values read as words.
|
||||
pub status_count: StatusCount,
|
||||
}
|
||||
|
||||
/// The ribbon tab the app opens on, and the one it falls back to when the tab that was showing
|
||||
@@ -414,6 +502,7 @@ impl Default for UiState {
|
||||
update_fields_before_print: false,
|
||||
dictionary: Vec::new(),
|
||||
display_server: DisplayServer::Auto,
|
||||
status_count: StatusCount::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -643,7 +732,8 @@ pub struct WordApp {
|
||||
/// The document's fonts that aren't installed, for the status bar and the font box, and the
|
||||
/// layout they were read from.
|
||||
pub missing_fonts: crate::chrome::MissingFontsMemo,
|
||||
pub word_count: (u64, usize),
|
||||
/// The document's [`StatusCounts`] and the key they were computed for; see [`WordApp::cached_status_counts`].
|
||||
pub status_counts: (u64, StatusCounts),
|
||||
last_autosave: f64,
|
||||
/// The tab shown before the Equation tab came up (restored when editing ends).
|
||||
pub(crate) equation_prev_tab: Option<String>,
|
||||
|
||||
@@ -313,6 +313,9 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
|
||||
}
|
||||
numeral_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Status bar");
|
||||
status_count_picker(app, ui);
|
||||
ui.add_space(8.0);
|
||||
section(ui, "Printing");
|
||||
ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color"));
|
||||
ui.checkbox(&mut app.ui.update_fields_before_print, tl!("Update fields before printing"));
|
||||
@@ -723,6 +726,25 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What the status bar's live count shows (#2797): words (as Word's status bar counts), characters
|
||||
/// with spaces, or characters without spaces. Right-clicking the count in the status bar offers
|
||||
/// the same choices.
|
||||
fn status_count_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
use crate::StatusCount;
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Status bar count:"));
|
||||
let combo = craft_ui::ComboBox::from_id_salt("status_count").selected_text(tl!(app.ui.status_count.label())).width(220.0).show_ui(ui, |ui| {
|
||||
for c in StatusCount::ALL {
|
||||
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
|
||||
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
|
||||
}
|
||||
}
|
||||
});
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
note(ui, "You can also right-click the count in the status bar to change it.");
|
||||
}
|
||||
|
||||
/// Interface size (#475, #822): Auto, which keeps the system's display scale and shows the size it
|
||||
/// draws at, or an absolute size, so "100%" means one screen pixel per point.
|
||||
fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use wordcraft_geom::Unit;
|
||||
use super::{decimal, length, parse_length, unit};
|
||||
|
||||
use crate::theme::Appearance;
|
||||
use crate::{Services, UiState, WordApp};
|
||||
use crate::{Services, StatusCount, UiState, WordApp};
|
||||
|
||||
#[test]
|
||||
fn unknown_settings_keys_load_in_linear_time() {
|
||||
@@ -155,6 +155,7 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
.unwrap();
|
||||
a.ui.recent_count = 30;
|
||||
a.ui.print_backgrounds = true;
|
||||
a.ui.status_count = StatusCount::CharactersNoSpaces;
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
let mut b = app();
|
||||
b.apply_prefs(UiState::from_json(&saved).unwrap());
|
||||
@@ -162,13 +163,16 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
|
||||
assert_eq!(b.session.prefs.units, Some(Unit::Centimeters));
|
||||
assert_eq!(b.session.initials, "VK");
|
||||
assert_eq!((b.ui.recent_count, b.ui.print_backgrounds), (30, true));
|
||||
assert_eq!(b.ui.status_count, StatusCount::CharactersNoSpaces, "#2797: what the status bar counts");
|
||||
assert!(wordcraft_engine::proof::user_dictionary().contains(&"zqxoptionsword".to_string()));
|
||||
|
||||
let junk = br#"{"theme": "dark", "recentCount": "lots", "printBackgrounds": 7, "optionsPane": ["x"], "dark": true,
|
||||
"statusCount": "paragraphs",
|
||||
"editing": {"measurementUnits": "furlongs", "typingReplacesSelection": false, "marks": {"tabs": "yes", "spaces": true}}}"#;
|
||||
let ui = UiState::from_json(junk).unwrap();
|
||||
assert_eq!(ui.theme, Appearance::Dark);
|
||||
assert_eq!((ui.recent_count, ui.print_backgrounds, ui.options_pane.as_str()), (12, false, "general"));
|
||||
assert_eq!(ui.status_count, StatusCount::Words, "#2797: a wrong value reads the default");
|
||||
assert_eq!(ui.legacy_dark, Some(true));
|
||||
assert_eq!(ui.editing.units, None);
|
||||
assert!(!ui.editing.typing_replaces_selection);
|
||||
@@ -246,6 +250,14 @@ fn options_panes_open_and_change_settings() {
|
||||
h.get_by_label("Spaces").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.prefs.marks.spaces, "Display › Spaces");
|
||||
// #2797: what the status bar's count shows, chosen here as in its right-click menu. The
|
||||
// Display pane's other combo box is Numerals, drawn above this one.
|
||||
h.get_all_by_role(egui::accesskit::Role::ComboBox).last().expect("the status bar count picker").click();
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Characters (with spaces)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.status_count, StatusCount::Characters, "Display › Status bar count");
|
||||
assert_eq!(h.state_mut().run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "the command answers the same");
|
||||
h.get_by_label("Proofing").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().ui.options_pane, "proofing");
|
||||
|
||||
@@ -692,7 +692,7 @@ fn start_screen_shows_file_home_and_esc_leaves_no_document() {
|
||||
for id in ["format.bold", "text.insert", "file.save", "file.saveAs", "file.print", "edit.paste", "insert.table", "ui.print"] {
|
||||
assert!(!widgets::enabled(a, id), "{id} is off with no document");
|
||||
}
|
||||
for id in ["file.new", "file.open", "ui.backstage", "ui.tab", "ui.discord", "file.options", "ui.theme"] {
|
||||
for id in ["file.new", "file.open", "ui.backstage", "ui.tab", "ui.discord", "file.options", "ui.theme", "ui.statusCount"] {
|
||||
assert!(widgets::enabled(a, id), "{id} works with no document");
|
||||
}
|
||||
assert!(!chrome::qat_enabled(a, "file.save"));
|
||||
@@ -5529,6 +5529,29 @@ fn interface_theme_resolves_against_the_os_appearance() {
|
||||
assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light");
|
||||
}
|
||||
|
||||
/// #2797: `ui.statusCount` reads and sets what the status bar's live count shows; a value it
|
||||
/// doesn't know is refused, and the choice is written to `ui.json` and read back.
|
||||
#[test]
|
||||
fn the_status_bar_count_is_a_ui_command() {
|
||||
let mut a = app();
|
||||
assert_eq!(a.run("ui.statusCount", json!({})).unwrap(), json!({"statusCount": "words"}), "words, the default");
|
||||
assert!(a.run("ui.statusCount", json!({"value": "paragraphs"})).is_err(), "junk is refused");
|
||||
// A value that isn't a string reads the setting, like `ui.theme`'s.
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": 7})).unwrap(), json!({"statusCount": "words"}));
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": "characters"})).unwrap()["statusCount"], "characters");
|
||||
assert_eq!(a.ui.status_count, StatusCount::Characters);
|
||||
// Like the theme setting, the code is read without regard to case or surrounding space.
|
||||
assert_eq!(a.run("ui.statusCount", json!({"value": " CharactersNoSpaces "})).unwrap()["statusCount"], "charactersNoSpaces");
|
||||
assert_eq!(a.ui.status_count, StatusCount::CharactersNoSpaces);
|
||||
let saved = serde_json::to_vec(&a.prefs()).unwrap();
|
||||
assert_eq!(UiState::from_json(&saved).unwrap().status_count, StatusCount::CharactersNoSpaces, "kept between runs");
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": "words"}"#).unwrap().status_count, StatusCount::Words);
|
||||
// A `ui.json` with a wrong value in this one setting keeps the rest and reads the default.
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": "paragraphs"}"#).unwrap().status_count, StatusCount::Words);
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": 7}"#).unwrap().status_count, StatusCount::Words);
|
||||
assert_eq!(UiState::from_json(br#"{"statusCount": ["characters"]}"#).unwrap().status_count, StatusCount::Words);
|
||||
}
|
||||
|
||||
/// One app frame with the OS appearance egui reports; returns the window commands it sent.
|
||||
fn theme_frame(ctx: &egui::Context, a: &mut WordApp, os: Option<egui::Theme>) -> Vec<egui::ViewportCommand> {
|
||||
let input = egui::RawInput { system_theme: os, ..Default::default() };
|
||||
|
||||
@@ -38,7 +38,7 @@ pub(crate) struct WindowUi {
|
||||
pub(crate) sent_full_screen: bool,
|
||||
pub(crate) seen_full_screen: Option<bool>,
|
||||
pub(crate) escape_free: bool,
|
||||
pub(crate) word_count: (u64, usize),
|
||||
pub(crate) status_counts: (u64, crate::StatusCounts),
|
||||
/// Per window, as each window shows its own layout: one shared list would be read again
|
||||
/// on every frame of every window.
|
||||
pub(crate) missing_fonts: crate::chrome::MissingFontsMemo,
|
||||
@@ -69,8 +69,8 @@ pub(crate) struct WindowUi {
|
||||
pub(crate) alt_modifier_seen: bool,
|
||||
}
|
||||
|
||||
/// A word count that matches no document revision, so the next frame counts.
|
||||
pub(crate) const NOT_COUNTED: (u64, usize) = (u64::MAX, 0);
|
||||
/// Status-bar counts that match no document revision, so the next frame counts.
|
||||
pub(crate) const NOT_COUNTED: (u64, crate::StatusCounts) = (u64::MAX, crate::StatusCounts { words: 0, chars: 0, chars_no_spaces: 0 });
|
||||
|
||||
impl WindowUi {
|
||||
/// A new window showing open document `document`: nothing open over the page, Home tab.
|
||||
@@ -111,7 +111,7 @@ impl WindowUi {
|
||||
sent_full_screen: false,
|
||||
seen_full_screen: None,
|
||||
escape_free: false,
|
||||
word_count: NOT_COUNTED,
|
||||
status_counts: NOT_COUNTED,
|
||||
missing_fonts: Default::default(),
|
||||
last_autosave: 0.0,
|
||||
equation_prev_tab: None,
|
||||
@@ -238,7 +238,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
@@ -270,6 +270,7 @@ impl WordApp {
|
||||
legacy_dark: _,
|
||||
nav_tab: _,
|
||||
display_server: _,
|
||||
status_count: _,
|
||||
nav_pane: _,
|
||||
show_discord: _,
|
||||
author: _,
|
||||
@@ -337,7 +338,7 @@ impl WordApp {
|
||||
sent_full_screen,
|
||||
seen_full_screen,
|
||||
escape_free,
|
||||
word_count,
|
||||
status_counts,
|
||||
missing_fonts,
|
||||
last_autosave,
|
||||
equation_prev_tab,
|
||||
|
||||
@@ -55,6 +55,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user