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:
authored and GitHub committed 2026-10-09 18:56:45 -04:00
1 parent 36468f0ad4
commit 4fc99e4845
20 files changed
+328 -55

No files matched your search

+13 -13
View File
@@ -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"
+12 -2
View File
@@ -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, …).
+11
View File
@@ -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
View File
@@ -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));
}
}
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+1 -1
View File
@@ -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();
+53
View File
@@ -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] {
+19
View File
@@ -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;