refactor(ui-egui): split app.rs into app/

This commit is contained in:
aiongg
2026-10-26 20:54:03 +00:00
parent da59acabcb
commit 10060a7b55
9 changed files with 2225 additions and 2188 deletions
+12 -2188
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
use super::*;
use crate::control;
impl WordApp {
/// One callback command per admitted request, and the synthetic-input queue's final pass.
pub(super) fn flush_shot_and_synthetic(&mut self, ctx: &egui::Context) {
// This is in `ui` so the captured frame is the layout just drawn; a discarded pass is not
// a layout anyone will see. The shot's `issued` flag makes repeated final passes harmless.
if !ctx.will_discard() {
control::issue_screenshots(self, ctx);
}
// The raw input was consumed by this host frame's passes; only a final UI pass proves
// the step is done. A discarded pass keeps the queue for the next UI frame.
if self.synthetic_pending && !ctx.will_discard() {
let n = self.synthetic_injected.min(self.synthetic.len());
self.synthetic.drain(..n);
self.synthetic_pending = false;
self.synthetic_injected = 0;
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
}
/// Make the capture geometry the active window's surface, viewport and document. The epoch is
/// monotonic: resizing back to an earlier size is still a new epoch.
pub(crate) fn refresh_capture_geometry(&mut self, ctx: &egui::Context) {
let (viewport, surface) = self.active_surface_geometry(ctx);
let geometry = surface.map(|surface| surface.with_target(viewport, self.session.document_id()));
if self.capture_geometry != geometry {
self.capture_geometry = geometry;
self.capture_epoch = self.capture_epoch.wrapping_add(1);
}
}
/// Drains queued control requests, dispatches each through `control::handle`, sends its reply or
/// admits a screenshot, then puts the receiver back.
pub(super) fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => control::admit_screenshot(self, ctx, path, reply),
}
}
self.control_rx = Some(rx);
}
}
+444
View File
@@ -0,0 +1,444 @@
use super::*;
impl WordApp {
/// Answer the Save Changes prompt: Save (through Save As for a new document) and carry on,
/// carry on without saving, or cancel. A failed or cancelled save cancels too.
pub(super) fn answer_save_changes(&mut self, choice: SaveChoice) -> Result<Value, String> {
let Some(dialogs::Dialog::SaveChanges { then, params, document, .. }) = self.dialog.take() else {
return Err("no Save Changes prompt is open".into());
};
if document != self.session.document_id() {
return Err("the document the prompt asked about has been replaced".into());
}
// Don't Save is the user's say-so to discard the changes, which Close otherwise refuses.
let mut params = params;
if choice == SaveChoice::DontSave && then == "file.close" {
match params.as_object_mut() {
Some(p) => {
p.insert("force".into(), json!(true));
}
None => params = json!({"force": true}),
}
}
let go = match choice {
SaveChoice::Cancel => false,
SaveChoice::DontSave => true,
// A new document or an import-only Word file: ask where through Save As. The command
// runs once that has saved, on a later frame with the non-blocking dialog (#94).
SaveChoice::Save if (self.session.path.is_none() || self.legacy_word_path()) && self.services.download.is_none() => {
return match self.save_as(file_dialogs::AfterSave::Continue { then, params, document }) {
file_dialogs::Asked::Done(r) => r,
file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})),
file_dialogs::Asked::Busy => Ok(json!({"done": false})),
};
}
SaveChoice::Save => match self.save_for_prompt() {
Ok(PromptSave::Saved) => true,
Ok(PromptSave::NotSaved) => false,
// The browser's picker finishes later: the command waits for it (#1315).
Ok(PromptSave::Later(ticket)) => {
let rev = self.session.rev();
self.awaiting_save = Some(AwaitingSave { ticket, then, params, document, rev });
self.status(tl!("Waiting for the save to finish…"));
return Ok(json!({"pending": "save"}));
}
Err(error) => return Err(self.save_failed(&error, then, params, document)),
},
};
if !go {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
}
/// Save before New/Open/Close (and the mailings that replace the document): true once the
/// document is safely written. A save in a format that doesn't keep everything (a page
/// image, plain text) writes a copy and leaves the document unsaved, so it doesn't count:
/// the command is cancelled and the document stays. A save over the document's own file
/// (a note.txt that was opened) is a save in any format (#2376). A save that fails (no permission, the
/// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`]
/// tells the user).
/// In the browser the save picker may write the file later ([`PromptSave::Later`], #1315).
fn save_for_prompt(&mut self) -> Result<PromptSave, String> {
let v = self.execute("file.save", json!({}))?;
let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some();
if !self.safely_saved(saved) {
return Ok(PromptSave::NotSaved);
}
Ok(match v.get("pendingSave").and_then(Value::as_u64) {
Some(ticket) => PromptSave::Later(ticket),
None => PromptSave::Saved,
})
}
/// Web: a save of this document is still in the browser's picker (#1315). The document
/// looks saved, but nothing is written yet, so New/Open ask first.
pub(crate) fn save_unconfirmed(&self) -> bool {
let document = self.session.document_id();
self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == document)
}
/// The document's name as the Save Changes prompt shows it: its file's name, else its title.
pub(super) fn prompt_name(&self) -> String {
self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem())
}
/// Save (or Save As) from the Save Changes prompt failed (#1445): say so plainly on the status
/// bar and in the prompt, which comes back for Save again, Don't Save or Cancel, with the
/// held-back command (`then`) still waiting. Nothing closes and nothing is discarded. Returns
/// the message, the prompt answer's error.
pub(crate) fn save_failed(&mut self, error: &str, then: String, params: Value, document: u64) -> String {
let name = self.prompt_name();
let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]);
log::error!("save from the Save Changes prompt failed: {error}");
self.prompt_again(message, then, params, document)
}
/// The Save Changes prompt comes back with `message` (also on the status bar), the held-back
/// command still waiting, if the document is still the one it asked about. Returns `message`.
fn prompt_again(&mut self, message: String, then: String, params: Value, document: u64) -> String {
self.status(message.clone());
if document == self.session.document_id() {
let name = self.prompt_name();
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() });
}
message
}
/// Read how the saves that finished later ended (#1315). Called every frame.
/// A save that didn't write the file leaves the document it was for unsaved again (only that
/// document: one that has replaced it since is left alone). The command a Save Changes prompt
/// held back runs once its save is written and closed, if the document is still the same and
/// unchanged since; a cancelled or failed save brings the prompt back with the reason.
pub(crate) fn settle_async_saves(&mut self) {
let Some(saves) = self.services.async_saves.clone() else { return };
for (ticket, outcome) in saves.take_finished() {
let current = self.session.document_id();
if let Some(i) = self.saves_in_flight.iter().position(|s| s.ticket == ticket) {
let save = self.saves_in_flight.swap_remove(i);
match &outcome {
async_saves::SaveOutcome::Saved => log::info!("saved {} through the save picker", save.name),
async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => {
if save.marked_saved && save.document == current {
self.session.dirty = true;
} else if save.marked_saved {
// Another document is shown: the one saved is unsaved again where it waits.
let _ = self.session.with_document(save.document, |s| s.dirty = true);
}
}
}
if let async_saves::SaveOutcome::Failed(error) = &outcome {
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &save.name), ("error", error)]);
log::error!("the save picker couldn't save {}: {error}", save.name);
self.status(msg);
}
}
let Some(wait) = self.awaiting_save.take_if(|w| w.ticket == ticket) else { continue };
if wait.document != current {
log::info!("the document a held-back {} was for has been replaced; dropped", wait.then);
continue;
}
match outcome {
// Only when nothing changed since and no later save of it is still unconfirmed.
async_saves::SaveOutcome::Saved if !self.session.dirty && self.session.rev() == wait.rev && !self.save_unconfirmed() => {
if let Err(e) = self.execute_user(&wait.then, wait.params) {
self.status(e);
}
}
// Edited while the picker was open (those edits may not be written yet): ask again.
async_saves::SaveOutcome::Saved => {
let name = self.prompt_name();
self.dialog = Some(dialogs::Dialog::SaveChanges {
name,
then: wait.then,
params: wait.params,
document: wait.document,
message: String::new(),
});
}
async_saves::SaveOutcome::Cancelled => {
let name = self.prompt_name();
let message = i18n::fmt(tl!("Saving {name} was cancelled, so it is still open and nothing was lost."), &[("name", &name)]);
self.prompt_again(message, wait.then, wait.params, wait.document);
}
async_saves::SaveOutcome::Failed(error) => {
self.save_failed(&error, wait.then, wait.params, wait.document);
}
}
}
}
/// Whether a save before New/Open/Close counts: it happened, and kept everything.
pub(crate) fn safely_saved(&mut self, saved: bool) -> bool {
if saved && self.session.dirty {
self.status(tl!("That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on."));
return false;
}
saved
}
/// The window's close button (or the system) asked to quit. Returns true to let the window
/// close; with unsaved changes it asks first and returns false.
pub fn close_requested(&mut self) -> bool {
if self.printing.busy() {
self.status(tl!("Wait for the print job to finish submitting before closing."));
return false;
}
if self.quit_requested {
return true;
}
// Nothing to save, also with no document open in this window (#1129) and none elsewhere.
if !self.has_unsaved_documents() {
return true;
}
// A comment draft the document refuses is an unsaved change: the prompt can drop it.
if self.has_document() {
let _ = panes::flush_comment_drafts(self);
}
if !self.has_unsaved_documents() {
return true;
}
// Each document with unsaved work is shown and asked about in turn; Cancel stops.
self.ask_before_quit();
self.quit_requested
}
/// File › Close (Mod+W, Mod+F4) closes the shown document, or only its window while other
/// windows show the document (View › New Window), asking nothing then. Its window closes too while other
/// windows are open, and the one focused before it comes forward (in the web app's one window,
/// the next document shows). The last window stays, as Word's does (#1712), with no page, as
/// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`,
/// from the window's close button, goes on quitting: the shown document's prompt has been
/// answered, so it closes and the next document with unsaved changes is asked about
/// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved
/// changes are never discarded unasked: a dirty document closes only with `{"force": true}`
/// (Don't Save in the prompt passes it).
pub(super) fn close_document(&mut self, params: &Value) -> Result<Value, String> {
if self.closes_only_its_window("file.close", params) && self.close_window_only() {
return Ok(json!({"closed": true, "window": true}));
}
if let Some(e) = crate::control::close_refused(self, "file.close", params) {
return Err(e.into());
}
let last = self.session.documents().len() <= 1;
if params.get("quit").and_then(Value::as_bool) == Some(true) {
if last {
// Don't Save: the document stays open until the app exits, but its changes,
// and so its recovery copy, are discarded.
if params.get("force").and_then(Value::as_bool) == Some(true) {
self.session.discard_recovery_copy();
}
self.quit_requested = true;
return Ok(json!({"quit": true}));
}
self.close_shown_document()?;
return Ok(self.ask_before_quit());
}
if !self.has_document() {
return Ok(json!({"closed": false}));
}
self.close_shown_document()?;
self.recovery_seen = true;
if last {
self.no_document = Some(self.session.document_id());
self.session.release_untitled_name();
}
// The window that stays leaves the File page; another window that came forward keeps
// its own.
if last || !self.windows.native() {
self.ui.backstage = false;
}
Ok(json!({"closed": true}))
}
/// These paths have readers but no writer, so an interactive save needs another format.
pub(super) fn legacy_word_path(&self) -> bool {
self.session
.path
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("doc") || e.eq_ignore_ascii_case("dot"))
}
/// The name a browser Save or Export without a name uses. An export is named after the
/// document (`report.pdf`, not `report.docx.pdf`). A host page that keeps the files
/// ([`Services::host_save`]) gets Save back under the opened file's own name and format, as
/// the desktop app writes it, except an import-only Word 97–2003 file (#2445).
pub(super) fn web_save_name(&self, id: &str) -> String {
match id {
"file.exportPdf" => return format!("{}.pdf", self.title_stem()),
"file.exportPng" => return format!("{}.png", self.title_stem()),
_ => {}
}
if id == "file.save"
&& self.services.host_save.is_some()
&& !self.legacy_word_path()
&& let Some(name) = self.session.path.as_ref().and_then(|p| p.file_name())
{
return name.to_string_lossy().to_string();
}
self.default_save_name()
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
_ => format!("{}.docx", self.title_stem()),
}
}
/// Say how the print job went, once, in the window showing the printed document.
pub(super) fn print_feedback(&mut self) {
if self.printing.message == self.print_feedback_seen {
return;
}
self.print_feedback_seen = self.printing.message.clone();
let Some(message @ (printing::Feedback::Submitted(_) | printing::Feedback::Failed(_) | printing::Feedback::WorkerStopped)) =
self.printing.message.clone()
else {
return;
};
let text = printing::feedback(&message, |label| i18n::t(label).to_owned());
self.status(text);
// Word returns to the document once a print job is away, the same as after Save; a
// failure stays on the Print page so the error (and the settings to fix it) are visible
// (#3210: the Print page used to stay up after a successful submission).
if matches!(message, printing::Feedback::Submitted(_)) && self.ui.backstage && self.ui.backstage_page == "print" {
self.ui.backstage = false;
self.canvas.want_focus = true;
}
}
/// Files dropped on the native window. Raw input is taken only in the first UI pass: a
/// hidden `run_logic` tick leaves the previous shown frame's events stale, and replaying a
/// drop would open or insert it again.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn open_dropped_files(&mut self, files: Vec<egui::DroppedFileHandle>) {
let before = self.session.document_id();
let mut placed_picture = false;
for f in &files {
let path = f.path().to_string_lossy().to_string();
let lp = path.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lp.ends_with(e));
let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lp);
let _ = if img {
self.caret_after_placed_picture(placed_picture);
let r = self.run("insert.picture", json!({"path": path}));
placed_picture = Self::placed_in_text(&r);
r
} else if sheet {
// A spreadsheet isn't a document: it's a recipient list (#334).
self.load_recipients(json!({"path": path}))
} else if wordcraft_fonts::is_font_file_name(&lp) {
self.run("font.add", json!({"path": path}))
} else {
self.open_file(&path, None)
};
}
if self.session.document_id() != before {
self.autosave_path = None;
self.dialog = None;
self.ui.backstage = false;
self.canvas.want_focus = true;
}
if !files.is_empty() {
self.ensure_caption_fonts();
self.ensure_comment_fonts();
self.ensure_recover_fonts();
self.ensure_recent_fonts();
self.ensure_find_fonts();
}
}
/// Whether the document's file is one the user saved to in this session.
pub fn saved_here(&self) -> bool {
self.session.path.is_some() && self.session.path == self.autosave_path
}
/// Why AutoSave can't cover the document, or `None` when it can: it writes only a file the
/// user saved to in this session, in a format that keeps everything, and never in the browser
/// (a page can't write to the user's files; saving there is a download, #176).
pub fn autosave_block(&self) -> Option<AutoSaveBlock> {
if self.services.download.is_some() {
return Some(AutoSaveBlock::Browser);
}
let Some(path) = &self.session.path else { return Some(AutoSaveBlock::Unsaved) };
if !keeps_everything(&path.to_string_lossy()) {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
return Some(AutoSaveBlock::Format { name });
}
if !self.saved_here() {
return Some(AutoSaveBlock::NotSavedHere);
}
None
}
/// Whether AutoSave covers the document: it is on and nothing blocks it ([`Self::autosave_block`]).
pub fn autosaves(&self) -> bool {
self.autosave && self.autosave_block().is_none()
}
/// `file.autosave` (the AutoSave switch, and scripts): `value` turns AutoSave on or off;
/// without it, it toggles what the switch shows. Turning it on for a document AutoSave can't
/// cover is an error that says why, instead of a switch that is on but saves nothing (#196).
pub(super) fn set_autosave(&mut self, params: &Value) -> Result<Value, String> {
let on = params.get("value").and_then(Value::as_bool).unwrap_or(!self.autosaves());
if on && let Some(block) = self.autosave_block() {
return Err(block.reason());
}
self.autosave = on;
self.session.autosave = on;
Ok(json!({"value": on}))
}
/// AutoSave: write a saved document a couple of seconds after the last change. A failure is
/// shown in the status bar and turns AutoSave off for the file until the user saves it again.
pub(crate) fn autosave_tick(&mut self, now: f64) {
if self.autosaves() && self.session.dirty && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 {
self.last_autosave = now;
if let Err(e) = self.session.run("file.save", &json!({})) {
log::warn!("AutoSave failed: {e}");
self.autosave_path = None;
self.status(i18n::fmt(tl!("AutoSave failed: {error}. Save the document to turn AutoSave back on."), &[("error", &e.to_string())]));
}
}
}
/// Files that arrived asynchronously (web picker, drops): documents open, pictures insert,
/// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients.
pub(crate) fn drain_inbox(&mut self) {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let mut placed_picture = false;
for (name, bytes) in files {
let lower = name.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".wmf", ".emf"].iter().any(|e| lower.ends_with(e));
// Any recipient file ends the pending picker request, so a later .txt drop opens normally.
let asked = std::mem::take(&mut self.recipient_list_pending);
let list = lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && asked);
let sheet = wordcraft_engine::cmd::mailings::is_spreadsheet_name(&lower);
self.recipient_list_pending = asked && !list && !sheet;
let r = if sheet {
self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
} else if wordcraft_fonts::is_font_file_name(&lower) {
self.run("font.add", json!({"name": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
} else if list {
self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)}))
} else if img {
self.caret_after_placed_picture(placed_picture);
let r = self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}));
placed_picture = Self::placed_in_text(&r);
r
} else {
self.run("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
};
if r.is_ok() {
self.ui.backstage = false;
}
}
}
}
+187
View File
@@ -0,0 +1,187 @@
use super::*;
impl WordApp {
/// Fonts were added or removed (a command, the web shell loading stored fonts): lay out
/// again and refresh the font lists and their previews.
pub(crate) fn watch_fonts(&mut self, ctx: &egui::Context) {
let db = wordcraft_fonts::FontDb::global();
let generation = db.generation();
if generation == self.font_generation {
return;
}
self.font_generation = generation;
self.session.relayout();
self.previews.fonts_changed(ctx, db.user_fonts().iter().flat_map(|f| f.families()));
ctx.request_repaint();
}
/// The window caption is painted with the semibold interface face (Inter). A file name in a
/// script those faces lack would otherwise be drawn as `?`. Borrow the document fonts'
/// fallback faces, as IME composition does (#565). A CJK name uses the CJK UI font instead:
/// those collections are too large to add here.
pub(super) fn ensure_caption_fonts(&mut self) {
let stem = self.title_stem();
if stem.chars().any(wordcraft_fonts::is_cjk) {
self.want_system_cjk = true;
}
let new = theme::faces_for_preedit(&stem, &self.canvas.ime_faces, theme::caption_char_covered, |c| {
wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX)
});
if !new.is_empty() {
self.canvas.ime_faces.extend(new);
// Fonts are already installed for this frame's language; load the new faces too.
self.styled = false;
}
}
/// Comment balloons and the Comments pane draw comment text and author names with the
/// interface faces, which have no Arabic, Hebrew, Thai …: each such letter was drawn as a box.
/// Borrow the document fonts' fallback faces for them, as the caption does. Looked at once per
/// document revision; CJK text uses the CJK UI font instead.
pub(super) fn ensure_comment_fonts(&mut self) {
let key = (self.session.document_id(), self.session.rev());
if self.comment_fonts_for == Some(key) {
return;
}
self.comment_fonts_for = Some(key);
let doc = &self.session.doc;
let mut chars = std::collections::BTreeSet::new();
for c in doc.comments.values() {
chars.extend(c.author.chars());
chars.extend(doc.plain_text(wordcraft_doc::StoryRef::Part(c.part)).chars());
}
if chars.iter().any(|c| wordcraft_fonts::is_cjk(*c)) {
self.want_system_cjk = true;
}
let text: String = chars.into_iter().collect();
let new = theme::faces_for_preedit(&text, &self.canvas.ime_faces, theme::caption_char_covered, |c| {
wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX)
});
if !new.is_empty() {
self.canvas.ime_faces.extend(new);
self.styled = false;
}
}
/// File › Recover draws the recovered documents' names with the interface faces too: a
/// Chinese name was a row of boxes, an Arabic or Hebrew one likewise. The same treatment as
/// the caption (and as the Recent list, `ensure_recent_fonts`): CJK loads the CJK UI font,
/// other scripts borrow the document fallback faces. Looked at again only when the names change.
pub(super) fn ensure_recover_fonts(&mut self) {
use std::hash::{Hash, Hasher};
let docs = self.session.recovery_documents();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
docs.iter().for_each(|d| d.name.hash(&mut hasher));
let key = hasher.finish();
if self.recover_fonts_for == Some(key) {
return;
}
self.recover_fonts_for = Some(key);
let chars: std::collections::BTreeSet<char> = docs.iter().flat_map(|d| d.name.chars()).collect();
if chars.iter().any(|c| wordcraft_fonts::is_cjk(*c)) {
self.want_system_cjk = true;
}
let text: String = chars.into_iter().collect();
let new = theme::faces_for_preedit(&text, &self.canvas.ime_faces, theme::caption_char_covered, |c| {
wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX)
});
if !new.is_empty() {
self.canvas.ime_faces.extend(new);
self.styled = false;
}
}
/// Recent documents in Backstage (and the Start screen) show file names and paths with the
/// interface faces. Borrow document fallback faces for characters in non-Latin scripts (#2067),
/// as the caption and comments do.
pub(super) fn ensure_recent_fonts(&mut self) {
if self.recent_fonts_for.as_slice() == self.ui.recent.as_slice() {
return;
}
self.recent_fonts_for = self.ui.recent.clone();
for p in &self.ui.recent {
if p.chars().any(wordcraft_fonts::is_cjk) {
self.want_system_cjk = true;
}
let new = theme::faces_for_preedit(p, &self.canvas.ime_faces, theme::caption_char_covered, |c| {
wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX)
});
if !new.is_empty() {
for face in new {
if !self.canvas.ime_faces.iter().any(|f| f.id() == face.id()) {
self.canvas.ime_faces.push(face);
}
}
self.styled = false;
}
}
}
/// The Find box and the Navigation pane's Results list draw the typed query and document
/// excerpts around each match with the interface faces, which have no Georgian, Armenian,
/// Thai … (#3248): each such letter was drawn as a box. Borrow the document fonts' fallback
/// faces for them, as comments and the caption do. Looked at once per query/result-count/
/// revision change (an edit can change a result's surrounding text — adding Georgian beside
/// an existing Latin match — without changing the query or how many matches there are, #3257
/// review); CJK text uses the CJK UI font instead.
pub(super) fn ensure_find_fonts(&mut self) {
let key = (self.session.find.query.clone(), self.session.find.results.len(), self.session.rev());
if self.find_fonts_for.as_ref() == Some(&key) {
return;
}
self.find_fonts_for = Some(key);
let mut text = self.session.find.query.clone();
for (a, b) in self.session.find.results.iter().take(500) {
let Some(p) = self.session.doc.para_at(a) else { continue };
let from = p.clamp(a.off.saturating_sub(30));
let to = p.clamp((b.off + 40).min(p.len()));
if let Some(s) = p.text.get(from..to) {
text.push_str(s);
}
}
if text.chars().any(wordcraft_fonts::is_cjk) {
self.want_system_cjk = true;
}
let new = theme::faces_for_preedit(&text, &self.canvas.ime_faces, theme::caption_char_covered, |c| {
wordcraft_fonts::FontDb::global().fallback_for(c, u32::MAX)
});
if !new.is_empty() {
self.canvas.ime_faces.extend(new);
self.styled = false;
}
}
/// Install the interface fonts when the language, the CJK fallback, or a caption fallback
/// face has changed since the last install.
pub(super) fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) {
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk
{
// Every window's borrowed faces: the fonts are the app's.
let mut faces = self.canvas.ime_faces.clone();
for w in self.other_windows() {
for face in &w.canvas.ime_faces {
if !faces.iter().any(|f| f.id() == face.id()) {
faces.push(face.clone());
}
}
}
theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces);
self.fonts_hans = lang.prefers_hans();
self.fonts_korean = lang.prefers_korean();
self.fonts_system_cjk = system_cjk;
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
// egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph
// Formatting there (#1218). Keep ⌘Q, which only the Mac sends. egui would close only the
// window it was pressed in, so with native windows the app quits itself
// ([`WordApp::window_logic_before`]).
let quit = (!self.windows.native()).then_some(QUIT).into_iter().collect();
ctx.options_mut(|o| {
o.zoom_with_keyboard = false;
o.quit_shortcuts = quit;
});
self.styled = true;
}
}
}
+118
View File
@@ -0,0 +1,118 @@
use super::*;
impl WordApp {
pub fn status(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms()));
}
/// Document title for the title bar (and the suggested file name). An untitled document is
/// named in the interface language, as `Document1` is in English.
pub fn title_stem(&self) -> String {
match &self.session.path {
Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()),
None => match &self.session.file_name {
Some(name) => name.clone(),
None if self.session.doc.core.title.is_empty() => {
documents::untitled_title(&self.session.resolved_filenames().0).unwrap_or_else(|| tl!("Document1").into())
}
None => self.session.doc.core.title.clone(),
},
}
}
/// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]).
pub(super) fn report_dirty(&self) {
if let Some(f) = &self.services.on_dirty {
f(self.session.dirty || panes::has_comment_drafts(self));
}
}
/// Draw the File page, the empty-window chrome, or the normal document window.
pub(super) fn paint_window(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, t: theme::Tokens) {
let window = self.windows.shown();
let moved = |app: &WordApp| app.windows.shown() != window;
if self.ui.backstage {
backstage::show(self, ui);
} else if !self.has_document() {
// No document open (#1129): the ribbon greyed out but for Help, and no page.
chrome::title_bar(self, ui);
ribbon::show(self, ui);
chrome::status_bar(self, ui);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |_| {});
} else {
let screen = chrome::Screen::of(&self.session.view);
// Focus and Full Screen (#1678) take the whole screen, with only their slim bar.
self.focus_ribbon &= screen == chrome::Screen::Focus;
if screen != chrome::Screen::Normal {
chrome::screen_bar(self, ui, screen);
} else {
chrome::title_bar(self, ui);
if moved(self) {
return;
}
ribbon::show(self, ui);
if moved(self) {
return;
}
chrome::status_bar(self, ui);
panes::show(self, ui);
}
if moved(self) {
return;
}
let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
canvas::show(self, ui);
});
if self.focus_ribbon && !moved(self) {
chrome::focus_ribbon(self, ctx);
}
}
}
/// Dialogs, panes and other overlays drawn over the window. A command that brings another
/// window's state in ends the drawing: the rest would be the other window's.
pub(super) fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
let window = self.windows.shown();
let moved = |app: &WordApp| app.windows.shown() != window;
content_controls::popup(self, ctx);
dialogs::show(self, ctx);
if moved(self) {
return;
}
updates_ui::show(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
file_browser::show(self, ctx);
colors::show(self, ctx);
zotero::show_alert(self, ctx);
read_aloud::show(self, ctx);
crate::keytips::show(self, ctx, ui);
if moved(self) {
return;
}
keys::global_shortcuts(self, ctx);
if moved(self) {
return;
}
self.refocus_after_chrome_click(ctx);
if let Some(url) = self.canvas.open_url.take() {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
}
/// Keep the window title and full-screen state in sync, then record this frame's time.
pub(super) fn sync_title_and_frame(&mut self, ctx: &egui::Context, t0: f64) {
// With native windows the viewport driver gives the window its title.
let title = self.window_title();
if title != self.sent_title {
if !self.windows.driven() {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
}
self.sent_title = title;
}
self.sync_full_screen(ctx);
self.frame_ms = now_ms() - t0;
// Typing and formatting land here, after `logic` has reported.
self.report_dirty();
}
}
+159
View File
@@ -0,0 +1,159 @@
use super::*;
impl WordApp {
/// Read a Control-click as the right-click on macOS, then offer at most one synthetic gesture
/// step to the host. eframe carries an unconsumed RawInput into the next pass and into hidden
/// logic ticks, so offering it again would duplicate input. The step stays queued until a
/// final (not discarded) UI pass acknowledges it, and a press/release pair stays intact.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Capture the surface from the raw input itself. `InputState::viewport_rect` and the
// last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's
// current answer, including minimized/occluded and a Wayland `inner_rect: None`.
if raw.viewport_id == egui::ViewportId::ROOT
&& let Some(geometry) = crate::control::SurfaceGeometry::from_raw(raw, ctx)
{
// A hidden resize and change back still invalidate an older capture, even if
// neither intermediate geometry reaches a drawing or logic pass.
if self.surface_geometry != Some(geometry) {
self.capture_epoch = self.capture_epoch.wrapping_add(1);
}
self.surface_geometry = Some(geometry);
}
if cfg!(target_os = "macos") {
control_click_as_secondary(&mut raw.events, &mut self.control_click);
}
if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 || !self.control_input_is_roots(ctx) {
return;
}
let Some((events, n)) = self.peek_synthetic() else { return };
if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) {
// Control input belongs to this window's remembered widget even while another app
// is active. Give only this input frame egui focus, without requesting OS activation.
raw.focused = true;
if let (Some(ctx), Some((id, _))) = (&self.ctx, self.canvas.widget_id) {
// Canvas keys run before painting updates last frame's native focus flag.
self.canvas.focused = ctx.memory(|m| m.has_focus(id));
}
}
raw.events.extend(events);
self.synthetic_pending = true;
self.synthetic_injected = n;
}
/// The control channel's next input frame, taken off the queue.
pub(crate) fn next_synthetic(&mut self) -> Option<Vec<egui::Event>> {
let (events, n) = self.peek_synthetic()?;
self.synthetic.drain(..n);
Some(events)
}
/// The control channel's next input frame: one pointer event (after a move to it), or keys
/// and text up to the next key release; with how many queued events it takes.
fn peek_synthetic(&self) -> Option<(Vec<egui::Event>, usize)> {
if self.synthetic.is_empty() {
return None;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
let mut events = Vec::new();
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
events.push(egui::Event::PointerMoved(*p));
}
let n = n.min(self.synthetic.len());
events.extend(self.synthetic.iter().take(n).cloned());
Some((events, n))
}
/// Raw input read in the known UI phase, first pass only: dropped files and the SmartArt Text
/// Pane's Tab/Shift+Tab, before the font warm-up return. `logic` also runs hidden, where its
/// input events are from the previous shown frame.
pub(super) fn handle_ui_input_events(&mut self, ctx: &egui::Context) {
// eframe does not preserve dropped files for a later frame, so a drop in the first two
// passes would otherwise disappear.
#[cfg(not(target_arch = "wasm32"))]
if ctx.current_pass_index() == 0 {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
if !dropped.is_empty() {
self.open_dropped_files(dropped);
let lang = i18n::Lang::from_pref(&self.ui.language);
self.install_ui_fonts(ctx, lang);
}
}
// SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as
// Word's pane does. This is an input action, so it runs in the first UI pass only; hidden
// `run_logic` ticks still see the previous shown frame's keys.
if ctx.current_pass_index() == 0 && self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() {
let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab));
let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab));
if shift_tab || tab {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)}));
}
}
}
/// A click on the window's chrome (ribbon tabs and empty ribbon space, galleries, check
/// boxes, the status bar, panes) hands keyboard focus back to the document, as in Word, so the
/// caret keeps blinking and typing continues where it was (#1093). egui drops the page's focus
/// on any click elsewhere; only a click that left nothing focused counts, so a text field
/// (font size, zoom, command search) keeps the focus it took, and clicks on a dialog, menu or
/// popup, or while one is open, leave focus alone. Clicks on the page are the canvas's own.
pub(super) fn refocus_after_chrome_click(&mut self, ctx: &egui::Context) {
if self.ui.backstage || self.dialog.is_some() || self.colors.is_some() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if self.file_browser.open.is_some() {
return;
}
let Some(pos) = ctx.input(|i| if i.pointer.any_click() { i.pointer.interact_pos() } else { None }) else { return };
// The ribbon dropped over the page in Focus (#1678) is chrome, though it lies over the page.
let on_focus_ribbon = self.focus_ribbon && ctx.layer_id_at(pos).is_some_and(|l| l.id == egui::Id::unique("focus_ribbon"));
// The page handles its own clicks (comment balloons there take the keyboard themselves).
if !on_focus_ribbon && (self.canvas.balloon.is_some() || self.canvas.viewport.is_some_and(|v| v.contains(pos))) {
return;
}
let on_chrome = on_focus_ribbon || ctx.layer_id_at(pos).is_none_or(|l| l.order == egui::Order::Background);
if on_chrome && ctx.memory(|m| m.focused().is_none()) && !egui::Popup::is_any_open(ctx) {
self.canvas.want_focus = true;
// The page has already been drawn, so it only sees `want_focus` next frame, and its own
// `focused` flag (which gates the keys) isn't recomputed until then either. Claim both
// now, or the click swallows the first keystroke.
self.canvas.focused = true;
// Only a page this pass drew. The File page's back arrow closes the File page in the
// pass that drew it instead of the page, and focus on a widget missing from the pass
// names a node AccessKit doesn't have: with accessibility on, AccessKit ends the program
// over that. `want_focus` hands the keyboard over next frame instead.
if let Some((id, pass)) = self.canvas.widget_id
&& pass == ctx.cumulative_pass_nr()
{
ctx.memory_mut(|m| m.request_focus(id));
}
// The canvas reads keys before it takes focus back (#1107), so it counts as focused
// now: a keystroke in the very next frame reaches the page instead of being dropped.
self.canvas.focused = true;
ctx.request_repaint();
}
}
}
/// macOS reads a Control-click as the right-click (winit reports the primary button with Control
/// held), so menus open on it as on a two-finger click. The release follows its press even if
/// Control was let go in between.
pub(crate) fn control_click_as_secondary(events: &mut [egui::Event], held: &mut bool) {
for e in events {
if let egui::Event::PointerButton { button: button @ egui::PointerButton::Primary, pressed, modifiers, .. } = e {
if *pressed {
*held = modifiers.ctrl && !modifiers.mac_cmd;
}
if *held {
*button = egui::PointerButton::Secondary;
}
if !*pressed {
*held = false;
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
use super::*;
/// Whether a pasted picture belongs in the document: the page has keyboard focus, and no
/// Backstage or dialog is open (#1349). A focused text field (comment, Find box) means the page
/// does not have focus, so `canvas_focused` already covers it; egui's own "wants keyboard input"
/// is true for the focused page itself and can't be used.
fn paste_target(canvas_focused: bool, backstage: bool, dialog: bool) -> bool {
canvas_focused && !backstage && !dialog
}
impl WordApp {
/// Select the picture or icon the person just inserted or pasted, so it shows its handles.
/// `insert.picture` leaves the caret after it, which scripts and agents keep so their next
/// typing doesn't replace it (#965).
pub(crate) fn select_inserted_picture(&mut self, r: &Result<Value, String>) {
if !r.as_ref().is_ok_and(|v| v.get("media").is_some() && v.get("canvasMember").is_none()) || !self.session.sel.is_collapsed() {
return;
}
let end = self.session.sel.focus.clone();
let Some(off) = end.off.checked_sub(wordcraft_doc::para::OBJ.len_utf8()) else { return };
let start = wordcraft_doc::Pos { off, ..end.clone() };
if self.session.doc.para_at(&start).and_then(|p| p.object_at(off)).is_some_and(|o| o.is_drawing()) {
self.session.sel = wordcraft_engine::Selection { anchor: start, focus: end };
}
}
/// Media key of the selected picture, if any.
pub(crate) fn selected_picture_media(&self) -> Option<String> {
match wordcraft_engine::cmd::objects::selected(&self.session) {
Some((_, wordcraft_doc::para::InlineObject::Image { media, .. })) => Some(media),
_ => None,
}
}
/// Inserting a picture selects it, and the next insert would replace it. When several images
/// arrive in one drop, put the caret after the picture placed by the previous file (#1592).
pub(super) fn caret_after_placed_picture(&mut self, placed: bool) {
if placed {
self.session.sel.anchor = self.session.sel.focus.clone();
}
}
/// Whether `r` placed a picture in the text (and selected it). One taken into a selected
/// Drawing Canvas leaves the canvas selected, so the next file of the drop joins it too.
pub(super) fn placed_in_text(r: &Result<Value, String>) -> bool {
r.as_ref().is_ok_and(|v| v.get("canvasMember").is_none())
}
/// A picked image replaces the pending Change Picture target, else it is inserted.
pub(crate) fn insert_or_change_picture(&mut self, params: Value) -> Result<Value, String> {
let target = std::mem::take(&mut self.change_picture_target);
match (target, self.selected_picture_media()) {
(Some(t), Some(m)) if t == m => self.run("picture.change", params),
_ => self.run("insert.picture", params),
}
}
/// Drop a pending Change Picture once the selection moved to another picture.
pub(crate) fn clear_stale_change_picture(&mut self) {
let stale = match (&self.change_picture_target, self.selected_picture_media()) {
(Some(t), Some(m)) => t != &m,
(Some(_), None) => true,
_ => false,
};
if stale {
self.change_picture_target = None;
}
}
/// Pictures pasted in the browser: inserted once when the page is the edit target, dropped
/// otherwise so the focused field's own paste applies (a text field ignores a picture).
pub(crate) fn drain_pasted(&mut self) {
let Some(pasted) = self.services.pasted.clone() else { return };
let pictures = std::mem::take(&mut *pasted.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
let target = paste_target(self.canvas.focused, self.ui.backstage, self.dialog.is_some() || self.file_dialog_open());
for (_, bytes) in pictures {
if !target {
log::debug!("pasted picture ignored: the document is not the edit target");
continue;
}
let _ = self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}));
}
}
}
+143
View File
@@ -0,0 +1,143 @@
use super::*;
/// What the operating system says about light and dark. macOS and Windows answer through winit;
/// winit reports nothing on Linux, where the desktop portal does ([`desktop_theme`]). `None` means
/// nobody answered, not a preference.
fn system_theme(ctx: &egui::Context, desktop_dark: Option<bool>) -> Option<egui::Theme> {
ctx.system_theme().or_else(|| desktop_dark.map(|dark| if dark { egui::Theme::Dark } else { egui::Theme::Light }))
}
impl WordApp {
/// Preferences to save between runs: the UI state plus the current user name and editing
/// preferences.
pub fn prefs(&self) -> UiState {
let mut ui = self.ui.clone();
ui.author = self.session.author.clone();
ui.initials = self.session.initials.clone();
ui.dictionary = wordcraft_engine::proof::user_dictionary();
ui.editing = self.session.prefs.clone();
ui.read_aloud_rate = self.session.read_aloud.rate();
ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations;
ui.dark_page = self.session.view.dark_mode;
ui.nav_pane = self.session.view.nav_pane;
ui.styles_pane_dock = self.session.view.styles_pane_dock;
ui.check_spelling = self.session.view.proofing;
ui.keyboard = self.session.keymap.clone();
ui.ribbon = self.session.ribbon.clone();
ui.ink_pens = self.session.view.draw.pens.clone();
ui
}
/// Restore preferences saved by [`WordApp::prefs`].
pub fn apply_prefs(&mut self, ui: UiState) {
self.ui = ui;
self.ui.backstage = false;
// Word opens on Home every launch, so the tab `ui.json` holds is not restored. Going File
// and back to the editor within the run still returns to the tab that was showing.
self.ui.tab = DEFAULT_TAB.into();
let recent_fonts = recent_fonts_from(self.ui.recent_fonts.iter().map(String::as_str));
self.ui.recent_fonts = recent_fonts;
// A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light.
if let Some(dark) = self.ui.legacy_dark.take() {
self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light };
}
// A `ui.json` from before #822: its interface size was on top of the display scale, so it
// becomes absolute on the first frame that knows that scale (`logic`). A chosen `uiScale`
// (the current key) wins; "auto" can't be told from a missing key, so the old size does.
self.legacy_interface_size =
self.ui.legacy_interface_size.take().filter(|_| self.ui.interface_size.is_auto()).and_then(|old| theme::legacy_interface_percent(&old));
// The session owns the name from here on; `prefs` copies it back when saving.
let author = std::mem::take(&mut self.ui.author);
if !author.trim().is_empty() {
self.session.author = author;
}
self.session.initials = std::mem::take(&mut self.ui.initials).chars().take(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).collect();
for w in std::mem::take(&mut self.ui.dictionary).iter().take(MAX_DICTIONARY) {
let w = w.trim();
if !w.is_empty() && w.chars().count() <= wordcraft_engine::cmd::options::MAX_WORD_CHARS {
wordcraft_engine::proof::add_word(w);
}
}
self.session.prefs = std::mem::take(&mut self.ui.editing);
// Check spelling as you type follows the saved preference (#1792).
self.session.view.proofing = self.session.prefs.check_spelling;
// The saved dictionaries folder (#1063) is searched from the start.
if let Some(f) = self.session.prefs.dictionaries_folder.as_deref() {
wordcraft_engine::cmd::options::use_dictionaries_folder(Some(f));
}
self.session.read_aloud.set_rate(self.ui.read_aloud_rate);
self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations;
self.session.view.dark_mode = self.ui.dark_page;
self.session.view.nav_pane = self.ui.nav_pane;
self.session.view.styles_pane_dock = self.ui.styles_pane_dock;
self.session.view.proofing = self.ui.check_spelling;
// Keys for commands this version doesn't have are dropped.
self.session.keymap = std::mem::take(&mut self.ui.keyboard);
let reg = self.session.registry.clone();
self.session.keymap.retain_known(&reg);
// So are ribbon and toolbar commands.
self.session.ribbon = std::mem::take(&mut self.ui.ribbon);
self.session.ribbon.retain_known(&reg);
self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens);
}
/// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no
/// system theme; see [`desktop_theme`]). The desktop app calls this once at start-up; unit
/// tests and `ui_shot` don't, so what they draw doesn't depend on the host's desktop theme.
pub fn watch_desktop_theme(&mut self, ctx: &egui::Context) {
let ctx = ctx.clone();
self.desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
}
/// Whether the interface theme setting currently resolves to dark (`System` asks the OS).
pub fn ui_is_dark(&self) -> bool {
self.ui.theme.is_dark(self.ctx.as_ref().and_then(|ctx| system_theme(ctx, self.desktop_dark)))
}
/// Whether animation effects are on: the Animation effects setting, where `System` follows
/// what the system last reported (on when it reports nothing).
pub fn animations_on(&self) -> bool {
self.ui.animations.enabled(self.system_animations)
}
/// What the system last said about its animation effects (`None`: it doesn't say).
pub fn system_animations(&self) -> Option<bool> {
self.system_animations
}
/// Ask the host for the system's animation setting.
pub(super) fn read_system_animations(&mut self) {
self.system_animations = self.services.animations_enabled.as_ref().and_then(|ask| ask());
}
/// Carry View › Full Screen (`view.fullScreen`, F11), Focus and Full Screen View (#1678) to
/// the window, and follow the window when the system takes it in or out of full screen (the
/// macOS title bar button, the window manager): in is Full Screen, out ends all three. Leaving
/// full screen gives the window back its earlier size and place: the system keeps them, and
/// [`window_geometry`] never saves a full-screen size (#980).
pub(crate) fn sync_full_screen(&mut self, ctx: &egui::Context) {
let v = &self.session.view;
let want = v.full_screen || v.focus_mode || v.full_screen_view;
let seen = ctx.input(|i| i.viewport().fullscreen);
if seen != self.seen_full_screen {
self.seen_full_screen = seen;
if let Some(on) = seen
&& on != want
{
let v = &mut self.session.view;
v.full_screen = on;
if !on {
v.focus_mode = false;
v.full_screen_view = false;
}
self.sent_full_screen = on;
}
}
let v = &self.session.view;
let want = v.full_screen || v.focus_mode || v.full_screen_view;
if want != self.sent_full_screen {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(want));
self.sent_full_screen = want;
}
}
}