diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 8b5e1a5ba..f8b54487e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "df55a2ab783a02419d86c244cd793d4f42616ff842860b9ee8740960526f713d" +sha256 = "3066b34500d662ac6515806784a20b3b6f39d708dbaea26204995323c0a841f0" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4aee7a68592c36cc2bb828a51e71fdf6ed12e660bd80b2f214a6d3aef554d1d5" +sha256 = "b8d2b959ae42cd0d1b823d958142f997891ce463e6075b37614536a51b63c190" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "af2e8234147dbcc8da6414e213fe560312d44e816f0bde6271577c4d8041748b" +sha256 = "e336657e76e498264691f4c55af84ae69af803bae2564e26f87a2bbad31fdf8e" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7abd689f910f5b28f48dc9c0b2d3859965ad27c962d919a011efb959247edfee" +sha256 = "ca56752387255e66e027eeb6efcac0434a6467997f63e6cfba629a27dd316c9a" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9ebeb8cd5168cffaa027f7d4bc08b7987c09661027f64e4067b0975f456737bd" +sha256 = "a7e06aac931016658d143c0ce0246fcfa55bf8d88eee3b25162fcfa12eebd263" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9b160ff2781512e07eb9f5a6be39681eec0fd413c61e0becc11913ef9baac5e3" +sha256 = "4120337665604b2799961383d44ab0e01413da5685b233c9051f4ab1a8234790" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e7007083de3633c85fdf98b249e201f50100c3adfa2cf889acd03449a700b1e7" +sha256 = "0856ff8e37cb40b44f90ff122d638aca6b94c528994abd3b360f7ff01b74508e" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7315ad9ec7e672c9654a399f0142e8f78b9331c654048085f3b4b573edd51fa1" +sha256 = "1d891722c4bb69968eb3fca2c479ba9cf0d68fd6b2f0f3a5975f154246bdde0e" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "54ae61ed7eb2c93cf739200a6ec4b198ce40991eccebfca0bdc1eb40847d64cf" +sha256 = "3d95becdff6b37d8c1bb878578f6afcb0790f6519154e2dcef323344923abebe" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "46f5a79f8264f3ca3aba4fa5a95877e55f7a55dcee68834854fcc7ee24390c2c" +sha256 = "b1679b1fca5008f0a7d882204863ddfc9574195f2da0b1d8ef7d1b63b43d00bc" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "12d77d9d83b20cea4486f91628655bbd49440d550c6d4516412b448a289e284d" +sha256 = "5970a230e1bfaf9ee0e0eaa25af6d58017141a88a4859eb9fd7cd47282723d55" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "62e9d95a3dbf94024c059f3a3f7646e99119bffb1da0d60580933bfed6f03985" +sha256 = "428c8729b83bf044cc2134f6fd4b130b38c9072a3c488889cf1adb6b3df1352c" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9a2259eb0a18fae4290f4f5e03b147f689315d02a86a0b1bb9bf4541bc2f984c" +sha256 = "98ade2903b213f16e9ee196aac9d35c54b923e20750cec84343d53f0ee655fed" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 084c065a4..c49c07511 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -467,14 +467,14 @@ impl Document { } /// The name shown on the tab and window: the document title when the document asks for it - /// (Initial View ▸ Show: Document Title) and has one, else the file name. + /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { self.editor .as_ref() .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) .unwrap_or_else(|| self.name.clone()) } @@ -490,6 +490,16 @@ impl Document { } } +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + /// What is displayed: the working file, plus (in memory only, never saved) the appearances /// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]). /// The working file is unchanged, and so is everything that reads it (saving, signatures, …). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0cff7d7cb..9f956db51 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2405,3 +2405,14 @@ fn comments_without_appearances_are_drawn_but_not_saved() { assert!(reopened.get(r).as_dict().is_some_and(|d| !d.contains(b"AP")), "{r:?} is saved without /AP"); } } + +/// Titles a browser writes for a blank page or a pop-up name no document; real titles stay. +#[test] +fn placeholder_titles_are_recognised() { + for t in ["about:blank", "About:Blank", " about:srcdoc ", "blob:https://bank.example/3f2a", "data:text/html,x", "Untitled", "untitled"] { + assert!(is_placeholder_title(t), "{t:?}"); + } + for t in ["Quarterly report", "Untitled report", "About: our company", "Statement", "blank"] { + assert!(!is_placeholder_title(t), "{t:?}"); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7ef9361a4..314176c9e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -30,6 +30,33 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } // Keep the two 28-point buttons, Discord's text/padding and three gaps outside the scrolling tabs. let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 106.0; + // Open stays in sight after the tabs: its icon, text and padding, and the gaps around it. + let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0; + let mut tabs_width = (ui.available_width() - controls_width - open_width).max(0.0); + // Tabs shrink to share the strip (names end in "…") and scroll only once they reach + // their narrowest; then arrows either side say so and step through them. + let mut natural: Vec = Vec::with_capacity(app.views.len() + 1); + for v in &app.views { + if let Some(doc) = app.session.get(v.id) { + natural.push(tab_natural_width(ui, &t, &doc.display_name())); + } + } + if app.combine_tab.open { + natural.push(tab_natural_width(ui, &t, tl!("Combine files"))); + } + let gap = ui.spacing().item_spacing.x; + let overflow = tabs_overflow(&natural, tabs_width, gap, TAB_MIN_WIDTH); + if overflow { + tabs_width = (tabs_width - 2.0 * (TAB_ARROW + gap)).max(0.0); + } + let cap = tab_cap(&natural, tabs_width, gap, TAB_MIN_WIDTH); + // Where the tabs were scrolled to last frame, and how far they can go. + let scroll_id = ui.id().with("tab_scroll"); + let (offset, max_offset) = ui.data(|d| d.get_temp::<(f32, f32)>(scroll_id)).unwrap_or((0.0, 0.0)); + let mut scroll_to = None; + if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) { + scroll_to = Some((offset - (TAB_MIN_WIDTH + gap)).max(0.0)); + } let state = (app.active, app.views.len(), app.combine_showing()); let changed = ui.data_mut(|data| { let id = ui.id().with("active_tab"); @@ -40,47 +67,59 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut close = None; ui.scope(|ui| { ui.style_mut().always_scroll_the_only_direction = true; - egui::ScrollArea::horizontal() - .id_salt("document_tabs") - .max_width((ui.available_width() - controls_width).max(0.0)) - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - for i in 0..app.views.len() { - let Some(doc) = app.session.get(app.views[i].id) else { continue }; - let (name, dirty) = (doc.display_name(), doc.dirty); - let response = tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i); - if changed && app.active == Some(i) && !app.combine_showing() { - response.scroll_to_me(Some(Align::Center)); - } - if response.clicked() { - app.active = Some(i); - } + let mut area = egui::ScrollArea::horizontal().id_salt("document_tabs").max_width(tabs_width).auto_shrink([true, true]); + if let Some(x) = ui.data_mut(|d| d.remove_temp::(scroll_id.with("to"))) { + area = area.horizontal_scroll_offset(x); + } + let out = area.show(ui, |ui| { + ui.horizontal_centered(|ui| { + for i in 0..app.views.len() { + let Some(doc) = app.session.get(app.views[i].id) else { continue }; + let (name, dirty) = (doc.display_name(), doc.dirty); + // A tab showing the document's title names its file on hover. + let file = (name != doc.name).then_some(doc.name.as_str()); + let response = tab(ui, &t, "file-text", &name, file, dirty, app.active == Some(i), &mut close, i, cap); + if changed && app.active == Some(i) && !app.combine_showing() { + response.scroll_to_me(Some(Align::Center)); } - if let Some(i) = close { - app.request_close_tab(i); + if response.clicked() { + app.active = Some(i); } - if app.combine_tab.open { - // After the document tabs; its index can't clash with theirs. - let mut close = None; - let response = tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX); - if changed && app.combine_showing() { - response.scroll_to_me(Some(Align::Center)); - } - if response.clicked() { - app.open_combine_tab(); - } - if close.is_some() { - app.close_combine_tab(); - } + } + if let Some(i) = close { + app.request_close_tab(i); + } + if app.combine_tab.open { + // After the document tabs; its index can't clash with theirs. + let mut close = None; + let response = + tab(ui, &t, "files", tl!("Combine files"), None, false, app.combine_showing(), &mut close, usize::MAX, cap); + if changed && app.combine_showing() { + response.scroll_to_me(Some(Align::Center)); } - ui.add_space(4.0); - if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { - app.open_dialog(); + if response.clicked() { + app.open_combine_tab(); } - }); + if close.is_some() { + app.close_combine_tab(); + } + } }); + }); + let max = (out.content_size.x - out.inner_rect.width()).max(0.0); + ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max))); }); + if overflow && arrow(ui, "chevron-right", offset < max_offset - 0.5, tl!("Scroll tabs right")) { + scroll_to = Some((offset + TAB_MIN_WIDTH + gap).min(max_offset)); + } + if let Some(x) = scroll_to { + ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), x)); + ui.ctx().request_repaint(); + } + ui.add_space(4.0); + if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { + app.open_dialog(); + } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let (icon, label) = match app.theme_preference { ThemePreference::System => ("settings", tl!("Use system setting")), @@ -116,14 +155,86 @@ fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } +/// A tab's chrome around its name: the icon on the left, the close button on the right. +const TAB_CHROME: f32 = 64.0; +/// The narrowest a tab shrinks to before the strip scrolls: room for most of a name. +const TAB_MIN_WIDTH: f32 = 180.0; +/// The arrows either side of tabs that don't fit. +const TAB_ARROW: f32 = 24.0; + +/// Whether tabs of `natural` widths, `gap` apart, are wider than `budget` even at `min` each. +fn tabs_overflow(natural: &[f32], budget: f32, gap: f32, min: f32) -> bool { + let narrowest: f32 = natural.iter().map(|w| w.min(min)).sum(); + narrowest + gap * natural.len().saturating_sub(1) as f32 > budget +} + +/// A scroll arrow beside the tabs; dimmed (and inert) at its end. True when clicked. +fn arrow(ui: &mut egui::Ui, icon: &str, enabled: bool, tip: &str) -> bool { + ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, TAB_ARROW, false, tip)).inner.clicked() +} + +/// A tab name, at most 28 characters (longer names end in "…"). +fn tab_name(name: &str) -> String { + if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::()) } else { name.to_string() } +} + +/// The width a tab takes when nothing has to shrink. +fn tab_natural_width(ui: &egui::Ui, t: &Tokens, name: &str) -> f32 { + let label = crate::bidi::visual(&tab_name(name)).into_owned(); + ui.fonts_mut(|f| f.layout_no_wrap(label, theme::regular(13.0), t.text).size().x) + TAB_CHROME +} + +/// The widest any tab may be so that tabs of `natural` widths, `gap` apart, fit in `budget`: +/// narrow tabs keep their width and the rest share what is left equally (as browsers do). +/// `None` when every tab fits as it is; never below `min` (then the strip scrolls). +fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option { + let n = natural.len(); + if n == 0 { + return None; + } + let mut left = budget - gap * (n - 1) as f32; + if natural.iter().sum::() <= left { + return None; + } + let mut sorted = natural.to_vec(); + sorted.sort_by(f32::total_cmp); + for (k, w) in sorted.iter().enumerate() { + let share = left / (n - k) as f32; + if *w > share { + return Some(share.max(min)); + } + left -= w; + } + None +} + #[allow(clippy::too_many_arguments)] -fn tab(ui: &mut egui::Ui, t: &Tokens, icon: &str, name: &str, dirty: bool, active: bool, close: &mut Option, index: usize) -> egui::Response { +fn tab( + ui: &mut egui::Ui, + t: &Tokens, + icon: &str, + name: &str, + file: Option<&str>, + dirty: bool, + active: bool, + close: &mut Option, + index: usize, + cap: Option, +) -> egui::Response { let font = theme::regular(13.0); - let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::()) } else { name.to_string() }; + let mut label = tab_name(name); + // A shrunk tab shows as much of its name as fits (the tooltip has all of it). + if let Some(cap) = cap { + let room = (cap - TAB_CHROME).max(0.0); + label = ui.fonts_mut(|f| { + crate::panels::ellipsized_prefix(&label, |s| f.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= room) + }); + } // Painted text only: the accessibility name below keeps the logical order. let label = crate::bidi::visual(&label).into_owned(); let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); - let (rect, resp) = ui.allocate_exact_size(vec2(text_w + 64.0, 30.0), Sense::click()); + let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap)); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 30.0), Sense::click()); let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() }; resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, &a11y)); let bg = if active { @@ -150,7 +261,10 @@ fn tab(ui: &mut egui::Ui, t: &Tokens, icon: &str, name: &str, dirty: bool, activ if x.clicked() { *close = Some(index); } - let shown = crate::bidi::visual(name); + let shown = match file { + Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))), + None => crate::bidi::visual(name), + }; resp.on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) } else { shown.into_owned() }) } @@ -440,3 +554,43 @@ fn rail_view_menu(ui: &mut egui::Ui, view: &mut DocView) -> Option<&'static str> } picked } + +#[cfg(test)] +mod tab_widths { + use super::tab_cap; + + /// Tabs that fit keep their own widths. + #[test] + fn tabs_that_fit_do_not_shrink() { + assert_eq!(tab_cap(&[200.0, 150.0], 400.0, 4.0, 110.0), None); + assert_eq!(tab_cap(&[], 0.0, 4.0, 110.0), None); + } + + /// Too wide: narrow tabs keep their width, the wide ones share the rest equally, and + /// together they fill the strip exactly. + #[test] + fn wide_tabs_share_what_narrow_ones_leave() { + let natural = [120.0, 300.0, 300.0]; + let cap = tab_cap(&natural, 500.0, 4.0, 110.0).expect("must shrink"); + let used: f32 = natural.iter().map(|w| w.min(cap)).sum::() + 2.0 * 4.0; + assert!((used - 500.0).abs() < 0.01, "{cap} uses {used}"); + assert!((cap - 186.0).abs() < 0.01, "{cap}"); + } + + /// Arrows appear only when the tabs can't fit even at their narrowest. + #[test] + fn arrows_only_when_the_narrowest_tabs_overflow() { + use super::tabs_overflow; + assert!(!tabs_overflow(&[250.0; 3], 600.0, 4.0, 180.0), "three shrunk tabs fit"); + assert!(tabs_overflow(&[250.0; 4], 600.0, 4.0, 180.0), "four don't, even at 180"); + assert!(!tabs_overflow(&[100.0; 5], 600.0, 4.0, 180.0), "narrow tabs fit as they are"); + assert!(!tabs_overflow(&[], 0.0, 4.0, 180.0)); + } + + /// Tabs never get narrower than the minimum: past it, the strip scrolls instead. + #[test] + fn tabs_stop_at_their_minimum() { + assert_eq!(tab_cap(&[250.0; 12], 600.0, 4.0, 110.0), Some(110.0)); + assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0)); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index fcd5f281b..dc1851011 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2073,3 +2073,5 @@ action wizard Start Старт Printer properties Свойства на принтера Reset to printer defaults Възстанови настройките по подразбиране на принтера This printer offers no settings of its own. Този принтер няма собствени настройки. + Scroll tabs left Превърти разделите наляво + Scroll tabs right Превърти разделите надясно diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 696171983..5ce51404c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -125,3 +125,5 @@ Reverse open arrow Otevřená šipka obráceně Reverse closed arrow Zavřená šipka obráceně Slash Lomítko + Scroll tabs left Posunout karty doleva + Scroll tabs right Posunout karty doprava diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 9cbd57a4f..8b35a4fd8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2111,3 +2111,5 @@ action wizard Start Starten Printer properties Druckereigenschaften Reset to printer defaults Auf Druckerstandard zurücksetzen This printer offers no settings of its own. Dieser Drucker bietet keine eigenen Einstellungen. + Scroll tabs left Tabs nach links scrollen + Scroll tabs right Tabs nach rechts scrollen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f5dabbafd..6f4c7e9a7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2027,3 +2027,5 @@ Printer properties Propiedades de la impresora Reset to printer defaults Restablecer los valores predeterminados de la impresora This printer offers no settings of its own. Esta impresora no ofrece ajustes propios. + Scroll tabs left Desplazar las pestañas a la izquierda + Scroll tabs right Desplazar las pestañas a la derecha diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index db9c0c320..80e0dc1b5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2111,3 +2111,5 @@ action wizard Start Démarrer certificate Subject Titulaire comment menu Edit Modifier signature pad Type Saisir + Scroll tabs left Faire défiler les onglets vers la gauche + Scroll tabs right Faire défiler les onglets vers la droite diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3346103a3..171878e45 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2109,3 +2109,5 @@ action wizard Start Indítás Printer properties Nyomtató tulajdonságai Reset to printer defaults A nyomtató alapértékeinek visszaállítása This printer offers no settings of its own. Ennek a nyomtatónak nincsenek saját beállításai. + Scroll tabs left Lapok görgetése balra + Scroll tabs right Lapok görgetése jobbra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fd9d0bbb0..aaddeea29 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2111,3 +2111,5 @@ signature pad Type 入力 Flatten Fill & Sign when saving 保存時に入力と署名を統合する Text, marks and signatures become part of the page. Other comments stay editable. テキスト、マーク、署名がページの一部になります。その他のコメントは引き続き編集できます。 + Scroll tabs left タブを左にスクロール + Scroll tabs right タブを右にスクロール diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 3486ecb96..245f4a7d5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -79,3 +79,5 @@ Reverse open arrow Seta aberta invertida Reverse closed arrow Seta fechada invertida Slash Barra + Scroll tabs left Rolar as abas para a esquerda + Scroll tabs right Rolar as abas para a direita diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 073e91132..76154df57 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2024,3 +2024,5 @@ Printer properties Свойства принтера Reset to printer defaults Вернуть параметры принтера по умолчанию This printer offers no settings of its own. У этого принтера нет собственных параметров. + Scroll tabs left Прокрутить вкладки влево + Scroll tabs right Прокрутить вкладки вправо diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 9014a0ae8..9bcb8ed3b 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2042,3 +2042,5 @@ action wizard Start ప్రారంభించు Printer properties ప్రింటర్ లక్షణాలు Reset to printer defaults ప్రింటర్ డిఫాల్ట్‌లకు రీసెట్ చేయి This printer offers no settings of its own. ఈ ప్రింటర్‌కు దాని స్వంత సెట్టింగ్‌లు లేవు. + Scroll tabs left ట్యాబ్‌లను ఎడమకు స్క్రోల్ చేయి + Scroll tabs right ట్యాబ్‌లను కుడికి స్క్రోల్ చేయి diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 57075537f..0921a12aa 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2134,3 +2134,5 @@ calendar date December грудня Reset to printer defaults Скинути до налаштувань принтера за замовчуванням This printer offers no settings of its own. Цей принтер не має власних налаштувань. certificate Subject Власник сертифіката + Scroll tabs left Прокрутити вкладки ліворуч + Scroll tabs right Прокрутити вкладки праворуч diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c58327673..9c42b2ae6 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2075,3 +2075,5 @@ Printer properties 打印机属性 Reset to printer defaults 恢复打印机默认设置 This printer offers no settings of its own. 此打印机没有专属设置。 + Scroll tabs left 向左滚动选项卡 + Scroll tabs right 向右滚动选项卡 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9d51dfe95..a45f1cc90 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1997,3 +1997,5 @@ Reset to printer defaults 還原印表機預設值 This printer offers no settings of its own. 此印表機沒有專屬設定。 PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft 正在不使用圖形處理器的情況下繪製,因此可能較慢。更新顯示卡驅動程式可能會有幫助。 + Scroll tabs left 向左捲動索引標籤 + Scroll tabs right 向右捲動索引標籤 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 010e95887..aacc58c07 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -762,7 +762,7 @@ const LABEL_MEASURE_CHARS: usize = 64; /// boundary, and the loop always ends (at the ellipsis alone). A label is document text: only /// its first [`LABEL_MEASURE_CHARS`] characters are measured, so a huge one can't make each /// frame lay out thousands of candidates. -fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) -> String { +pub(crate) fn ellipsized_prefix(text: &str, mut fits: impl FnMut(&str) -> bool) -> String { let mut s: String = text.chars().take(LABEL_MEASURE_CHARS).collect(); if s.len() == text.len() && fits(text) { return text.to_owned(); diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 46a5e3dc2..ba0e3c918 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -172,6 +172,59 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { } } +/// Whether a node labelled `label` is there and enabled (`None`: not there). +fn enabled(h: &Harness<'static, PdfCraftApp>, label: &str) -> Option { + h.query_by_label(label)?; + Some(h.query_by(|n| n.label().as_deref() == Some(label) && n.is_disabled()).is_none()) +} + +#[test] +fn tabs_shrink_to_fit_then_arrows_step_through_them_and_open_stays_in_sight() { + // Five long names are wider than the strip: the tabs shrink instead of running under the + // buttons on the right, Open sits after them, and no arrows show. + let name = |i: usize| format!("Statement {i} for the office records, scanned.pdf"); + let mut h = harness(move |app| { + for i in 0..5 { + app.open_bytes(&name(i), None, FIXTURE.to_vec()).unwrap(); + } + }); + let open = h.get_by_label("Open").rect(); + let discord = h.get_by_label("Discord").rect(); + assert!(open.right() <= discord.left(), "Open {open:?} must not run under Discord {discord:?}"); + let mut previous_right = f32::MIN; + for i in 0..5 { + let tab = h.get_by_label(&name(i)).rect(); + assert!(tab.left() >= previous_right - 0.5, "tabs must not overlap: {i} at {tab:?}"); + assert!(tab.right() <= open.left() + 0.5, "every tab fits before Open: {i} at {tab:?}, Open at {open:?}"); + assert!(tab.width() >= 179.0, "tabs keep most of their name: {i} is {tab:?}"); + previous_right = tab.right(); + } + assert_eq!(enabled(&h, "Scroll tabs left"), None, "no arrows while every tab fits"); + // Seven don't fit even at their narrowest: arrows show. The newest tab is active and in + // sight at the end, so only the left arrow can go further. + for i in 5..7 { + h.state_mut().open_bytes(&name(i), None, FIXTURE.to_vec()).unwrap(); + } + h.run_steps(6); + assert_eq!((enabled(&h, "Scroll tabs left"), enabled(&h, "Scroll tabs right")), (Some(true), Some(false))); + let first = h.get_by_label(&name(0)).rect(); + h.get_by_label("Scroll tabs left").click(); + h.run_steps(4); + assert!(h.get_by_label(&name(0)).rect().left() > first.left(), "the left arrow scrolls the tabs back"); + assert_eq!(enabled(&h, "Scroll tabs right"), Some(true), "and then there is more to the right"); + let open = h.get_by_label("Open").rect(); + let right = h.get_by_label("Scroll tabs right").rect(); + assert!(right.right() <= open.left() + 0.5 && open.right() <= h.get_by_label("Discord").rect().left()); + // Twenty: Open stays in sight left of Discord. + for i in 0..13 { + h.state_mut().open_bytes(&format!("more-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap(); + } + h.run_steps(4); + let open = h.get_by_label("Open").rect(); + let discord = h.get_by_label("Discord").rect(); + assert!(open.left() > 0.0 && open.right() <= discord.left(), "Open {open:?} stays in sight left of Discord {discord:?}"); +} + #[test] fn narrow_tab_strips_accept_vertical_wheel_scrolling() { for width in [600.0, 900.0] { diff --git a/crates/ui-egui/tests/viewer.rs b/crates/ui-egui/tests/viewer.rs index af89def4b..46c04cc0f 100644 --- a/crates/ui-egui/tests/viewer.rs +++ b/crates/ui-egui/tests/viewer.rs @@ -331,6 +331,25 @@ fn tab_and_window_show_the_document_title_when_asked() { h.get_by_label_contains("Quarterly report"); } +#[test] +fn a_placeholder_title_leaves_the_file_name_on_the_tab_and_window() { + // A browser's "Save as PDF" of a blank page or pop-up titles the document "about:blank" + // and asks viewers to show the title: the file name says more. + use pdfcraft_engine::Edit; + let mut h = harness(); + { + let s = h.state_mut(); + let id = s.views[s.active.unwrap()].id; + s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "about:blank".into() }).unwrap(); + let mut v = s.session.get(id).unwrap().initial_view(); + v.display_title = true; + s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); + } + h.run_steps(2); + assert_eq!(h.state().window_title, "b.pdf — PdfCraft"); + assert!(h.query_by_label_contains("about:blank").is_none()); +} + #[test] fn an_earlier_revision_opens_from_document_properties() { use pdfcraft_engine::Edit;