mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
Tab strip: shrink tabs before scrolling, keep Open in sight; ignore about:blank titles (#612)
* M3: tabs shrink to fit before they scroll, and Open stays in sight With more documents open than fit, the tabs kept their full width: they ran under the buttons on the right (#416 made them scroll) and the Open button scrolled away with them. Now the tabs shrink first, as browsers do: narrow tabs keep their width, wider ones share the rest equally and end their names in "…" (the tooltip has the whole name). Only once they are at their narrowest does the strip scroll. Open sits after the tabs, outside the scrolling area, so it is always in sight next to the controls on the right. Refs #397. Co-Authored-By: Claude Opus 5.5 <[email protected]> * M3: a placeholder title like about:blank leaves the file name on the tab 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, so its tab and window said "about:blank". A one-word browser address (about:, blob:, data:) or a bare "Untitled" now counts as no title, and the file name is shown. A tab that does show a document title names its file on hover. Co-Authored-By: Claude Opus 5.5 <[email protected]> * M3: arrows step through tabs that don't fit, and tabs keep most of their name Tabs shrank to 110 points before the strip scrolled, so with many open they became a few characters each and nothing showed that more were hidden. Tabs now stop at 180 points (most of a name), and once even that doesn't fit, arrows appear either side of them: each steps about one tab, and an arrow is dimmed at its end, so it is clear whether more tabs lie that way. The mouse wheel still scrolls them, and the active tab is still brought into view. New labels "Scroll tabs left" / "Scroll tabs right" are translated in every catalog (machine-written; native review welcome). Refs #397. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: William Max & Co. <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
20 files changed
+328
-55
No files matched your search
+13
-13
@@ -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"
|
||||
|
||||
@@ -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, …).
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
+193
-39
@@ -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<f32> = 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::<f32>(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::<String>()) } 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<f32> {
|
||||
let n = natural.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut left = budget - gap * (n - 1) as f32;
|
||||
if natural.iter().sum::<f32>() <= 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<usize>, 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<usize>,
|
||||
index: usize,
|
||||
cap: Option<f32>,
|
||||
) -> egui::Response {
|
||||
let font = theme::regular(13.0);
|
||||
let label: String = if name.chars().count() > 28 { format!("{}…", name.chars().take(27).collect::<String>()) } 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::<f32>() + 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));
|
||||
}
|
||||
}
|
||||
@@ -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 Превърти разделите надясно
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 81.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -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 タブを右にスクロール
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2024,3 +2024,5 @@
|
||||
Printer properties Свойства принтера
|
||||
Reset to printer defaults Вернуть параметры принтера по умолчанию
|
||||
This printer offers no settings of its own. У этого принтера нет собственных параметров.
|
||||
Scroll tabs left Прокрутить вкладки влево
|
||||
Scroll tabs right Прокрутить вкладки вправо
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 ట్యాబ్లను కుడికి స్క్రోల్ చేయి
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -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 Прокрутити вкладки праворуч
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2075,3 +2075,5 @@
|
||||
Printer properties 打印机属性
|
||||
Reset to printer defaults 恢复打印机默认设置
|
||||
This printer offers no settings of its own. 此打印机没有专属设置。
|
||||
Scroll tabs left 向左滚动选项卡
|
||||
Scroll tabs right 向右滚动选项卡
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 向右捲動索引標籤
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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();
|
||||
|
||||
@@ -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<bool> {
|
||||
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] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user