mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -06:00
refactor(ui-egui): split app.rs into app/
This commit is contained in:
+12
-2188
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(®);
|
||||
// So are ribbon and toolbar commands.
|
||||
self.session.ribbon = std::mem::take(&mut self.ui.ribbon);
|
||||
self.session.ribbon.retain_known(®);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user