mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 13:05:56 +00:00
* build(i18n): embed the craft-i18n catalogs from a pinned checkout
- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
commits equal to the trimmed pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper
`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.
Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.
Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(i18n): remove the in-repo catalogs; craft-i18n holds them
The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption
Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.
With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): watch the locales directory before reading it
A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis
craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report
same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.
bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with a `}` that closes no placeholder
same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): no braces in the assert messages of the brace test
assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
921 lines
47 KiB
Rust
921 lines
47 KiB
Rust
//! What each registered command does in this frontend (`pdfcraft_engine::commands` says
|
||
//! what it is called, where it appears, which key runs it and when it is enabled).
|
||
//!
|
||
//! Menus, keyboard shortcuts, the palette, the tool panels and automation (`set_option`, and
|
||
//! the control channel later) all call `PdfCraftApp::execute`.
|
||
|
||
use pdfcraft_engine::Edit;
|
||
use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut};
|
||
|
||
use crate::{
|
||
Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget,
|
||
theme::{ThemeKind, ThemePreference},
|
||
};
|
||
|
||
/// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and
|
||
/// Help ▸ Keyboard shortcuts. Keep them in step with the bindings there.
|
||
pub(crate) const ACTUAL_SIZE: Shortcut = Shortcut::cmd("1");
|
||
pub(crate) const PAGE_LEVEL: Shortcut = Shortcut::cmd("0");
|
||
pub(crate) const FIT_WIDTH: Shortcut = Shortcut::cmd("2");
|
||
pub(crate) const ZOOM_IN: Shortcut = Shortcut::cmd("+");
|
||
pub(crate) const ZOOM_OUT: Shortcut = Shortcut::cmd("−");
|
||
pub(crate) const ROTATE_CW: Shortcut = Shortcut::cmd_shift("+");
|
||
pub(crate) const ROTATE_CCW: Shortcut = Shortcut::cmd_shift("−");
|
||
pub(crate) const PREV_VIEW: Shortcut = Shortcut::cmd("[");
|
||
pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]");
|
||
pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G");
|
||
pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G");
|
||
pub(crate) const COPY: Shortcut = Shortcut::cmd("C");
|
||
pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A");
|
||
pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←");
|
||
pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→");
|
||
|
||
/// Whether shortcuts are written the macOS way (`⇧⌘S`) rather than `Ctrl+Shift+S`. egui knows the
|
||
/// platform from the build target, and on the web from the browser's user agent.
|
||
pub(crate) fn mac_shortcuts(ctx: &egui::Context) -> bool {
|
||
ctx.os() == egui::os::OperatingSystem::Mac
|
||
}
|
||
|
||
/// How `s` is written on this platform, using the resolved current interface language.
|
||
pub(crate) fn shortcut_label(ctx: &egui::Context, s: Shortcut) -> String {
|
||
let key = crate::i18n::key_name(s.key);
|
||
if mac_shortcuts(ctx) {
|
||
Shortcut { key, ..s }.label(true)
|
||
} else {
|
||
let mut parts = Vec::new();
|
||
if s.command || s.mac_ctrl {
|
||
parts.push(crate::i18n::key_name("Ctrl"));
|
||
}
|
||
if s.shift {
|
||
parts.push(crate::i18n::key_name("Shift"));
|
||
}
|
||
parts.push(key);
|
||
parts.join("+")
|
||
}
|
||
}
|
||
|
||
/// The command modifier for help text such as “Strg-scroll”; bindings stay in the engine.
|
||
pub(crate) fn command_modifier_label(ctx: &egui::Context) -> &'static str {
|
||
if mac_shortcuts(ctx) { Shortcut::command_name(true) } else { crate::i18n::key_name("Ctrl") }
|
||
}
|
||
|
||
/// How a registered command's shortcut is written on this platform ("" without one).
|
||
pub(crate) fn command_shortcut_label(ctx: &egui::Context, id: &str) -> String {
|
||
commands::command(id).and_then(|c| c.shortcut).map(|s| shortcut_label(ctx, s)).unwrap_or_default()
|
||
}
|
||
|
||
/// A translated tooltip with a `{key}` placeholder, filled with `s` as written on this platform:
|
||
/// "Zoom in ({key})" → "Zoom in (Ctrl++)".
|
||
pub(crate) fn key_tip(ctx: &egui::Context, template: &str, s: Shortcut) -> String {
|
||
crate::i18n::fmt(template, &[("key", &shortcut_label(ctx, s))])
|
||
}
|
||
|
||
/// [`key_tip`] with a registered command's shortcut, so the tooltip names the real binding.
|
||
pub(crate) fn command_tip(ctx: &egui::Context, template: &str, id: &str) -> String {
|
||
crate::i18n::fmt(template, &[("key", &command_shortcut_label(ctx, id))])
|
||
}
|
||
|
||
impl PdfCraftApp {
|
||
/// Whether a registered command can run now. The engine judges the document (security,
|
||
/// contents, undo history); view state it can't see is checked here.
|
||
pub(crate) fn command_enabled(&self, spec: &CommandSpec) -> bool {
|
||
// Undo and Redo act on the Combine files list while its tab shows.
|
||
if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) {
|
||
return self.combine_can_undo(spec.needs == commands::Needs::Undo);
|
||
}
|
||
// Undo also takes back typing in a box open on the page, and Redo what Undo took there,
|
||
// with no document step.
|
||
(commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id))
|
||
|| spec.needs == commands::Needs::Undo && self.page_typing_unapplied()
|
||
|| spec.needs == commands::Needs::Redo && self.open_box_can_redo())
|
||
&& (spec.id != "edit.copy"
|
||
|| self
|
||
.active
|
||
.and_then(|i| self.views.get(i))
|
||
.is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty()))))
|
||
&& (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies))
|
||
&& match spec.id {
|
||
"view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()),
|
||
"view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()),
|
||
_ => true,
|
||
}
|
||
}
|
||
|
||
/// Run a registered command by id. Returns `false` when the id is unknown or the command
|
||
/// is disabled right now (the user is told why).
|
||
///
|
||
/// Last-resort guard (AGENTS.md §4): a command that panics is reported and the app, with its
|
||
/// open documents, keeps running. Edits are applied to a copy, so the document is unchanged.
|
||
pub fn execute(&mut self, id: &str) -> bool {
|
||
match pdfcraft_engine::guard(|| self.execute_unguarded(id)) {
|
||
Ok(done) => done,
|
||
Err(m) => {
|
||
self.notify_fmt("That didn't work: an internal error stopped it ({m}). Your documents are unchanged.", &[("m", m.as_str())]);
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Why the command can't run right now, for the user.
|
||
fn disabled_reason(&self, spec: &CommandSpec) -> String {
|
||
match spec.needs {
|
||
commands::Needs::Undo => tl!("Nothing to undo").to_string(),
|
||
commands::Needs::Redo => tl!("Nothing to redo").to_string(),
|
||
commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(),
|
||
commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(),
|
||
commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(),
|
||
commands::Needs::HasRedactions if self.active.is_some() => {
|
||
tl!("There are no redaction marks (mark text, areas or pages first)").to_string()
|
||
}
|
||
commands::Needs::Marks(k) if self.active.is_some() => {
|
||
let kind = match k {
|
||
pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"),
|
||
pdfcraft_engine::MarkKind::Watermark => tl!("watermark"),
|
||
pdfcraft_engine::MarkKind::Background => tl!("background"),
|
||
};
|
||
crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)])
|
||
}
|
||
// Restricted documents ask for their permissions password instead, so only an
|
||
// unprotected or a read-only document gets here.
|
||
commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => {
|
||
match self.active_ids().and_then(|(_, id)| self.session.get(id)) {
|
||
Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]),
|
||
_ => tl!("This document isn't password-protected").to_string(),
|
||
}
|
||
}
|
||
commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(),
|
||
commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => {
|
||
tl!("The document's security settings don't allow this change").to_string()
|
||
}
|
||
commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(),
|
||
commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(),
|
||
commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(),
|
||
_ => tl!("Open a document first").to_string(),
|
||
}
|
||
}
|
||
|
||
fn execute_unguarded(&mut self, id: &str) -> bool {
|
||
let Some(spec) = commands::command(id) else { return false };
|
||
if !self.command_enabled(spec) {
|
||
let why = self.disabled_reason(spec);
|
||
self.notify(why);
|
||
return false;
|
||
}
|
||
let active = self.active;
|
||
let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default();
|
||
self.run_file_command(id, active)
|
||
.or_else(|| self.run_view_command(id, active))
|
||
.or_else(|| self.run_page_command(id, active, targets))
|
||
.or_else(|| self.run_edit_command(id, active))
|
||
.or_else(|| self.run_form_command(id, active))
|
||
.or_else(|| self.run_secure_command(id, active))
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
fn run_file_command(&mut self, id: &str, active: Option<usize>) -> Option<bool> {
|
||
match id {
|
||
"file.open" => self.open_dialog(),
|
||
"file.open_recent" => match self.recent.first().map(|r| r.path.clone()) {
|
||
// The palette runs commands without a submenu: open the most recent file.
|
||
Some(p) => self.open_recent(&p),
|
||
None => self.notify_tr("No recent files"),
|
||
},
|
||
"file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"),
|
||
"file.clear_recent" => self.recent.clear(),
|
||
"file.pin_folder" => self.pin_folder_dialog(),
|
||
"file.save" => {
|
||
self.save_active(SaveTarget::InPlace);
|
||
}
|
||
"file.save_as" => {
|
||
self.save_active(SaveTarget::As);
|
||
}
|
||
"file.close" => {
|
||
if let Some(i) = active {
|
||
self.request_close_tab(i);
|
||
}
|
||
}
|
||
"file.close_all" => self.close_all(),
|
||
"file.revert" => self.dialog = Some(Dialog::Revert),
|
||
"file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)),
|
||
"file.word_count" => self.dialog = Some(Dialog::WordCount),
|
||
"protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)),
|
||
// A restricted document's security changes once its permissions password is entered.
|
||
"protect.password" | "protect.remove" if self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.can_unlock()) => {
|
||
let then = if id == "protect.password" { "protect.password" } else { "protect.remove" };
|
||
self.request_unlock(Some(then));
|
||
}
|
||
"protect.unlock" => self.request_unlock(None),
|
||
"protect.password" => {
|
||
self.protect_draft = Default::default();
|
||
self.dialog = Some(Dialog::Protect);
|
||
}
|
||
"protect.remove" => {
|
||
if self.apply_edit(Edit::RemoveProtection) {
|
||
self.notify_tr("Security will be removed when you save");
|
||
}
|
||
}
|
||
link if pdfcraft_engine::links::for_command(link).is_some() => {
|
||
if let Some(l) = pdfcraft_engine::links::for_command(link) {
|
||
self.open_url(l.url);
|
||
}
|
||
}
|
||
"app.preferences" => self.dialog = Some(Dialog::Preferences),
|
||
"protect.remove_hidden" => self.open_remove_hidden(),
|
||
"print.dialog" => self.open_print(),
|
||
"optimize.advanced" => self.dialog = Some(Dialog::Optimize),
|
||
"doc.compare" => self.dialog = Some(Dialog::CompareFiles),
|
||
"standards.pdfa" => {
|
||
self.pdfa.issues = None;
|
||
self.dialog = Some(Dialog::PdfA);
|
||
}
|
||
"actions.wizard" | "actions.distribution" | "actions.optimize_scans" => {
|
||
self.wizard.editing = None;
|
||
match id {
|
||
"actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()),
|
||
"actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()),
|
||
_ => {}
|
||
}
|
||
self.dialog = Some(Dialog::ActionWizard);
|
||
}
|
||
"create.blank" => self.create_blank(),
|
||
"create.file" => self.open_dialog(),
|
||
"create.multiple" => self.create_multiple_dialog(),
|
||
"create.multiple_separate" => self.create_multiple_separate_dialog(),
|
||
"create.images" => self.create_from_images_dialog(),
|
||
"create.clipboard" => self.create_from_clipboard(),
|
||
"optimize.reduce" => self.reduce_file_size(),
|
||
"help.shortcuts" => self.dialog = Some(Dialog::Shortcuts),
|
||
"help.about" => self.dialog = Some(Dialog::About),
|
||
"help.check_updates" => self.check_for_updates(),
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
fn run_view_command(&mut self, id: &str, active: Option<usize>) -> Option<bool> {
|
||
match id {
|
||
"ui.dock.reset" => {
|
||
if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) {
|
||
self.notify(error);
|
||
}
|
||
}
|
||
"view.select_tool" => self.quick_tool = crate::QuickTool::Select,
|
||
"view.hand_tool" => self.quick_tool = crate::QuickTool::Hand,
|
||
"view.side_panel" => self.toggle_side_panel(),
|
||
"view.tools_pane" => self.left_open = !self.left_open,
|
||
"view.focus_page_input" => {
|
||
if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) {
|
||
// The page box in the toolbar (chrome.rs); select its label so typing replaces it.
|
||
let id = view.page_input_id();
|
||
if !ctx.memory(|m| m.has_focus(id))
|
||
&& let Some(doc) = self.session.get(view.id)
|
||
{
|
||
// Navigation and this command can arrive before the next rail frame.
|
||
view.page_input = view.current_page_label(doc.info.pages.get(view.current).map(|p| p.label.as_str()));
|
||
}
|
||
ctx.memory_mut(|m| m.request_focus(id));
|
||
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default();
|
||
let len = view.page_input.chars().count();
|
||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(len))));
|
||
state.store(&ctx, id);
|
||
}
|
||
}
|
||
"view.palette" => self.palette_open = !self.palette_open,
|
||
layout if crate::canvas::PageLayout::from_command(layout).is_some() => {
|
||
if let (Some(i), Some(layout)) = (active, crate::canvas::PageLayout::from_command(layout)) {
|
||
self.views[i].set_layout(layout);
|
||
}
|
||
}
|
||
// The Continuous scrolling checkbox (an independent toggle over the base layout).
|
||
"view.continuous" => {
|
||
if let Some(i) = active {
|
||
let v = &mut self.views[i];
|
||
v.set_continuous(!v.continuous());
|
||
}
|
||
}
|
||
"view.page_gaps" => {
|
||
if let Some(view) = active.and_then(|i| self.views.get(i)) {
|
||
self.set_active_page_gaps(!view.show_page_gaps);
|
||
}
|
||
}
|
||
"view.layout.cover" => {
|
||
if let Some(i) = active {
|
||
let v = &mut self.views[i];
|
||
v.set_cover(!v.cover);
|
||
}
|
||
}
|
||
"view.fit_width_scrolling" | "view.fit_one_page" => {
|
||
use crate::canvas::{Fit, PageLayout};
|
||
// Fit one full page shows a single page on its own; fit width scrolls it.
|
||
let (continuous, fit) = if id == "view.fit_one_page" { (false, Fit::Page) } else { (true, Fit::Width) };
|
||
if let Some(i) = active {
|
||
self.views[i].set_display(PageLayout::Single, continuous);
|
||
self.views[i].set_fit(fit);
|
||
}
|
||
}
|
||
"view.full_screen" => {
|
||
let on = !self.full_screen;
|
||
match self.ctx.clone() {
|
||
Some(ctx) => self.set_full_screen(&ctx, on),
|
||
None => self.apply_full_screen(on),
|
||
}
|
||
}
|
||
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
|
||
"view.previous_view" | "view.next_view" => {
|
||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||
return Some(v.view_history(id == "view.next_view"));
|
||
}
|
||
}
|
||
"view.next_tab" => self.step_tab(true),
|
||
"view.previous_tab" => self.step_tab(false),
|
||
"view.theme" => {
|
||
let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light };
|
||
self.set_theme_preference(next);
|
||
}
|
||
command if command.starts_with("measure.") => crate::measure_ui::command(self, command),
|
||
"view.theme.system" => self.set_theme_preference(ThemePreference::System),
|
||
"view.theme.light" => self.set_theme_preference(ThemePreference::Light),
|
||
"view.theme.dark" => self.set_theme_preference(ThemePreference::Dark),
|
||
"view.fit_visible" => {
|
||
if let Some(i) = self.active
|
||
&& let Err(e) = self.fit_visible(i)
|
||
{
|
||
self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]);
|
||
}
|
||
}
|
||
"view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom,
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
fn run_page_command(&mut self, id: &str, active: Option<usize>, targets: Vec<usize>) -> Option<bool> {
|
||
match id {
|
||
"page.combine" => self.open_combine_tab(),
|
||
"page.number" => {
|
||
if let Some(i) = active {
|
||
let v = &self.views[i];
|
||
let pages: Vec<usize> = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() };
|
||
let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0));
|
||
self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() };
|
||
}
|
||
self.dialog = Some(Dialog::NumberPages);
|
||
}
|
||
"page.properties" => {
|
||
if let Some(i) = active
|
||
&& let Some(doc) = self.session.get(self.views[i].id)
|
||
{
|
||
let pages = self.views[i].target_pages();
|
||
let orders = doc.tab_orders();
|
||
let mut theirs = pages.iter().map(|p| orders.get(*p).copied());
|
||
let first = theirs.next().flatten();
|
||
let before = if theirs.all(|o| o == first) { first } else { None };
|
||
let generation = doc.edit_generation();
|
||
self.page_props_draft = Some(crate::PagePropsDraft { doc: self.views[i].id, generation, pages, before, tab_order: before });
|
||
self.dialog = Some(Dialog::PageProps);
|
||
}
|
||
}
|
||
"bookmark.add" => self.bookmark_action(crate::panels::BmAction::New),
|
||
"bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure),
|
||
"bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles),
|
||
"page.organize" => {
|
||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||
if v.organize {
|
||
v.organize = false;
|
||
} else {
|
||
v.open_organize();
|
||
}
|
||
}
|
||
}
|
||
"page.rotate" => {
|
||
self.apply_edit(Edit::RotatePages { pages: targets, degrees: 90 });
|
||
}
|
||
"page.rotate_ccw" => {
|
||
self.apply_edit(Edit::RotatePages { pages: targets, degrees: -90 });
|
||
}
|
||
"page.delete" => {
|
||
self.apply_edit(Edit::DeletePages { pages: targets });
|
||
}
|
||
"page.insert_blank" => {
|
||
if let (Some(i), Some(&last)) = (active, targets.last())
|
||
&& let Some(p) = self.session.get(self.views[i].id).and_then(|d| d.info.pages.get(last))
|
||
{
|
||
let (w, h) = ((p.crop[2] - p.crop[0]).abs().max(1.0) as f64, (p.crop[3] - p.crop[1]).abs().max(1.0) as f64);
|
||
self.apply_edit(Edit::InsertBlankPage { at: last + 1, width: w, height: h });
|
||
}
|
||
}
|
||
"page.insert" => self.insert_from_file_dialog(),
|
||
"page.replace" => self.replace_pages_dialog(),
|
||
"page.copy" => self.copy_pages(false),
|
||
"page.cut" => self.copy_pages(true),
|
||
"page.paste" => self.paste_pages(),
|
||
"page.duplicate" => {
|
||
self.apply_edit(Edit::DuplicatePages { pages: targets, at: None });
|
||
}
|
||
// Two or more selected pages reverse among themselves; otherwise the whole document.
|
||
"page.reverse" => {
|
||
let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count());
|
||
let pages = if targets.len() >= 2 { targets } else { (0..all).collect() };
|
||
self.apply_edit(Edit::ReversePages { pages });
|
||
}
|
||
"page.crop" => {
|
||
self.quick_tool = crate::QuickTool::Crop;
|
||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||
v.organize = false;
|
||
}
|
||
self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes");
|
||
}
|
||
"page.boxes" => {
|
||
self.boxes_draft.seeded = None;
|
||
self.dialog = Some(Dialog::PageBoxes);
|
||
}
|
||
"page.extract" => self.open_extract_dialog(),
|
||
"page.rotate_dialog" => {
|
||
if let Some(i) = active {
|
||
let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len());
|
||
self.rotate_draft.to = n;
|
||
self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 };
|
||
}
|
||
self.dialog = Some(Dialog::RotatePages);
|
||
}
|
||
"page.split" => self.dialog = Some(Dialog::Split),
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
fn run_edit_command(&mut self, id: &str, active: Option<usize>) -> Option<bool> {
|
||
match id {
|
||
"edit.attach" => self.attach_file_dialog(),
|
||
"edit.undo" => self.undo(),
|
||
"edit.redo" => self.redo(),
|
||
"edit.find" => {
|
||
if let Some(i) = active {
|
||
self.views[i].open_find();
|
||
}
|
||
}
|
||
"comment.list" => self.choose_right_panel(Some(RightPanel::Comments)),
|
||
tool if crate::comments::CommentTool::from_command(tool).is_some() => {
|
||
let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return Some(false) };
|
||
self.comment_prefs.group_tool[tool.group()] = tool;
|
||
self.quick_tool = crate::QuickTool::Comment(tool);
|
||
// Acrobat opens the Comments panel with the commenting tools, unless the user closed it.
|
||
if self.right.is_none() && !self.comments_panel_closed {
|
||
self.choose_right_panel(Some(RightPanel::Comments));
|
||
}
|
||
// A text selection made before picking a markup tool is marked right away.
|
||
if let (Some(kind), Some(i)) = (tool.markup(), active)
|
||
&& let Some(doc) = self.session.get(self.views[i].id)
|
||
{
|
||
let info = &doc.info;
|
||
if let Some((page, quads)) = self.views[i].selection_quads(info) {
|
||
self.views[i].clear_selection();
|
||
let style = self.comment_prefs.style(tool);
|
||
let author = self.comment_prefs.author.clone();
|
||
let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads };
|
||
self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author }));
|
||
}
|
||
}
|
||
}
|
||
"comment.flatten" => {
|
||
self.apply_edit(Edit::Flatten { comments: true, fields: false });
|
||
}
|
||
"edit.header_footer"
|
||
| "edit.watermark"
|
||
| "edit.background"
|
||
| "edit.header_footer.update"
|
||
| "edit.watermark.update"
|
||
| "edit.background.update" => {
|
||
use pdfcraft_engine::MarkKind as K;
|
||
let kind = if id.starts_with("edit.header_footer") {
|
||
K::HeaderFooter
|
||
} else if id.starts_with("edit.watermark") {
|
||
K::Watermark
|
||
} else {
|
||
K::Background
|
||
};
|
||
self.marks_draft.replace = id.ends_with(".update");
|
||
self.marks_draft.opened(kind);
|
||
self.marks_draft.preview = None;
|
||
self.dialog = Some(Dialog::Marks(kind));
|
||
}
|
||
"edit.bates" => {
|
||
// Bates numbering ▸ Add: the header & footer dialog with a Bates number in the
|
||
// bottom-right box (6 digits from 1), ready to edit.
|
||
self.marks_draft.replace = false;
|
||
if self.marks_draft.hf.text.iter().all(|t| !t.contains("<<Bates")) {
|
||
self.marks_draft.hf.text[5] = "<<Bates Number#6#1##>>".into();
|
||
}
|
||
self.marks_draft.focused_box = 5;
|
||
self.marks_draft.preview = None;
|
||
self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter));
|
||
}
|
||
"edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => {
|
||
use pdfcraft_engine::MarkKind as K;
|
||
let kind = match id {
|
||
"edit.header_footer.remove" => K::HeaderFooter,
|
||
"edit.watermark.remove" => K::Watermark,
|
||
_ => K::Background,
|
||
};
|
||
self.apply_edit(Edit::RemoveMarks { kind });
|
||
}
|
||
"export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)),
|
||
"export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)),
|
||
"ocr.recognize" => self.dialog = Some(Dialog::RecognizeText),
|
||
"ocr.recognize_batch" => self.ocr_files_dialog(),
|
||
"edit.edit_text" => {
|
||
self.quick_tool = crate::QuickTool::EditText;
|
||
self.left = crate::LeftPanel::Tool("edit");
|
||
self.reveal_tools();
|
||
self.notify_tr("Click text or an image to edit it");
|
||
}
|
||
"edit.advanced_search" => {
|
||
if let Some(i) = self.active {
|
||
let f = self.views[i].find.get_or_insert_with(Default::default);
|
||
f.in_panel = true;
|
||
f.focus = true;
|
||
self.choose_right_panel(Some(crate::RightPanel::Search));
|
||
}
|
||
}
|
||
"export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)),
|
||
"edit.text" => {
|
||
self.quick_tool = crate::QuickTool::AddText;
|
||
self.left = crate::LeftPanel::Tool("edit");
|
||
self.reveal_tools();
|
||
}
|
||
"edit.image" => self.add_image_dialog(),
|
||
"edit.link" => {
|
||
self.quick_tool = crate::QuickTool::Link;
|
||
self.notify_tr("Drag a rectangle to create a link; double-click a link to edit it");
|
||
}
|
||
"edit.links_from_urls" => self.links_from_urls(),
|
||
"edit.remove_links" => self.remove_all_links(),
|
||
"comment.import" => self.import_comments_dialog(),
|
||
"form.import_data" => self.import_data_dialog(),
|
||
"comment.stamp" => {
|
||
self.left = crate::LeftPanel::Tool("stamp");
|
||
self.reveal_tools();
|
||
}
|
||
"comment.export" => self.export_data_dialog(true, false),
|
||
"comment.summarize" => self.dialog = Some(Dialog::SummarizeComments),
|
||
"edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect,
|
||
"edit.snapshot" => {
|
||
self.quick_tool = crate::QuickTool::Snapshot;
|
||
self.notify_tr("Drag a rectangle around the area to copy");
|
||
}
|
||
"edit.copy" => {
|
||
if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) {
|
||
return Some(self.copy_selected_text(i, &ctx));
|
||
}
|
||
return Some(false);
|
||
}
|
||
"comment.hide_all" => {
|
||
if let Some(i) = active {
|
||
let id = self.views[i].id;
|
||
let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden());
|
||
if self.session.set_hide_comments(id, hide) {
|
||
self.views[i].invalidate_content();
|
||
}
|
||
}
|
||
}
|
||
"export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx),
|
||
"export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx),
|
||
"export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html),
|
||
"export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf),
|
||
"ocr.enhance_camera" => {
|
||
// The corners start on the document's edges when they can be found, else
|
||
// inset on the current page (camera_ui::page_input).
|
||
self.quick_tool = crate::QuickTool::EnhanceCamera;
|
||
if let Some(i) = self.active
|
||
&& let Some(view) = self.views.get_mut(i)
|
||
&& let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned())
|
||
{
|
||
view.camera_done = None;
|
||
view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad {
|
||
page: view.current,
|
||
corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)),
|
||
grab: None,
|
||
});
|
||
}
|
||
self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page.");
|
||
}
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
fn run_form_command(&mut self, id: &str, active: Option<usize>) -> Option<bool> {
|
||
match id {
|
||
"form.fields" => self.choose_right_panel(Some(RightPanel::Fields)),
|
||
"form.calculation_order" => {
|
||
self.left = crate::LeftPanel::CalculationOrder;
|
||
// The page edits the fields, so Preview ends; the page grid closes, as for a tool.
|
||
self.form_preview = false;
|
||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||
v.organize = false;
|
||
}
|
||
self.reveal_tools();
|
||
}
|
||
"form.flatten" => {
|
||
// Flatten what's typed in a field too (#166); a refused value flattens nothing.
|
||
if self.commit_form_typing() {
|
||
self.apply_edit(Edit::Flatten { comments: false, fields: true });
|
||
}
|
||
}
|
||
"form.clear" => {
|
||
if let Some(i) = active {
|
||
self.views[i].forms.focus = None;
|
||
}
|
||
self.apply_edit(Edit::ResetForm { names: None });
|
||
}
|
||
"form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => {
|
||
let order = match id {
|
||
"form.tab_order.row" => pdfcraft_engine::TabOrder::Row,
|
||
"form.tab_order.column" => pdfcraft_engine::TabOrder::Column,
|
||
_ => pdfcraft_engine::TabOrder::Structure,
|
||
};
|
||
let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len());
|
||
self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order });
|
||
}
|
||
"sign.digital" | "sign.certify" => {
|
||
let certify = id == "sign.certify";
|
||
self.quick_tool = crate::QuickTool::SignArea { certify };
|
||
self.notify_tr("Drag to draw the area where the signature should appear.");
|
||
}
|
||
"sign.certify_invisible" => {
|
||
let page = active.map_or(0, |i| self.views[i].current);
|
||
self.start_signing(page, None, None, Some(2));
|
||
}
|
||
"sign.validate" => {
|
||
let certs = self.session.trusted_certificates().to_vec();
|
||
self.apply_trusted_certificates(certs);
|
||
self.choose_right_panel(Some(RightPanel::Signatures));
|
||
}
|
||
"sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)),
|
||
"form.export_data" => self.export_data_dialog(false, true),
|
||
"form.merge_data" => self.merge_data_dialog(),
|
||
"form.prepare" => {
|
||
self.left = crate::LeftPanel::Tool("form");
|
||
self.reveal_tools();
|
||
self.choose_right_panel(Some(RightPanel::Fields));
|
||
// Like Acrobat, a document without fields gets them detected on the way in.
|
||
if let Some((_, id)) = self.active_ids()
|
||
&& self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable())
|
||
{
|
||
self.detect_fields();
|
||
}
|
||
}
|
||
"form.detect" => self.detect_fields(),
|
||
"form.field.properties" => {
|
||
if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) {
|
||
self.open_field_props(&name, w);
|
||
}
|
||
}
|
||
field if crate::prepare::FieldTool::from_command(field).is_some() => {
|
||
let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return Some(false) };
|
||
self.quick_tool = crate::QuickTool::Field(tool);
|
||
self.left = crate::LeftPanel::Tool("form");
|
||
self.reveal_tools();
|
||
if let Some(i) = active {
|
||
self.views[i].forms.focus = None;
|
||
}
|
||
self.notify_fmt(
|
||
"Click on the page to add a {tool}, or drag to set its size",
|
||
&[("tool", &crate::i18n::in_sentence(tl!(tool.label())))],
|
||
);
|
||
}
|
||
"sign.fill.signature.remove" => self.signature = None,
|
||
"sign.fill.initials.remove" => self.initials = None,
|
||
"sign.fill.signature.change" | "sign.fill.initials.change" => {
|
||
let initials = id == "sign.fill.initials.change";
|
||
let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() };
|
||
self.signature_draft = saved.map_or_else(
|
||
|| crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author),
|
||
|s| crate::fill_sign::SigDraft::from_saved(initials, s),
|
||
);
|
||
self.dialog = Some(Dialog::Signature);
|
||
}
|
||
fill if crate::fill_sign::FillTool::from_command(fill).is_some() => {
|
||
let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) };
|
||
self.quick_tool = crate::QuickTool::Fill(tool);
|
||
let initials = tool == crate::fill_sign::FillTool::Initials;
|
||
if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) {
|
||
self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author);
|
||
self.dialog = Some(Dialog::Signature);
|
||
}
|
||
}
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
fn run_secure_command(&mut self, id: &str, active: Option<usize>) -> Option<bool> {
|
||
match id {
|
||
"a11y.check" => self.start_accessibility_check(),
|
||
"a11y.find_untagged" => self.find_untagged_content(),
|
||
"tools.js_console" => self.dialog = Some(Dialog::JsConsole),
|
||
"tools.document_js" => {
|
||
self.doc_js = Default::default();
|
||
self.dialog = Some(Dialog::DocumentJs);
|
||
}
|
||
"a11y.report" => self.show_accessibility_report(),
|
||
"a11y.autotag" => {
|
||
if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) {
|
||
self.choose_right_panel(Some(RightPanel::Tags));
|
||
}
|
||
}
|
||
"a11y.alt_text" => self.start_alt_text(),
|
||
"a11y.autotag_fields" => self.autotag_form_fields(),
|
||
"a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)),
|
||
"a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)),
|
||
"redact.mark" => {
|
||
self.quick_tool = crate::QuickTool::Redact;
|
||
self.left = crate::LeftPanel::Tool("redact");
|
||
self.reveal_tools();
|
||
// A text selection made first is marked right away.
|
||
if let Some(i) = active
|
||
&& let Some(doc) = self.session.get(self.views[i].id)
|
||
{
|
||
let info = &doc.info;
|
||
if let Some((page, quads)) = self.views[i].selection_quads(info) {
|
||
self.views[i].clear_selection();
|
||
let author = self.comment_prefs.author.clone();
|
||
self.apply_edit(self.redact_prefs.mark(page, quads, &author));
|
||
}
|
||
}
|
||
}
|
||
"redact.pages" => {
|
||
if let Some(i) = active {
|
||
let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len());
|
||
self.redact_pages_draft = crate::RedactPagesDraft { current: true, from: self.views[i].current + 1, to: n };
|
||
}
|
||
self.dialog = Some(Dialog::RedactPages);
|
||
}
|
||
"redact.search" => {
|
||
self.redact_search.found = None;
|
||
self.dialog = Some(Dialog::RedactSearch);
|
||
}
|
||
"redact.properties" => {
|
||
self.redact_before = Some(self.redact_prefs.clone());
|
||
self.dialog = Some(Dialog::RedactProps);
|
||
}
|
||
"redact.apply" => {
|
||
let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks());
|
||
if marks == 0 {
|
||
self.notify_tr("There are no redaction marks to apply");
|
||
} else {
|
||
self.dialog = Some(Dialog::RedactApply);
|
||
}
|
||
}
|
||
"redact.sanitize" => self.dialog = Some(Dialog::Sanitize),
|
||
"redact.clear" => {
|
||
self.apply_edit(Edit::ClearRedactions);
|
||
}
|
||
_ => return None,
|
||
}
|
||
Some(true)
|
||
}
|
||
|
||
/// Shows the next tab (or the previous one) in the strip's order, wrapping around at either
|
||
/// end (#1542). The Combine files tab, when open, comes after the documents. From Home the
|
||
/// next tab is the first and the previous one the last.
|
||
fn step_tab(&mut self, forward: bool) {
|
||
let count = self.views.len() + usize::from(self.combine_tab.open);
|
||
let current = if self.combine_showing() { Some(self.views.len()) } else { self.active };
|
||
let Some(next) = tab_after(current, count, forward) else { return };
|
||
if next < self.views.len() {
|
||
self.active = Some(next);
|
||
} else {
|
||
self.open_combine_tab();
|
||
}
|
||
}
|
||
|
||
/// Run the registered keyboard shortcuts (more specific combinations first).
|
||
pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) {
|
||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||
// Buttons also own keyboard focus for Tab/Enter activation, but they must not
|
||
// suppress document shortcuts. Text editors retain their own undo/redo handling.
|
||
let typing = ctx.text_edit_focused();
|
||
let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect();
|
||
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
|
||
for spec in specs {
|
||
let Some(s) = spec.shortcut else { continue };
|
||
// Keep the canvas's silent no-op when there is no previous/next view to visit.
|
||
if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) {
|
||
continue;
|
||
}
|
||
// A box typed into on the page (a paragraph being edited, added text) keeps Undo and
|
||
// Redo only while it holds typing the page doesn't have, or Redo has typing to put
|
||
// back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a
|
||
// Redo the box keeps is never taken as Undo.
|
||
let page_box = matches!(spec.id, "edit.undo" | "edit.redo")
|
||
&& self.page_box_focused(ctx)
|
||
&& !self.page_typing_unapplied()
|
||
&& !(spec.id == "edit.redo" && self.page_box_can_redo(ctx))
|
||
&& !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift));
|
||
if typing && !spec.in_text && !page_box {
|
||
continue;
|
||
}
|
||
let Some(key) = Key::from_name(s.key) else { continue };
|
||
let mut m = Modifiers::NONE;
|
||
if s.command {
|
||
m |= Modifiers::COMMAND;
|
||
}
|
||
if s.shift {
|
||
m |= Modifiers::SHIFT;
|
||
}
|
||
if s.mac_ctrl {
|
||
m |= Modifiers::CTRL;
|
||
}
|
||
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) {
|
||
self.dispatch_command(ctx, spec.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Run a registry command from its shortcut or a menu, which share this path.
|
||
pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) {
|
||
let active = self.active_ids();
|
||
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
|
||
if form_typing {
|
||
// A form field has the keyboard: let it take this frame's typing (and Escape) first,
|
||
// so ⌘S saves what's on screen (#166). Runs next frame, for this document only.
|
||
self.deferred_commands.push((id, active.map(|(_, id)| id)));
|
||
ctx.request_repaint();
|
||
} else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
|
||
// macOS ⌘W closes the last window when no document tabs remain (#869). Route through
|
||
// the normal viewport-close path (and its quit guard), not the disabled document-only
|
||
// `file.close` command.
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||
} else {
|
||
self.execute(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
|
||
/// no tab, forward is the first and back the last. `None` when there are no tabs.
|
||
fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usize> {
|
||
let last = count.checked_sub(1)?;
|
||
Some(match (current.filter(|&i| i <= last), forward) {
|
||
(None, true) => 0,
|
||
(None, false) => last,
|
||
(Some(i), true) => {
|
||
if i == last {
|
||
0
|
||
} else {
|
||
i + 1
|
||
}
|
||
}
|
||
(Some(i), false) => i.checked_sub(1).unwrap_or(last),
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use egui::os::OperatingSystem;
|
||
|
||
#[test]
|
||
fn localized_shortcut_labels_follow_language_and_platform() {
|
||
if !crate::i18n::needs_catalogs() {
|
||
return;
|
||
}
|
||
let previous = crate::i18n::current();
|
||
let ctx = egui::Context::default();
|
||
for os in [OperatingSystem::Windows, OperatingSystem::Nix] {
|
||
ctx.set_os(os);
|
||
crate::i18n::set_current(crate::i18n::Lang::from_code("de").unwrap());
|
||
assert_eq!(shortcut_label(&ctx, Shortcut::cmd("K")), "Strg+K");
|
||
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "Strg+Umschalt+S");
|
||
assert_eq!(command_shortcut_label(&ctx, "view.read_mode"), "Strg+H");
|
||
assert_eq!(command_modifier_label(&ctx), "Strg");
|
||
assert_eq!(Shortcut::cmd_shift("S").label(false), "Ctrl+Shift+S", "engine/control labels keep their stable spelling");
|
||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "Ctrl+Shift+S");
|
||
}
|
||
ctx.set_os(OperatingSystem::Mac);
|
||
crate::i18n::set_current(crate::i18n::Lang::from_code("de").unwrap());
|
||
assert_eq!(shortcut_label(&ctx, Shortcut::cmd_shift("S")), "⇧⌘S");
|
||
assert_eq!(command_shortcut_label(&ctx, "view.read_mode"), "⌃⌘H");
|
||
assert_eq!(command_modifier_label(&ctx), "⌘");
|
||
crate::i18n::set_current(previous);
|
||
}
|
||
|
||
/// Next and previous tab step one place and wrap around at either end; from no tab they go
|
||
/// to the first or the last (#1542).
|
||
#[test]
|
||
fn tab_steps_wrap_around_and_start_from_either_end() {
|
||
assert_eq!(tab_after(Some(0), 3, true), Some(1));
|
||
assert_eq!(tab_after(Some(2), 3, true), Some(0), "the last wraps to the first");
|
||
assert_eq!(tab_after(Some(1), 3, false), Some(0));
|
||
assert_eq!(tab_after(Some(0), 3, false), Some(2), "the first wraps to the last");
|
||
assert_eq!(tab_after(None, 3, true), Some(0));
|
||
assert_eq!(tab_after(None, 3, false), Some(2));
|
||
assert_eq!(tab_after(Some(0), 1, true), Some(0), "a single tab stays");
|
||
assert_eq!(tab_after(Some(7), 3, true), Some(0), "a stale index starts over");
|
||
assert_eq!(tab_after(None, 0, true), None);
|
||
assert_eq!(tab_after(Some(0), 0, false), None);
|
||
}
|
||
}
|