mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 21:48:09 -06:00
Combine files shows each file as a card (the default; Grid/List kept in the settings, --combine-view): the first page it adds, stacked when it adds more, its name shortened in the middle. Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar, selection and history. Locked, unreadable, bad-range and unmeasurable files say why. Thumbnails render off the UI thread, only for cards in view: at most three render threads at once, counted until they exit (no watchdog replacements), a 32 MB texture budget, keys that never show another page, password or file, and one heavy step per frame on the web. Cards are sized from 60% to 200% (toolbar slider and buttons, Ctrl +/-/0, Ctrl+wheel or pinch over the grid, kept in the settings, --combine-zoom), keeping the card under the pointer in place. Hovering a card shows its actions over the top of its page: remove that file, and a magnifier that shows it large, flipping through the pages it adds; the same on a right click, and Space by keyboard. The toolbar wraps between groups at narrow widths. RenderPool gains retire(), without_replacements() and threads() so a caller can bound the render threads that exist at once. Co-authored-by: Andrew Taylor <andrew.taylor@tayeng.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Andrew Taylor
Claude Opus 5.5
Brandon Thomas
parent
65e89aefb2
commit
5268047a33
+14
-14
@@ -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 = "90c0df152b440991ba6654812961dd885674e9b9e93c37d66a3d241df71bd28a"
|
||||
sha256 = "5d982ffe0c946c4dc9930901a23e0a6221cff8ecd8481f704b06003719cd6fe0"
|
||||
|
||||
[[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 = "2d4027cb7577efab49f523879873fefda78efb743e091a178fa1f92df37203cc"
|
||||
sha256 = "eb612b49df91777bbb89625eb0257707a389720349b018fc954afe7ebfb02970"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.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 = "bd2d867653f3b5089941e52c1964028d91c164138e7142728b0d81e4b8dd295c"
|
||||
sha256 = "9b6ef3dd77e51982a8e23eb4e4182c16c590553b060ab141283e851e93ee2041"
|
||||
|
||||
[[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 = "38f53f6e2413fddb747c362a1354d6627ded42f9bfc1e718cc61b90eeec5b980"
|
||||
sha256 = "7708e9b9a12d1896261e3e010addd61b4313c5126959f5b9d613d67ee469cc33"
|
||||
|
||||
[[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 = "4f58ca9ef332e525277bdf6f9a8259d34b38f2c1607b1b5952fcc5e341c990ab"
|
||||
sha256 = "36bf3703711e7730a2b6995e6949f120bf0a4ff7ef6836c129d396d66ee61cac"
|
||||
|
||||
[[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 = "8f004e5abf2d5977771991b4f9e7296367b7e3911e5460e599550ce45c5a603e"
|
||||
sha256 = "89e5a0f086d176a92addd6dfa327aa7e9ee619e6cab640a3353c07390c45575c"
|
||||
|
||||
[[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 = "2454a4c6c6825b53ed259927bfc5ad914b0f7b88bb9418ccbb6c60053f4f58d0"
|
||||
sha256 = "1e24555846cacfc8e84ed6900e2a495ecf994bf7bd96bc01733b48507518d2a3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8c9e0815bb64a06f3cd3536649560183ba17a02928d6aed834ba895b8a51c0fc"
|
||||
sha256 = "2772eb00b4ccdf378bf311ca82510202a4b9b6c948706378e3c319c8aa0713a2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -118,7 +118,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 = "0c794ec4c554485a4f306010550516ae1e4d05ddf9a4f707f01a023839e516f0"
|
||||
sha256 = "e273af2513c3bc8db051b7f80fedeaae36aaba53486e54f608fc227497d43a7f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -129,7 +129,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 = "667849f4f8082ab6622c8d644fdc29f6db0cc05035c5b93faab2ee0e5b0b1bf6"
|
||||
sha256 = "4c044110ea4feef81e8561b510bd4bcf89beabc3e32a42b94265cf3d46692b72"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -140,7 +140,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 = "ad3ce6eb44c7b5b10180c574f6c91ef6e56eac833a1b385afae873ad794612cc"
|
||||
sha256 = "d20714c060877abcea56c7d0f638a5bf1e31f698d606086d741985c03b10ff7a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -151,7 +151,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 = "895ec4f4814f74bcadc3c949735fa3cf4907255e9807367fe2c7a5e2875e5764"
|
||||
sha256 = "361e015a2ebfb507d4c59f5b2450c9112fe0a7e3d4ad5ba77e88f33f473bdd75"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -162,7 +162,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 = "89fd25329037709bac9dafbdf63e08ee22d4a5d3d99c70671614e5e5e0ac65a5"
|
||||
sha256 = "48114bed18d577a6aa00dab59241ad26a22d369b41cc4f60cecd716e7921dbc4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "fad8447c713a75691b29b4395d55368a6154df568b5bb67a26eaee00bdfb6459"
|
||||
sha256 = "e073dfb9aa269b5174b5b9e997629ffd988cc310afb461d6fa3185a9da050776"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! `--page N --zoom 150 --layout continuous|two-up|single --panel comments|bookmarks|pages|fields|layers|attachments|none
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>`
|
||||
//! --cover on|off --default-layout continuous|two-up|single --default-zoom fit-width|fit-page|<percent>
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
|
||||
|
||||
@@ -20,8 +20,8 @@ pub use inspect::{
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Tile,
|
||||
device_pixels, effective_scale,
|
||||
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, Retiring,
|
||||
Tile, device_pixels, effective_scale,
|
||||
};
|
||||
pub use text::{PageText, TextGlyph};
|
||||
|
||||
|
||||
@@ -883,6 +883,48 @@ impl RenderPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pool's threads after [`RenderPool::retire`]. Idle ones exit at once; one still rendering is
|
||||
/// told to stop at its next drawing operator and exits when its render returns, which a single
|
||||
/// long operator (such as decoding a huge image) can delay.
|
||||
pub struct Retiring(Vec<JoinHandle<()>>);
|
||||
|
||||
impl Retiring {
|
||||
/// The threads that haven't exited yet.
|
||||
pub fn running(&mut self) -> usize {
|
||||
self.0.retain(|h| !h.is_finished());
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Whether every thread has exited.
|
||||
pub fn exited(&self) -> bool {
|
||||
self.0.iter().all(JoinHandle::is_finished)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderPool {
|
||||
/// No replacement threads: when the watchdog gives up on a render, the request (and any
|
||||
/// later one) is answered with an error instead of starting another thread. For a caller that
|
||||
/// bounds how many render threads exist at once: the stuck thread keeps running until its
|
||||
/// operator returns, and a replacement would run beside it.
|
||||
pub fn without_replacements(self) -> Self {
|
||||
*lock(&self.replacements_left) = 0;
|
||||
self
|
||||
}
|
||||
|
||||
/// This pool's threads that haven't exited (workers and replacements; 0 when rendering inline).
|
||||
pub fn threads(&self) -> usize {
|
||||
lock(&self._workers).iter().filter(|h| !h.is_finished()).count()
|
||||
}
|
||||
|
||||
/// Drop the pool but keep its threads' handles, for a caller that bounds how many render
|
||||
/// threads run at once: a render the watchdog gave up on can outlive its pool.
|
||||
pub fn retire(self) -> Retiring {
|
||||
let handles = std::mem::take(&mut *lock(&self._workers));
|
||||
drop(self);
|
||||
Retiring(handles)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RenderPool {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1294,6 +1336,58 @@ mod tests {
|
||||
assert!(initializer.join().unwrap().unwrap().is_some());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_retired_pool_counts_its_threads_until_they_exit() {
|
||||
// Idle: its thread leaves as soon as the pool is gone.
|
||||
let mut idle = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).retire();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while idle.running() > 0 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(idle.running(), 0);
|
||||
// Busy in a step that can't be interrupted: still counted after the pool is dropped.
|
||||
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
|
||||
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2)));
|
||||
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]);
|
||||
while pool.shared.render_started.load(Ordering::Relaxed) == 0 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let mut busy = pool.retire();
|
||||
assert_eq!(busy.running(), 1, "the render is still under way");
|
||||
while busy.running() > 0 && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(busy.running(), 0, "and the thread exits when it returns");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn a_pool_without_replacements_never_runs_a_second_thread() {
|
||||
// The watchdog gives up on a render stuck in one long step; with no replacement, the
|
||||
// request is answered with an error and the stuck thread is the only one.
|
||||
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default()).without_replacements();
|
||||
pool.set_stuck_after(std::time::Duration::from_millis(50));
|
||||
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_secs(2)));
|
||||
pool.set_queue(vec![RenderRequest { page: 0, scale: 1.0, ..Default::default() }]);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let answer = loop {
|
||||
assert!(pool.threads() <= 1, "{} render threads", pool.threads());
|
||||
if let Some(page) = pool.try_recv() {
|
||||
break page;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "the stuck render is answered");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert!(answer.error.is_some(), "answered with an error, not a second attempt");
|
||||
assert!(pool.threads() <= 1);
|
||||
let mut retired = pool.retire();
|
||||
while retired.running() > 0 {
|
||||
assert!(std::time::Instant::now() < deadline, "the stuck thread exits when its step ends");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn page_panic_retires_shared_generation_and_healthy_pages_use_private_parsers() {
|
||||
|
||||
@@ -60,7 +60,7 @@ fn texture_bytes(tex: &TextureHandle) -> usize {
|
||||
|
||||
/// Visible grid rows, plus one adjacent row in either direction. Work is independent of
|
||||
/// document length, including after a large scroll jump.
|
||||
fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range<usize> {
|
||||
pub(crate) fn thumbnail_rows(top: f32, bottom: f32, row_height: f32, rows: usize) -> Range<usize> {
|
||||
let first = (top.max(0.0) / row_height).floor() as usize;
|
||||
let end = (bottom.max(0.0) / row_height).ceil() as usize;
|
||||
first.saturating_sub(1).min(rows)..end.saturating_add(1).min(rows)
|
||||
@@ -3411,7 +3411,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b
|
||||
/// Organize pages: a thumbnail grid (Acrobat's Organize Pages view). Click selects, ⌘-click
|
||||
/// toggles, ⇧-click extends; double-click opens the page.
|
||||
/// The gap (0 = before the first page, n = after the last) the pointer points at in the grid.
|
||||
fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
|
||||
pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option<usize> {
|
||||
let (i, r) = cells.iter().min_by(|(_, a), (_, b)| a.distance_sq_to_pos(p).total_cmp(&b.distance_sq_to_pos(p)))?;
|
||||
Some(if p.x < r.center().x { *i } else { i + 1 })
|
||||
}
|
||||
@@ -3662,7 +3662,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
|
||||
|
||||
/// A rendered raster as texture data. Its words are premultiplied RGBA bytes, exactly
|
||||
/// `Color32`s, so the renderer's buffer becomes the image without a copy on the UI thread.
|
||||
fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
|
||||
pub(crate) fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
|
||||
match bytemuck::allocation::try_cast_vec::<u32, Color32>(pixels.into_words()) {
|
||||
Ok(px) if px.len() == size[0].saturating_mul(size[1]) => egui::ColorImage::new(size, px),
|
||||
Ok(px) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&px)),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,8 @@ use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
|
||||
/// Same reds and ambers as the signature status (`sign_ui`).
|
||||
const ERROR: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F);
|
||||
const WARNING: Color32 = Color32::from_rgb(0xE6, 0x86, 0x19);
|
||||
pub(crate) const ERROR: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F);
|
||||
pub(crate) const WARNING: Color32 = Color32::from_rgb(0xE6, 0x86, 0x19);
|
||||
/// Undo steps kept for the list (each holds the list itself; the files' bytes are shared).
|
||||
const HISTORY: usize = 100;
|
||||
|
||||
@@ -113,6 +113,32 @@ impl Columns {
|
||||
}
|
||||
}
|
||||
|
||||
/// How the files are shown: a thumbnail of each (the default, to see what goes where) or the
|
||||
/// table (kept in the settings).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum CombineView {
|
||||
#[default]
|
||||
Grid,
|
||||
List,
|
||||
}
|
||||
|
||||
impl CombineView {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
CombineView::Grid => "grid",
|
||||
CombineView::List => "list",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"grid" => Some(CombineView::Grid),
|
||||
"list" => Some(CombineView::List),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A column heading being dragged to a new place.
|
||||
#[derive(Clone, Copy)]
|
||||
struct HeadingDrag(SortKey);
|
||||
@@ -152,6 +178,39 @@ pub struct CombineTab {
|
||||
fitted: f32,
|
||||
/// The password box of Unlock…, while it shows.
|
||||
unlock: Option<UnlockPrompt>,
|
||||
/// Bumped on every change to the list, so a grid drag that began before one is dropped.
|
||||
pub(crate) revision: u64,
|
||||
/// The file the keyboard moved to, for the grid to scroll into view.
|
||||
pub(crate) reveal: Option<u64>,
|
||||
/// Files per grid row as last drawn (Up/Down move a row in the grid).
|
||||
pub(crate) grid_columns: usize,
|
||||
/// A grid zoom asked for this frame (toolbar, keys, pinch); the grid applies it once it has
|
||||
/// laid out, so it can keep the same cards in view.
|
||||
pub(crate) zoom_request: Option<f32>,
|
||||
/// The card size slider, which takes the arrow keys while it has the keyboard.
|
||||
pub(crate) zoom_slider: Option<egui::Id>,
|
||||
/// The card kept in place through a zoom gesture: its file, how far below the top of the grid
|
||||
/// it was, and when the grid last zoomed (seconds).
|
||||
pub(crate) zoom_anchor: Option<(u64, f32, f64)>,
|
||||
/// A file shown large (a card's magnifier), while it shows.
|
||||
pub(crate) preview: Option<Preview>,
|
||||
/// The card that had the keyboard last frame: Space shows its file large; on any other focused
|
||||
/// widget Space is that widget's.
|
||||
pub(crate) card_focus: Option<egui::Id>,
|
||||
}
|
||||
|
||||
/// The pages a range takes, and the range and page count they were worked out for.
|
||||
pub(crate) type PagesTaken = ((String, usize), Arc<[usize]>);
|
||||
|
||||
/// A file shown large: which, the place among the pages it adds, and those pages (worked out
|
||||
/// again only when its page range or page count changes).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Preview {
|
||||
pub(crate) file: u64,
|
||||
pub(crate) at: usize,
|
||||
/// The page (0-based, in the file) on screen, for the thumbnails to render.
|
||||
pub(crate) page: Option<usize>,
|
||||
pub(crate) pages: Option<PagesTaken>,
|
||||
}
|
||||
|
||||
/// A password: kept in memory only, never printed (debug output ends up in logs).
|
||||
@@ -188,6 +247,8 @@ struct UnlockPrompt {
|
||||
/// What can be known about a file up front, opened with a password or not.
|
||||
struct Assessed {
|
||||
pages: usize,
|
||||
/// Each page's displayed size in points (empty when the inspector couldn't read it).
|
||||
sizes: Arc<[(f32, f32)]>,
|
||||
problem: Option<String>,
|
||||
lock: Option<Lock>,
|
||||
notes: Vec<String>,
|
||||
@@ -216,6 +277,10 @@ fn assess(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<Assessed, Asse
|
||||
Some(i) => i.pages.len(),
|
||||
None => pdfcraft_engine::source_page_count(bytes, password).unwrap_or(0),
|
||||
};
|
||||
let sizes: Arc<[(f32, f32)]> = match &info {
|
||||
Some(i) => i.pages.iter().map(|p| (p.width, p.height)).collect(),
|
||||
None => Arc::from(Vec::new()),
|
||||
};
|
||||
let encrypted = password.is_some() || info.as_ref().is_some_and(|i| i.encrypted);
|
||||
let warnings = info.map(|i| i.warnings).unwrap_or_default();
|
||||
let (problem, lock) = match check {
|
||||
@@ -239,7 +304,7 @@ fn assess(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<Assessed, Asse
|
||||
});
|
||||
}
|
||||
notes.extend(warnings.into_iter().take(5));
|
||||
Ok(Assessed { pages, problem, lock, notes })
|
||||
Ok(Assessed { pages, sizes, problem, lock, notes })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -258,6 +323,15 @@ pub(crate) struct Incoming {
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// A new value for [`CombineFile::auth`]: counted for the whole run, outside the undo history.
|
||||
fn next_auth() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// A page range checked: the pages it takes and the first of them (0-based), or why it's wrong.
|
||||
type RangeCheck = Result<(usize, usize), String>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CombineFile {
|
||||
/// Stable while the file is in the list (selection, undo).
|
||||
@@ -265,6 +339,11 @@ pub struct CombineFile {
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub pages: usize,
|
||||
/// Each page's displayed size in points; shared, so undo snapshots don't copy it.
|
||||
pub(crate) sizes: Arc<[(f32, f32)]>,
|
||||
/// How the file was read (with which password): a new value every time it is read, never
|
||||
/// reused, not even after undo, so a thumbnail made another way is never taken for this one.
|
||||
pub(crate) auth: u64,
|
||||
/// The pages to take ("" = all).
|
||||
pub range: String,
|
||||
/// When the file was last modified (desktop picks and drops only).
|
||||
@@ -279,34 +358,68 @@ pub struct CombineFile {
|
||||
pub notes: Vec<String>,
|
||||
/// Where it came from, shown among the warnings (e.g. an open document's unsaved changes).
|
||||
origin_note: Option<String>,
|
||||
/// The range last checked and the result: the number of pages it takes, or why it is wrong.
|
||||
checked: Option<(String, Result<usize, String>)>,
|
||||
/// The range last checked and the result: the number of pages it takes and the first of
|
||||
/// them (0-based), or why it is wrong.
|
||||
checked: Option<(String, RangeCheck)>,
|
||||
}
|
||||
|
||||
impl CombineFile {
|
||||
fn take(&mut self, a: Assessed, password: Option<Secret>) {
|
||||
self.pages = a.pages;
|
||||
self.sizes = a.sizes;
|
||||
self.problem = a.problem;
|
||||
self.lock = a.lock;
|
||||
self.password = password;
|
||||
self.auth = next_auth();
|
||||
self.notes = self.origin_note.iter().cloned().chain(a.notes).collect();
|
||||
self.checked = None;
|
||||
}
|
||||
|
||||
/// The password the file was unlocked with, if any.
|
||||
pub(crate) fn password(&self) -> Option<&str> {
|
||||
self.password.as_ref().map(|p| p.0.as_str())
|
||||
}
|
||||
|
||||
/// The number of pages `range` takes, or why it can't be used. Checked again only when the
|
||||
/// range changes.
|
||||
pub fn selection(&mut self) -> Result<usize, String> {
|
||||
self.checked_range().map(|(count, _)| count)
|
||||
}
|
||||
|
||||
/// The first page (0-based) the range takes, in its order ("3, 1" starts with page 3): the
|
||||
/// page the grid shows for the file.
|
||||
pub fn first_page(&mut self) -> Result<usize, String> {
|
||||
self.checked_range().map(|(_, first)| first)
|
||||
}
|
||||
|
||||
/// Every page (0-based) the range takes, in its order: what the preview flips through.
|
||||
pub(crate) fn pages_taken(&self) -> Result<Vec<usize>, String> {
|
||||
if self.range.trim().is_empty() {
|
||||
return Ok((0..self.pages).collect());
|
||||
}
|
||||
match pdfcraft_engine::print::select_pages(self.pages, Some(&self.range), &[], pdfcraft_engine::print::Subset::All, false) {
|
||||
Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()),
|
||||
Ok(p) => Ok(p),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_range(&mut self) -> RangeCheck {
|
||||
if let Some((range, result)) = &self.checked
|
||||
&& *range == self.range
|
||||
{
|
||||
return result.clone();
|
||||
}
|
||||
let result = if self.range.trim().is_empty() {
|
||||
Ok(self.pages)
|
||||
// (A file whose pages couldn't be counted is left to the engine, as before; the grid
|
||||
// checks the first page against the page count.)
|
||||
Ok((self.pages, 0))
|
||||
} else {
|
||||
match pdfcraft_engine::print::select_pages(self.pages, Some(&self.range), &[], pdfcraft_engine::print::Subset::All, false) {
|
||||
Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()),
|
||||
Ok(p) => Ok(p.len()),
|
||||
Ok(p) => match p.first() {
|
||||
Some(first) => Ok((p.len(), *first)),
|
||||
None => Err(tl!("No pages selected").to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
@@ -326,23 +439,39 @@ impl CombineFile {
|
||||
}
|
||||
}
|
||||
|
||||
enum RowAction {
|
||||
pub(crate) enum RowAction {
|
||||
/// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it).
|
||||
Click(usize, Modifiers),
|
||||
SelectAll,
|
||||
/// Up/Down arrow; with Shift the selection grows.
|
||||
/// An arrow key: `by` files on (1 in the list, or along a grid row; a grid row's length for
|
||||
/// Up/Down in the grid). With Shift the selection grows.
|
||||
Step {
|
||||
down: bool,
|
||||
by: usize,
|
||||
extend: bool,
|
||||
},
|
||||
MoveUp,
|
||||
MoveDown,
|
||||
Remove,
|
||||
/// One file's card trash: that file only, whatever else is selected.
|
||||
RemoveFile(u64),
|
||||
/// One file's magnifier: show it large.
|
||||
Preview(u64),
|
||||
/// Space: show the file the keyboard is on (or the first selected) large.
|
||||
PreviewSelected,
|
||||
/// Row `from` (and the rest of the selection, if it is selected) dropped on row `to`.
|
||||
Drop {
|
||||
from: usize,
|
||||
to: usize,
|
||||
},
|
||||
/// A grid card (and the rest of the selection, if it is selected) dropped at `gap`
|
||||
/// (0 = before the first file, n = after the last), if the list hasn't changed since the
|
||||
/// drag began.
|
||||
DropAt {
|
||||
file: u64,
|
||||
revision: u64,
|
||||
gap: usize,
|
||||
},
|
||||
Sort(SortKey),
|
||||
/// Unlock… on row `i` (and the rest of the selection, if it is selected), or on the
|
||||
/// selection (`None`); the password box opens at the position.
|
||||
@@ -369,7 +498,25 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let selected: Vec<bool> = ids.iter().map(|id| app.combine_tab.selected.contains(id)).collect();
|
||||
let count = selected.iter().filter(|s| **s).count();
|
||||
let blocker = blocker(&app.combine_draft, &checks);
|
||||
let mut events = TableEvents { action: keyboard(ui, n, count > 0), ..Default::default() };
|
||||
let grid = app.combine_view == CombineView::Grid;
|
||||
let columns = grid.then_some(app.combine_tab.grid_columns);
|
||||
// The keyboard is the list's unless a text field, the password box or the size slider has it.
|
||||
let slider_focused = grid && app.combine_tab.zoom_slider.is_some_and(|id| ui.memory(|m| m.has_focus(id)));
|
||||
// (Nor while a file is shown large: its window may only open or close this frame.)
|
||||
let covered = app.combine_tab.unlock.is_some() || app.combine_tab.preview.is_some();
|
||||
let focus = ui.memory(|m| m.focused());
|
||||
let space = focus.is_none() || focus == app.combine_tab.card_focus;
|
||||
let keys = if covered || slider_focused { None } else { keyboard(ui, n, count > 0, columns, space) };
|
||||
let mut events = TableEvents { action: keys, ..Default::default() };
|
||||
// ⌘+ / ⌘− / ⌘0 size the grid's cards (instead of the whole window's text).
|
||||
if grid && n > 0 && !covered {
|
||||
app.combine_tab.zoom_request = zoom_keys(ui, app.combine_zoom).or(app.combine_tab.zoom_request);
|
||||
}
|
||||
// An arrow pressed or a zoom asked for in the list has nothing for the grid to do later.
|
||||
if !grid {
|
||||
app.combine_tab.reveal = None;
|
||||
app.combine_tab.zoom_request = None;
|
||||
}
|
||||
let mut combine = false;
|
||||
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(28, 20)).show(ui, |ui| {
|
||||
@@ -386,12 +533,19 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Files are combined from top to bottom. Leave Pages empty to take every page.")).color(t.text_faint));
|
||||
let hint = if grid {
|
||||
tl!("Files are combined in the order shown. Drag them to change it.")
|
||||
} else {
|
||||
tl!("Files are combined from top to bottom. Leave Pages empty to take every page.")
|
||||
};
|
||||
ui.label(egui::RichText::new(hint).color(t.text_faint));
|
||||
ui.add_space(12.0);
|
||||
|
||||
// Toolbar.
|
||||
ui.horizontal(|ui| {
|
||||
// Toolbar: on two lines when the window is narrow.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
// Where the line ends (an overflowing line would move it).
|
||||
let edge = ui.max_rect().right();
|
||||
add_menu(app, ui);
|
||||
let open_docs: Vec<(usize, String)> =
|
||||
app.views.iter().enumerate().filter_map(|(i, v)| app.session.get(v.id).map(|d| (i, d.display_name()))).collect();
|
||||
@@ -409,7 +563,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
});
|
||||
separator(ui, &t);
|
||||
group(ui, &t, edge, 4.0 * 30.0 + 3.0 * 6.0);
|
||||
// Moves need a selected row with room to move; the buttons stay put either way.
|
||||
let can_up = selected.iter().skip_while(|s| **s).any(|s| *s);
|
||||
let can_down = selected.iter().rev().skip_while(|s| **s).any(|s| *s);
|
||||
@@ -436,7 +590,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if unlock.clicked() {
|
||||
events.action = Some(RowAction::Unlock(None, unlock.rect.left_bottom() + vec2(0.0, 6.0)));
|
||||
}
|
||||
separator(ui, &t);
|
||||
group(ui, &t, edge, 2.0 * 30.0 + 6.0);
|
||||
let (undo, redo) = (!app.combine_tab.undo.is_empty(), !app.combine_tab.redo.is_empty());
|
||||
if tool(ui, undo, "undo-2", tl!("Undo"), tl!("Nothing to undo")).clicked() {
|
||||
app.execute("edit.undo");
|
||||
@@ -444,10 +598,20 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if tool(ui, redo, "redo-2", tl!("Redo"), tl!("Nothing to redo")).clicked() {
|
||||
app.execute("edit.redo");
|
||||
}
|
||||
group(ui, &t, edge, 2.0 * 30.0 + 6.0);
|
||||
// Thumbnails or the table; the choice is kept in the settings.
|
||||
for (view, icon, label) in [(CombineView::Grid, "layout-grid", tl!("Grid view")), (CombineView::List, "list", tl!("List view"))] {
|
||||
if icons::button(ui, icon, 30.0, app.combine_view == view, label).clicked() {
|
||||
app.combine_view = view;
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{n} selected"), &[("n", &count.to_string())])).color(t.text_muted));
|
||||
}
|
||||
if grid && n > 0 {
|
||||
zoom_controls(app, ui, &t, edge);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
|
||||
@@ -459,9 +623,19 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_min_height(card_h - 12.0);
|
||||
// The grid scrolls inside the card, leaving room for the totals below it.
|
||||
if grid {
|
||||
ui.set_max_height(card_h - 12.0);
|
||||
}
|
||||
ui.set_width(ui.available_width());
|
||||
if n == 0 {
|
||||
empty_state(app, ui, &t, files_hovering);
|
||||
// The view as the toolbar left it: a click on List view this frame starts no
|
||||
// thumbnail renders.
|
||||
} else if app.combine_view == CombineView::Grid {
|
||||
if let Some(action) = crate::combine_grid::grid(app, ui, &t, &checks, &selected) {
|
||||
events.action = Some(action);
|
||||
}
|
||||
} else {
|
||||
let table_events = table(app, ui, &t, &checks, &selected);
|
||||
events.range_focused |= table_events.range_focused;
|
||||
@@ -473,6 +647,9 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
},
|
||||
);
|
||||
|
||||
// A file shown large, over everything.
|
||||
crate::combine_grid::preview(app, ui.ctx(), &t);
|
||||
|
||||
// Totals.
|
||||
ui.add_space(8.0);
|
||||
if n > 0 {
|
||||
@@ -597,10 +774,22 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
|
||||
fn separator(ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.add_space(4.0);
|
||||
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(4.0), Stroke::new(1.0, t.divider));
|
||||
// As tall as the buttons on this line (the toolbar can take two).
|
||||
let top = ui.cursor().top();
|
||||
ui.painter().vline(ui.cursor().left(), (top + 4.0)..=(top + 26.0), Stroke::new(1.0, t.divider));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Before a group of toolbar buttons `width` wide: a divider, or a new line when the group
|
||||
/// doesn't fit before `edge` (the toolbar wraps between groups, never inside one).
|
||||
fn group(ui: &mut egui::Ui, t: &Tokens, edge: f32, width: f32) {
|
||||
if edge - ui.cursor().min.x < width + 10.0 {
|
||||
ui.end_row();
|
||||
} else {
|
||||
separator(ui, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// A toolbar icon button, disabled with a reason.
|
||||
fn tool(ui: &mut egui::Ui, enabled: bool, icon: &str, label: &str, disabled_why: &str) -> egui::Response {
|
||||
ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, label)).inner.on_disabled_hover_text(disabled_why)
|
||||
@@ -623,9 +812,13 @@ fn blocker(files: &[CombineFile], checks: &[Result<usize, String>]) -> Option<St
|
||||
}
|
||||
|
||||
/// While no text field has the keyboard: Up/Down select (Shift extends), Alt+Up/Down move the
|
||||
/// selection, Delete removes it, Ctrl/⌘+A selects every file.
|
||||
fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option<RowAction> {
|
||||
if n == 0 || ui.ctx().memory(|m| m.focused().is_some()) {
|
||||
/// selection, Delete removes it, Ctrl/⌘+A selects every file. In the grid (`columns` per row)
|
||||
/// Left/Right step one file and Up/Down one row.
|
||||
pub(crate) fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool, grid_columns: Option<usize>, space: bool) -> Option<RowAction> {
|
||||
// A focused card or button still leaves the arrows to the list; a text field, an open menu
|
||||
// or a dialog doesn't.
|
||||
let ctx = ui.ctx();
|
||||
if n == 0 || ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || ctx.memory(|m| m.top_modal_layer().is_some()) {
|
||||
return None;
|
||||
}
|
||||
use egui::Key;
|
||||
@@ -633,6 +826,11 @@ fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option<RowAction> {
|
||||
if i.consume_key(Modifiers::COMMAND, Key::A) {
|
||||
return Some(RowAction::SelectAll);
|
||||
}
|
||||
// As Quick Look: the file the keyboard is on, large (the way there without a pointer). A
|
||||
// focused button keeps its Space.
|
||||
if any_selected && space && i.consume_key(Modifiers::NONE, Key::Space) {
|
||||
return Some(RowAction::PreviewSelected);
|
||||
}
|
||||
if any_selected {
|
||||
if i.consume_key(Modifiers::ALT, Key::ArrowUp) {
|
||||
return Some(RowAction::MoveUp);
|
||||
@@ -644,18 +842,89 @@ fn keyboard(ui: &egui::Ui, n: usize, any_selected: bool) -> Option<RowAction> {
|
||||
return Some(RowAction::Remove);
|
||||
}
|
||||
}
|
||||
for (down, key) in [(false, Key::ArrowUp), (true, Key::ArrowDown)] {
|
||||
if i.consume_key(Modifiers::NONE, key) {
|
||||
return Some(RowAction::Step { down, extend: false });
|
||||
}
|
||||
let row = grid_columns.unwrap_or(1).max(1);
|
||||
let mut keys = vec![(false, row, Key::ArrowUp), (true, row, Key::ArrowDown)];
|
||||
if grid_columns.is_some() {
|
||||
keys.extend([(false, 1, Key::ArrowLeft), (true, 1, Key::ArrowRight)]);
|
||||
}
|
||||
for (down, by, key) in keys {
|
||||
// Shift first: a plain arrow also matches with Shift held (egui ignores an extra
|
||||
// Shift), which would step instead of extending.
|
||||
if i.consume_key(Modifiers::SHIFT, key) {
|
||||
return Some(RowAction::Step { down, extend: true });
|
||||
return Some(RowAction::Step { down, by, extend: true });
|
||||
}
|
||||
if i.consume_key(Modifiers::NONE, key) {
|
||||
return Some(RowAction::Step { down, by, extend: false });
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// ⌘+ (or ⌘=), ⌘− and ⌘0, while no text field, menu or dialog has the keyboard: the grid zoom
|
||||
/// they ask for, from `zoom`.
|
||||
fn zoom_keys(ui: &egui::Ui, zoom: f32) -> Option<f32> {
|
||||
use crate::combine_grid::zoom_step;
|
||||
use egui::{Key, KeyboardShortcut};
|
||||
let ctx = ui.ctx();
|
||||
if ctx.text_edit_focused() || egui::Popup::is_any_open(ctx) || ctx.memory(|m| m.top_modal_layer().is_some()) {
|
||||
return None;
|
||||
}
|
||||
// All of them are taken (several can arrive in one slow frame), so none is left for egui to
|
||||
// zoom the whole window with.
|
||||
let (larger, smaller, reset) = ctx.input_mut(|i| {
|
||||
let mut pressed = |k| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, k));
|
||||
(pressed(Key::Plus) | pressed(Key::Equals), pressed(Key::Minus), pressed(Key::Num0))
|
||||
});
|
||||
match (larger, smaller, reset) {
|
||||
(_, _, true) => Some(1.0),
|
||||
(true, false, _) => Some(zoom_step(zoom, true)),
|
||||
(false, true, _) => Some(zoom_step(zoom, false)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's card size, at the right of the toolbar (right to left: larger, the percentage, the
|
||||
/// slider, smaller), as the page grid's.
|
||||
fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32) {
|
||||
use crate::combine_grid::{ZOOM_RANGE, zoom_step};
|
||||
let zoom = app.combine_zoom;
|
||||
// Two buttons, the percentage, the slider and the spaces between: on a line of their own when
|
||||
// they don't fit beside the rest.
|
||||
let needed = 30.0 + 44.0 + 96.0 + 30.0 + 4.0 * ui.spacing().item_spacing.x + 8.0;
|
||||
if edge - ui.cursor().min.x < needed {
|
||||
ui.end_row();
|
||||
}
|
||||
// Right-aligned on the line, up to its end.
|
||||
let line = Rect::from_min_max(ui.cursor().min, egui::pos2(edge, ui.cursor().min.y + 30.0));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(line).layout(Layout::right_to_left(Align::Center)), |ui| {
|
||||
let larger = ui.add_enabled_ui(zoom < *ZOOM_RANGE.end(), |ui| icons::button(ui, "zoom-in", 30.0, false, tl!("Larger pages"))).inner;
|
||||
if larger.clicked() {
|
||||
app.combine_tab.zoom_request = Some(zoom_step(zoom, true));
|
||||
}
|
||||
let percent =
|
||||
egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)).frame(false);
|
||||
let percent = ui.add_sized([44.0, 30.0], percent);
|
||||
percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size")));
|
||||
if percent.on_hover_text(tl!("Reset page size")).clicked() {
|
||||
app.combine_tab.zoom_request = Some(1.0);
|
||||
}
|
||||
let mut value = zoom;
|
||||
ui.spacing_mut().slider_width = 96.0;
|
||||
let slider = ui.add(egui::Slider::new(&mut value, ZOOM_RANGE).show_value(false));
|
||||
// Read out as a percentage, as shown beside it.
|
||||
slider.widget_info(|| egui::WidgetInfo::slider(true, f64::from(value * 100.0).round(), tl!("Page size")));
|
||||
app.combine_tab.zoom_slider = Some(slider.id);
|
||||
if slider.on_hover_text(tl!("Page size")).changed() {
|
||||
app.combine_tab.zoom_request = Some(value);
|
||||
}
|
||||
let smaller = ui.add_enabled_ui(zoom > *ZOOM_RANGE.start(), |ui| icons::button(ui, "zoom-out", 30.0, false, tl!("Smaller pages"))).inner;
|
||||
if smaller.clicked() {
|
||||
app.combine_tab.zoom_request = Some(zoom_step(zoom, false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn empty_state(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, files_hovering: bool) {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space((ui.available_height() / 2.0 - 70.0).max(16.0));
|
||||
@@ -1092,6 +1361,37 @@ fn natural(a: &str, b: &str) -> Ordering {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an arrow key goes from `at` among `n` files, `by` at a time (1 in the list; a grid
|
||||
/// row's length for Up/Down in the grid). It stays put on the first row going up and on the last
|
||||
/// row going down; a short last row is reached from the row above at its last file.
|
||||
pub(crate) fn step_to(at: usize, n: usize, by: usize, down: bool) -> usize {
|
||||
let by = by.max(1);
|
||||
let last = n.saturating_sub(1);
|
||||
let at = at.min(last);
|
||||
if down {
|
||||
if at >= last / by * by { at } else { at.saturating_add(by).min(last) }
|
||||
} else if at < by {
|
||||
at
|
||||
} else {
|
||||
at - by
|
||||
}
|
||||
}
|
||||
|
||||
/// The order after moving the files `moving` (keeping their order) to `gap` in `ids` as it is
|
||||
/// (0 = before the first, `ids.len()` = after the last); `None` when nothing would change.
|
||||
pub(crate) fn moved_order(ids: &[u64], moving: &BTreeSet<u64>, gap: usize) -> Option<Vec<u64>> {
|
||||
let gap = gap.min(ids.len());
|
||||
// The gap counted among the files that stay.
|
||||
let before = ids.get(..gap).map_or(0, |s| s.iter().filter(|id| moving.contains(id)).count());
|
||||
let (taken, mut rest): (Vec<u64>, Vec<u64>) = ids.iter().partition(|id| moving.contains(id));
|
||||
if taken.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let at = gap.saturating_sub(before).min(rest.len());
|
||||
rest.splice(at..at, taken);
|
||||
(rest != ids).then_some(rest)
|
||||
}
|
||||
|
||||
/// Six dots: the drag handle.
|
||||
fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) {
|
||||
for dx in [-2.5, 2.5] {
|
||||
@@ -1167,6 +1467,7 @@ impl PdfCraftApp {
|
||||
self.combine_tab.selected = s.selected;
|
||||
self.combine_tab.sort = s.sort;
|
||||
self.combine_tab.range_edit = None;
|
||||
self.combine_tab.revision = self.combine_tab.revision.wrapping_add(1);
|
||||
// A Pages field with the keyboard would otherwise keep typing into the restored list
|
||||
// without an undo step.
|
||||
if let Some(ctx) = &self.ctx {
|
||||
@@ -1175,6 +1476,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
fn combine_push_undo(&mut self, before: Snapshot) {
|
||||
self.combine_tab.revision = self.combine_tab.revision.wrapping_add(1);
|
||||
let undo = &mut self.combine_tab.undo;
|
||||
undo.push(before);
|
||||
if undo.len() > HISTORY {
|
||||
@@ -1230,7 +1532,19 @@ impl PdfCraftApp {
|
||||
self.combine_tab.sort = Some((key, ascending));
|
||||
}
|
||||
|
||||
fn combine_apply(&mut self, action: RowAction) {
|
||||
/// Move `moving` to `gap`, as one undo step; a move that changes nothing leaves the list,
|
||||
/// its sort and the history alone.
|
||||
fn combine_move_to_gap(&mut self, moving: BTreeSet<u64>, gap: usize) {
|
||||
let ids: Vec<u64> = self.combine_draft.iter().map(|f| f.id).collect();
|
||||
let Some(order) = moved_order(&ids, &moving, gap) else { return };
|
||||
self.combine_record();
|
||||
let rank: std::collections::HashMap<u64, usize> = order.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||
self.combine_draft.sort_by_key(|f| rank.get(&f.id).copied().unwrap_or(usize::MAX));
|
||||
self.combine_tab.selected = moving.into_iter().filter(|id| rank.contains_key(id)).collect();
|
||||
self.combine_tab.sort = None;
|
||||
}
|
||||
|
||||
pub(crate) fn combine_apply(&mut self, action: RowAction) {
|
||||
let ids: Vec<u64> = self.combine_draft.iter().map(|f| f.id).collect();
|
||||
let n = ids.len();
|
||||
let index_of = |id: Option<u64>| id.and_then(|id| ids.iter().position(|x| *x == id));
|
||||
@@ -1259,10 +1573,9 @@ impl PdfCraftApp {
|
||||
RowAction::SelectAll => {
|
||||
tab.selected = ids.iter().copied().collect();
|
||||
}
|
||||
RowAction::Step { down, extend } => {
|
||||
RowAction::Step { down, by, extend } => {
|
||||
let next = match index_of(tab.cursor) {
|
||||
Some(c) if down => (c + 1).min(n.saturating_sub(1)),
|
||||
Some(c) => c.saturating_sub(1),
|
||||
Some(c) => step_to(c, n, by, down),
|
||||
None if down => 0,
|
||||
None => n.saturating_sub(1),
|
||||
};
|
||||
@@ -1275,6 +1588,7 @@ impl PdfCraftApp {
|
||||
tab.anchor = Some(id);
|
||||
}
|
||||
tab.cursor = Some(id);
|
||||
tab.reveal = Some(id);
|
||||
}
|
||||
RowAction::MoveUp | RowAction::MoveDown => {
|
||||
let up = matches!(action, RowAction::MoveUp);
|
||||
@@ -1313,21 +1627,52 @@ impl PdfCraftApp {
|
||||
self.combine_tab.selected = next.into_iter().collect();
|
||||
(self.combine_tab.anchor, self.combine_tab.cursor) = (next, next);
|
||||
}
|
||||
RowAction::RemoveFile(id) => {
|
||||
if !ids.contains(&id) {
|
||||
return;
|
||||
}
|
||||
self.combine_record();
|
||||
self.combine_draft.retain(|f| f.id != id);
|
||||
// The rest of the selection stays as it was.
|
||||
self.combine_tab.selected.remove(&id);
|
||||
if self.combine_tab.anchor == Some(id) {
|
||||
self.combine_tab.anchor = None;
|
||||
}
|
||||
if self.combine_tab.cursor == Some(id) {
|
||||
self.combine_tab.cursor = None;
|
||||
}
|
||||
}
|
||||
RowAction::Preview(id) => {
|
||||
if ids.contains(&id) {
|
||||
self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None });
|
||||
}
|
||||
}
|
||||
RowAction::PreviewSelected => {
|
||||
let id = tab.cursor.filter(|c| tab.selected.contains(c)).or_else(|| ids.iter().copied().find(|id| tab.selected.contains(id)));
|
||||
// Only what its card would offer a magnifier for (unlocked, readable, measured).
|
||||
let shows = |id: u64| self.combine_draft.iter().any(|f| f.id == id && f.lock.is_none() && f.problem.is_none() && !f.sizes.is_empty());
|
||||
if let Some(id) = id.filter(|id| shows(*id)) {
|
||||
self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None });
|
||||
}
|
||||
}
|
||||
RowAction::Drop { from, to } => {
|
||||
let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return };
|
||||
// Dragging a selected file takes the whole selection along.
|
||||
let moving: BTreeSet<u64> = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) };
|
||||
// Dropped on the selection itself: nothing to do.
|
||||
if moving.contains(&target) {
|
||||
return;
|
||||
}
|
||||
self.combine_record();
|
||||
let list = &mut self.combine_draft;
|
||||
let (taken, mut rest): (Vec<_>, Vec<_>) = list.drain(..).partition(|f| moving.contains(&f.id));
|
||||
let at = rest.iter().position(|f| f.id == target).map_or(rest.len(), |p| if from < to { p + 1 } else { p });
|
||||
rest.splice(at..at, taken);
|
||||
*list = rest;
|
||||
self.combine_tab.selected = moving;
|
||||
self.combine_tab.sort = None;
|
||||
// Below the row when dragged down, above it when dragged up.
|
||||
self.combine_move_to_gap(moving, if from < to { to + 1 } else { to });
|
||||
}
|
||||
RowAction::DropAt { file, revision, gap } => {
|
||||
// The list changed under the drag (e.g. undo): the gap no longer means the same.
|
||||
if revision != tab.revision || !ids.contains(&file) {
|
||||
return;
|
||||
}
|
||||
let moving: BTreeSet<u64> = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) };
|
||||
self.combine_move_to_gap(moving, gap);
|
||||
}
|
||||
RowAction::Unlock(row, at) => {
|
||||
let locked: BTreeSet<u64> = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect();
|
||||
@@ -1356,8 +1701,11 @@ impl PdfCraftApp {
|
||||
/// Close the Combine files tab and forget its list; the last document shows, if any.
|
||||
pub fn close_combine_tab(&mut self) {
|
||||
let showing = self.combine_showing();
|
||||
self.combine_tab = CombineTab::default();
|
||||
// (Revisions keep counting: a drag begun before can never match a list made after.)
|
||||
let revision = self.combine_tab.revision.wrapping_add(1);
|
||||
self.combine_tab = CombineTab { revision, ..CombineTab::default() };
|
||||
self.combine_draft.clear();
|
||||
self.combine_thumbs.clear();
|
||||
if showing {
|
||||
self.active = self.views.len().checked_sub(1);
|
||||
}
|
||||
@@ -1394,6 +1742,8 @@ impl PdfCraftApp {
|
||||
problem: None,
|
||||
lock: None,
|
||||
password: None,
|
||||
sizes: Arc::from(Vec::new()),
|
||||
auth: 0,
|
||||
notes: Vec::new(),
|
||||
origin_note: note,
|
||||
checked: None,
|
||||
@@ -1525,3 +1875,71 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A one-page PDF that opens with "pw".
|
||||
fn locked() -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(out)).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "pw",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [4; 32],
|
||||
})
|
||||
.unwrap();
|
||||
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_read_of_a_file_is_new_even_after_undo() {
|
||||
// Regression: the read counter lived in the undo history, so unlock → undo → unlock (with
|
||||
// another password) could repeat a value and keep a thumbnail of the other read.
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.stage_combine(vec![("secret.pdf".into(), locked())]);
|
||||
let added = app.combine_draft[0].auth;
|
||||
assert!(app.combine_unlock_rows(&[0], "pw"));
|
||||
let first = app.combine_draft[0].auth;
|
||||
app.combine_history_step(true);
|
||||
assert_eq!(app.combine_draft[0].auth, added, "undo brings back the file as it was read");
|
||||
assert!(app.combine_unlock_rows(&[0], "owner"));
|
||||
let second = app.combine_draft[0].auth;
|
||||
assert!(second != first && second != added, "added {added}, unlocked {first}, unlocked again {second}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_by_rank_never_lose_or_repeat_a_file() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.stage_combine((0..6).map(|i| (format!("f{i}.pdf"), locked())).collect());
|
||||
let mut ids: Vec<u64> = app.combine_draft.iter().map(|f| f.id).collect();
|
||||
for (moving, gap) in [(vec![0usize, 2, 4], 6), (vec![5], 0), (vec![1, 3], 3)] {
|
||||
let set: BTreeSet<u64> = moving.iter().filter_map(|i| ids.get(*i).copied()).collect();
|
||||
app.combine_move_to_gap(set, gap);
|
||||
let mut now: Vec<u64> = app.combine_draft.iter().map(|f| f.id).collect();
|
||||
ids = now.clone();
|
||||
now.sort_unstable();
|
||||
assert_eq!(now, (0..6).collect::<Vec<u64>>(), "every file once");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +566,16 @@ impl Host for crate::PdfCraftApp {
|
||||
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
|
||||
"comment_composer_open": v.comments.composer.is_some(),
|
||||
})),
|
||||
// The Combine files tab: how its files show, and each grid thumbnail's state.
|
||||
"combine": {
|
||||
"showing": self.combine_showing(),
|
||||
"view": self.combine_view.as_str(),
|
||||
"zoom": self.combine_zoom,
|
||||
"files": self.combine_draft.iter().map(|f| f.name.clone()).collect::<Vec<_>>(),
|
||||
"selected": self.combine_selection(),
|
||||
"thumbnails": self.combine_thumbnails().iter().map(|t| format!("{t:?}")).collect::<Vec<_>>(),
|
||||
"preview": self.combine_preview().map(|(file, page, render)| json!({ "file": file, "page": page + 1, "render": format!("{render:?}") })),
|
||||
},
|
||||
"quick_tool": match self.quick_tool {
|
||||
crate::QuickTool::Measure(t) => format!("measure-{}", t.name()),
|
||||
crate::QuickTool::Select => "select".to_string(),
|
||||
|
||||
@@ -2178,3 +2178,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space مسافة
|
||||
Grid view عرض الشبكة
|
||||
List view عرض القائمة
|
||||
Files are combined in the order shown. Drag them to change it. تُدمج الملفات بالترتيب المعروض. اسحبها لتغيير الترتيب.
|
||||
Preview unavailable: {e} المعاينة غير متاحة: {e}
|
||||
Pages: {k} of {n} الصفحات: {k} من {n}
|
||||
Check the pages تحقق من الصفحات
|
||||
Switch to List view to change the pages it takes. انتقل إلى عرض القائمة لتغيير الصفحات التي يأخذها.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2118,3 +2118,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space Интервал
|
||||
Grid view Изглед мрежа
|
||||
List view Изглед списък
|
||||
Files are combined in the order shown. Drag them to change it. Файловете се обединяват в показания ред. Плъзнете ги, за да го промените.
|
||||
Preview unavailable: {e} Няма преглед: {e}
|
||||
Pages: {k} of {n} Страници: {k} от {n}
|
||||
Check the pages Проверете страниците
|
||||
Switch to List view to change the pages it takes. Превключете към изглед списък, за да промените кои страници се вземат.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2156,3 +2156,10 @@ key Esc Esc
|
||||
key Home Pos1
|
||||
key End Ende
|
||||
key Space Leertaste
|
||||
Grid view Rasteransicht
|
||||
List view Listenansicht
|
||||
Files are combined in the order shown. Drag them to change it. Dateien werden in der gezeigten Reihenfolge kombiniert. Zieh sie, um sie zu ändern.
|
||||
Preview unavailable: {e} Keine Vorschau: {e}
|
||||
Pages: {k} of {n} Seiten: {k} von {n}
|
||||
Check the pages Prüfe die Seiten
|
||||
Switch to List view to change the pages it takes. Wechsle zur Listenansicht, um die übernommenen Seiten zu ändern.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2072,3 +2072,10 @@ key Esc Esc
|
||||
key Home Inicio
|
||||
key End Fin
|
||||
key Space Espacio
|
||||
Grid view Vista de cuadrícula
|
||||
List view Vista de lista
|
||||
Files are combined in the order shown. Drag them to change it. Los archivos se combinan en el orden mostrado. Arrástralos para cambiarlo.
|
||||
Preview unavailable: {e} Vista previa no disponible: {e}
|
||||
Pages: {k} of {n} Páginas: {k} de {n}
|
||||
Check the pages Revisa las páginas
|
||||
Switch to List view to change the pages it takes. Cambia a la vista de lista para cambiar las páginas que se incluyen.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2156,3 +2156,10 @@ key Esc Échap
|
||||
key Home Début
|
||||
key End Fin
|
||||
key Space Espace
|
||||
Grid view Vue en grille
|
||||
List view Vue en liste
|
||||
Files are combined in the order shown. Drag them to change it. Les fichiers sont combinés dans l'ordre affiché. Faites-les glisser pour le modifier.
|
||||
Preview unavailable: {e} Aperçu indisponible : {e}
|
||||
Pages: {k} of {n} Pages : {k} sur {n}
|
||||
Check the pages Vérifiez les pages
|
||||
Switch to List view to change the pages it takes. Passez à la vue en liste pour modifier les pages prises.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2154,3 +2154,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space Szóköz
|
||||
Grid view Rácsnézet
|
||||
List view Listanézet
|
||||
Files are combined in the order shown. Drag them to change it. A fájlok a látható sorrendben egyesülnek. Húzd őket a sorrend módosításához.
|
||||
Preview unavailable: {e} Nincs előnézet: {e}
|
||||
Pages: {k} of {n} Oldalak: {k} / {n}
|
||||
Check the pages Ellenőrizd az oldalakat
|
||||
Switch to List view to change the pages it takes. Válts listanézetre, ha módosítanád, mely oldalak kerüljenek bele.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2165,3 +2165,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End Fine
|
||||
key Space Spazio
|
||||
Grid view Vista griglia
|
||||
List view Vista elenco
|
||||
Files are combined in the order shown. Drag them to change it. I file vengono combinati nell'ordine mostrato. Trascinali per cambiarlo.
|
||||
Preview unavailable: {e} Anteprima non disponibile: {e}
|
||||
Pages: {k} of {n} Pagine: {k} di {n}
|
||||
Check the pages Controlla le pagine
|
||||
Switch to List view to change the pages it takes. Passa alla vista elenco per cambiare le pagine incluse.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2156,3 +2156,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space スペース
|
||||
Grid view グリッド表示
|
||||
List view リスト表示
|
||||
Files are combined in the order shown. Drag them to change it. ファイルは表示されている順に結合されます。ドラッグして順番を変更できます。
|
||||
Preview unavailable: {e} プレビューを表示できません: {e}
|
||||
Pages: {k} of {n} ページ: {k}/{n}
|
||||
Check the pages ページ指定を確認してください
|
||||
Switch to List view to change the pages it takes. 使用するページを変更するには、リスト表示に切り替えてください。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2157,3 +2157,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space Espaço
|
||||
Grid view Exibição em grade
|
||||
List view Exibição em lista
|
||||
Files are combined in the order shown. Drag them to change it. Os arquivos são combinados na ordem mostrada. Arraste-os para mudá-la.
|
||||
Preview unavailable: {e} Pré-visualização indisponível: {e}
|
||||
Pages: {k} of {n} Páginas: {k} de {n}
|
||||
Check the pages Verifique as páginas
|
||||
Switch to List view to change the pages it takes. Mude para a exibição em lista para alterar as páginas incluídas.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2069,3 +2069,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space Пробел
|
||||
Grid view Вид сеткой
|
||||
List view Вид списком
|
||||
Files are combined in the order shown. Drag them to change it. Файлы объединяются в показанном порядке. Перетащите их, чтобы изменить его.
|
||||
Preview unavailable: {e} Предпросмотр недоступен: {e}
|
||||
Pages: {k} of {n} Страницы: {k} из {n}
|
||||
Check the pages Проверьте страницы
|
||||
Switch to List view to change the pages it takes. Переключитесь на вид списком, чтобы изменить, какие страницы взять.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2084,3 +2084,13 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space స్పేస్
|
||||
Grid view గ్రిడ్ వీక్షణ
|
||||
List view జాబితా వీక్షణ
|
||||
Files are combined in the order shown. Drag them to change it. ఫైల్లు చూపిన క్రమంలో కలపబడతాయి. క్రమాన్ని మార్చడానికి వాటిని లాగండి.
|
||||
Preview unavailable: {e} ప్రివ్యూ అందుబాటులో లేదు: {e}
|
||||
Pages: {k} of {n} పేజీలు: {n}లో {k}
|
||||
Check the pages పేజీలను తనిఖీ చేయండి
|
||||
Switch to List view to change the pages it takes. ఇది తీసుకునే పేజీలను మార్చడానికి జాబితా వీక్షణకు మారండి.
|
||||
Larger pages పెద్ద పేజీలు
|
||||
Smaller pages చిన్న పేజీలు
|
||||
Reset page size పేజీ పరిమాణాన్ని రీసెట్ చేయి
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -2179,3 +2179,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space Пробіл
|
||||
Grid view Вигляд сіткою
|
||||
List view Вигляд списком
|
||||
Files are combined in the order shown. Drag them to change it. Файли об’єднуються в показаному порядку. Перетягніть їх, щоб змінити його.
|
||||
Preview unavailable: {e} Попередній перегляд недоступний: {e}
|
||||
Pages: {k} of {n} Сторінки: {k} із {n}
|
||||
Check the pages Перевірте сторінки
|
||||
Switch to List view to change the pages it takes. Перейдіть до вигляду списком, щоб змінити, які сторінки взяти.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2120,3 +2120,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space 空格
|
||||
Grid view 网格视图
|
||||
List view 列表视图
|
||||
Files are combined in the order shown. Drag them to change it. 文件按显示的顺序合并。拖动可更改顺序。
|
||||
Preview unavailable: {e} 无法预览:{e}
|
||||
Pages: {k} of {n} 页面:{k}/{n}
|
||||
Check the pages 请检查页码
|
||||
Switch to List view to change the pages it takes. 切换到列表视图以更改要使用的页面。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2042,3 +2042,10 @@ key Esc Esc
|
||||
key Home Home
|
||||
key End End
|
||||
key Space 空格
|
||||
Grid view 格狀檢視
|
||||
List view 清單檢視
|
||||
Files are combined in the order shown. Drag them to change it. 檔案將按顯示的順序合併。拖曳可變更順序。
|
||||
Preview unavailable: {e} 無法預覽:{e}
|
||||
Pages: {k} of {n} 頁面:{k}/{n}
|
||||
Check the pages 請檢查頁碼
|
||||
Switch to List view to change the pages it takes. 切換到清單檢視以變更要使用的頁面。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -26,8 +26,10 @@ mod a11y_ui;
|
||||
mod actions_ui;
|
||||
pub mod canvas;
|
||||
mod chrome;
|
||||
mod combine_grid;
|
||||
pub use combine_grid::ThumbState as CombineThumb;
|
||||
mod combine_ui;
|
||||
pub use combine_ui::{Columns as CombineColumns, Lock as CombineLock, SortKey};
|
||||
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
|
||||
mod commands;
|
||||
mod comment_props;
|
||||
pub mod comments;
|
||||
@@ -516,6 +518,13 @@ pub struct PdfCraftApp {
|
||||
pub combine_tab: combine_ui::CombineTab,
|
||||
/// The Combine files table's column order and widths (kept in the settings).
|
||||
pub combine_columns: combine_ui::Columns,
|
||||
/// Combine files as thumbnails or as the table (kept in the settings).
|
||||
pub combine_view: combine_ui::CombineView,
|
||||
/// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set
|
||||
/// with [`PdfCraftApp::set_combine_zoom`].
|
||||
pub combine_zoom: f32,
|
||||
/// The Combine grid's thumbnails: rendered off the UI thread, a few at a time.
|
||||
pub(crate) combine_thumbs: combine_grid::Thumbs,
|
||||
/// Images waiting for the resolution choice (released on cancel).
|
||||
pub image_import: Option<create_ui::ImageImport>,
|
||||
/// The custom stamp library, and the stamp being created.
|
||||
@@ -764,6 +773,9 @@ impl PdfCraftApp {
|
||||
combine_draft: Vec::new(),
|
||||
combine_tab: Default::default(),
|
||||
combine_columns: Default::default(),
|
||||
combine_view: Default::default(),
|
||||
combine_zoom: 1.0,
|
||||
combine_thumbs: Default::default(),
|
||||
image_import: None,
|
||||
custom_stamps: Vec::new(),
|
||||
stamp_draft: Default::default(),
|
||||
@@ -1373,6 +1385,8 @@ impl PdfCraftApp {
|
||||
"javascript": self.session.javascript(),
|
||||
"actions": actions_ui::encode(&self.custom_actions),
|
||||
"combine_columns": self.combine_columns.to_json(),
|
||||
"combine_view": self.combine_view.as_str(),
|
||||
"combine_zoom": self.combine_zoom,
|
||||
"comments_panel_closed": self.comments_panel_closed,
|
||||
})
|
||||
.to_string()
|
||||
@@ -1382,6 +1396,12 @@ impl PdfCraftApp {
|
||||
pub fn restore(&mut self, json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return };
|
||||
self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]);
|
||||
if let Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) {
|
||||
self.combine_view = view;
|
||||
}
|
||||
if let Some(zoom) = v["combine_zoom"].as_f64() {
|
||||
self.set_combine_zoom(zoom as f32);
|
||||
}
|
||||
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {
|
||||
// Only keep entries whose files still exist.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1715,6 +1735,18 @@ impl PdfCraftApp {
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
// `--combine-view list`: Combine files as thumbnails (grid) or as the table (list).
|
||||
("combine-view", _) => {
|
||||
self.combine_view = combine_ui::CombineView::parse(value).ok_or("combine-view must be grid or list")?;
|
||||
}
|
||||
// `--combine-zoom 150`: the size of the cards in the Combine grid, in percent.
|
||||
("combine-zoom", _) => {
|
||||
let percent = value.trim_end_matches('%').parse::<f32>().map_err(|e| e.to_string())?;
|
||||
if !percent.is_finite() || !combine_grid::ZOOM_RANGE.contains(&(percent / 100.0)) {
|
||||
return Err("combine-zoom must be between 60 and 200".into());
|
||||
}
|
||||
self.set_combine_zoom(percent / 100.0);
|
||||
}
|
||||
(k, None)
|
||||
if ["page", "zoom", "layout", "cover", "organize", "grid-zoom", "fields", "find", "rotate", "select", "notice", "comment"]
|
||||
.contains(&k) =>
|
||||
@@ -1743,7 +1775,8 @@ impl PdfCraftApp {
|
||||
return;
|
||||
}
|
||||
self.registry_shortcuts(ctx);
|
||||
if self.full_screen && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
// (Esc closes a Combine file shown large first.)
|
||||
if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
self.set_full_screen(ctx, false);
|
||||
}
|
||||
if let Some(i) = self.active {
|
||||
@@ -2018,6 +2051,8 @@ impl eframe::App for PdfCraftApp {
|
||||
|
||||
impl PdfCraftApp {
|
||||
fn finish_render_frame(&mut self, ctx: &egui::Context) {
|
||||
// The Combine grid's thumbnails, whichever tab shows (renders under way still finish).
|
||||
self.combine_thumbs_frame(ctx);
|
||||
// Retire hidden documents before admitting the visible document's textures, so the
|
||||
// three cache limits apply to the application, regardless of how many tabs are open.
|
||||
for (i, view) in self.views.iter_mut().enumerate() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -661,6 +661,7 @@ fn combining_files_opens_a_new_unsaved_tab() {
|
||||
#[test]
|
||||
fn combine_files_takes_chosen_pages_in_the_order_listed() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, vec![("one.pdf".into(), fixture(3)), ("two.pdf".into(), fixture(2))]);
|
||||
});
|
||||
h.run_steps(3);
|
||||
@@ -727,6 +728,7 @@ fn closing_the_combine_tab_forgets_its_list() {
|
||||
#[test]
|
||||
fn combine_lists_size_and_warns_before_combining() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(
|
||||
pdfcraft_ui_egui::FilePurpose::Combine,
|
||||
vec![
|
||||
@@ -792,9 +794,13 @@ fn combine_names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
|
||||
h.state().combine_draft.iter().map(|f| f.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Files in the Combine tab's list view (the table: headings, page ranges, row links).
|
||||
fn combine_of(names: &[(&str, usize)]) -> Harness<'static, PdfCraftApp> {
|
||||
let files: Vec<(String, Vec<u8>)> = names.iter().map(|(n, p)| (n.to_string(), fixture(*p))).collect();
|
||||
let mut h = harness(1, move |app| app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files));
|
||||
let mut h = harness(1, move |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(pdfcraft_ui_egui::FilePurpose::Combine, files);
|
||||
});
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
@@ -887,6 +893,7 @@ fn undo_and_redo_restore_the_combine_list() {
|
||||
#[test]
|
||||
fn protected_files_that_can_be_combined_are_flagged() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(
|
||||
pdfcraft_ui_egui::FilePurpose::Combine,
|
||||
vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))],
|
||||
|
||||
@@ -511,6 +511,7 @@ fn pdfs_dropped_on_the_combine_tab_join_its_list() {
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
h.state_mut().set_option("combine-view", "list").unwrap();
|
||||
h.state_mut().execute("page.combine");
|
||||
h.run_steps(2);
|
||||
// An absolute path is read from disk, with its modified time.
|
||||
|
||||
@@ -2951,7 +2951,19 @@ status = "shipped"
|
||||
commands = ["page.combine"]
|
||||
tools = ["doc_combine"]
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::column_headings_sort_the_list_and_flip_on_the_second_click", "crates/ui-egui/tests/editing.rs::ctrl_and_shift_clicks_select_several_files_that_move_and_go_together", "crates/ui-egui/tests/editing.rs::undo_and_redo_restore_the_combine_list", "crates/automation/tests/automation.rs::combine_extract_and_split"]
|
||||
notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag; every change undoable. doc_combine combines in the order its paths are given."
|
||||
notes = "Sort by any column (name in natural order, pages, size, modified, warnings), ascending or descending; multi-select (Ctrl/Shift) and move up/down, Alt+arrows or drag (rows in the list, cards in the grid); every change undoable. doc_combine combines in the order its paths are given."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.combine-thumbnails"
|
||||
title = "Thumbnail (grid) view of the files before combining"
|
||||
area = "D"
|
||||
tier = "P2"
|
||||
milestone = "M4"
|
||||
status = "shipped"
|
||||
commands = ["page.combine"]
|
||||
tools = ["doc_combine"]
|
||||
evidence = ["crates/ui-egui/tests/combine_grid.rs::combine_shows_a_grid_by_default_and_the_view_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::cards_select_like_the_list_and_the_selection_survives_switching_views", "crates/ui-egui/tests/combine_grid.rs::dragging_a_card_moves_it_and_undo_puts_it_back", "crates/ui-egui/tests/combine_grid.rs::a_dragged_selection_moves_together_to_the_first_and_last_gaps", "crates/ui-egui/tests/combine_grid.rs::thumbnails_show_the_first_page_each_file_takes", "crates/ui-egui/tests/combine_grid.rs::many_files_render_only_what_is_in_view_a_few_at_a_time", "crates/ui-egui/tests/combine_grid.rs::long_thin_pages_get_thumbnails_within_the_size_limits", "crates/ui-egui/tests/combine_grid.rs::combine_follows_the_order_shown_in_the_grid", "crates/ui-egui/src/combine_grid.rs::render_threads_never_exceed_three_while_files_come_and_go", "crates/ui-egui/src/combine_grid.rs::textures_over_budget_go_longest_unseen_first_and_those_in_view_stay", "crates/ui-egui/src/combine_grid.rs::on_the_web_one_thumbnail_waits_at_a_time", "crates/ui-egui/tests/combine_grid.rs::a_file_whose_pages_cant_be_measured_says_there_is_no_preview", "crates/ui-egui/tests/combine_grid.rs::the_toolbar_sizes_the_cards_within_limits_and_the_size_is_kept_in_the_settings", "crates/ui-egui/tests/combine_grid.rs::ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone", "crates/ui-egui/tests/combine_grid.rs::zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank", "crates/ui-egui/tests/combine_grid.rs::zooming_quickly_never_runs_more_than_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::hovering_a_card_shows_its_trash_and_magnifier_and_only_then", "crates/ui-egui/tests/combine_grid.rs::the_card_trash_removes_only_that_file_and_undo_brings_it_back", "crates/ui-egui/tests/combine_grid.rs::the_magnifier_shows_the_file_large_and_flips_through_the_pages_it_adds", "crates/ui-egui/tests/combine_grid.rs::the_preview_closes_when_its_file_leaves_and_keeps_to_three_render_threads", "crates/ui-egui/tests/combine_grid.rs::a_right_click_on_a_card_offers_preview_and_remove", "crates/ui-egui/tests/combine_grid.rs::space_shows_the_selected_file_large_in_the_grid_and_the_list"]
|
||||
notes = "Combine files shows a card per file (the default; Grid/List toggle kept in the settings, --combine-view grid|list): the first page the file contributes (its page range's first page), stacked when it contributes more, and its name shortened in the middle. Cards are sized from 60% to 200% (toolbar slider, buttons and percentage reset, Ctrl/⌘ +/−/0, Ctrl/⌘ with the wheel or a pinch over the grid; kept in the settings, --combine-zoom 60..200), keeping the card under the pointer in place. Hovering a card shows its own actions along the top of its page: remove that file only (whatever else is selected; undoable) and a magnifier that shows the file large, flipping through the pages it adds (buttons, Left/Right; Esc, close or a click outside closes it), also on the card's right-click menu, and Space shows the selected file large (the way there by keyboard). Cards select (click, Ctrl, Shift, arrows by card and by row), drag to reorder (one undo step; the grid scrolls at its edges) and share the list's toolbar. Thumbnails render off the UI thread, only for cards in view, sized to both sides of the page and at most 768 px; at most three render threads at once, counted as threads until they exit (no watchdog replacements; a page stuck for good in one step that can't be interrupted keeps its thread, and so its place, for the rest of the run); textures out of view are dropped over a 32 MB budget (those in view are kept). Locked, unreadable and bad-range files show why instead of a page, and a file whose page size can't be read (e.g. opened with an RC4 owner password) says No preview; page ranges are edited in the list. On the web a thumbnail, and the page shown large, render on the UI thread (one per frame, and only one waits at a time), so a very slow or very large page stalls that frame. Missing: expanding a file into its pages, and moving or removing single pages before combining (#611)."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.combine-options"
|
||||
|
||||
Reference in New Issue
Block a user