mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
refactor(ui-egui): split combine_ui.rs into combine_ui/
New modules: files, view, table, parts, session, actions (tests.rs moved in the previous commit).
This commit is contained in:
+21
-2470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
use super::files::{Preview, RowAction};
|
||||
use super::parts::{moved_pages, removed_pages, step_to};
|
||||
use super::view::expandable;
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
pub(crate) fn combine_apply(&mut self, action: RowAction) {
|
||||
let page_edit = matches!(action, RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_));
|
||||
self.combine_apply_once(action);
|
||||
// One kind of selection at a time: files picked (by a click, the keys, a drop, Unlock…)
|
||||
// let go of the pages picked, so the trash never takes pages the user no longer means.
|
||||
if !self.combine_tab.selected.is_empty() {
|
||||
self.combine_tab.selected_pages.clear();
|
||||
self.combine_tab.page_anchor = None;
|
||||
}
|
||||
// A file's parts brought side by side (the file between them removed or moved away) are
|
||||
// one entry again, as in the same undo step.
|
||||
if !page_edit {
|
||||
self.combine_join_parts();
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_apply_once(&mut self, action: RowAction) {
|
||||
// The trash and Delete act on the selected pages while there are any.
|
||||
let action = match action {
|
||||
// (Files picked come first: the two are never both picked, but if they ever were, the
|
||||
// files the user sees picked are what goes.)
|
||||
RowAction::Remove if self.combine_tab.selected.is_empty() && !self.combine_tab.selected_pages.is_empty() => RowAction::RemovePages(None),
|
||||
a => a,
|
||||
};
|
||||
// Page edits work on the list as parts.
|
||||
match action {
|
||||
RowAction::ClickPage(id, nth, m) => return self.combine_click_page(id, nth, m),
|
||||
RowAction::MovePages { page, revision, gap } => {
|
||||
if revision != self.combine_tab.revision {
|
||||
return;
|
||||
}
|
||||
let moving =
|
||||
if self.combine_tab.selected_pages.contains(&page) { self.combine_tab.selected_pages.clone() } else { BTreeSet::from([page]) };
|
||||
let parts = self.combine_parts();
|
||||
let next = &mut self.combine_tab.next_id;
|
||||
let mut new_id = || {
|
||||
let id = *next;
|
||||
*next = next.wrapping_add(1);
|
||||
id
|
||||
};
|
||||
if let Some(moved) = moved_pages(&parts, &moving, gap, &mut new_id) {
|
||||
self.combine_set_parts(moved);
|
||||
}
|
||||
return;
|
||||
}
|
||||
RowAction::RemovePages(one) => {
|
||||
let removing = match one {
|
||||
Some(page) => BTreeSet::from([page]),
|
||||
None => self.combine_tab.selected_pages.clone(),
|
||||
};
|
||||
if removing.is_empty() {
|
||||
return;
|
||||
}
|
||||
let parts = self.combine_parts();
|
||||
let next = &mut self.combine_tab.next_id;
|
||||
let mut new_id = || {
|
||||
let id = *next;
|
||||
*next = next.wrapping_add(1);
|
||||
id
|
||||
};
|
||||
// (Nothing to take out: no undo step, the redo history kept.)
|
||||
if let Some(left) = removed_pages(&parts, &removing, &mut new_id) {
|
||||
self.combine_set_parts(left);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// A file's card clicked (or the list's keys): the files are selected, no page.
|
||||
if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) {
|
||||
self.combine_tab.selected_pages.clear();
|
||||
}
|
||||
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));
|
||||
let tab = &mut self.combine_tab;
|
||||
match action {
|
||||
RowAction::Click(i, m) => {
|
||||
let Some(&id) = ids.get(i) else { return };
|
||||
if m.shift {
|
||||
let from = index_of(tab.anchor).unwrap_or(i);
|
||||
let range = ids.get(from.min(i)..=from.max(i)).unwrap_or_default();
|
||||
if !m.command {
|
||||
tab.selected.clear();
|
||||
}
|
||||
tab.selected.extend(range.iter().copied());
|
||||
tab.cursor = Some(id);
|
||||
} else if m.command {
|
||||
if !tab.selected.remove(&id) {
|
||||
tab.selected.insert(id);
|
||||
}
|
||||
(tab.anchor, tab.cursor) = (Some(id), Some(id));
|
||||
} else {
|
||||
tab.selected = BTreeSet::from([id]);
|
||||
(tab.anchor, tab.cursor) = (Some(id), Some(id));
|
||||
}
|
||||
}
|
||||
RowAction::SelectAll => {
|
||||
tab.selected = ids.iter().copied().collect();
|
||||
}
|
||||
RowAction::Step { down, by, extend } => {
|
||||
// With files shown as their pages, Up and Down go a row of cards (from the last
|
||||
// of a file's cards down, from its first up) to the file there.
|
||||
let places = &tab.grid_places;
|
||||
let rows_of_cards = by > 1 && places.len() > n;
|
||||
let next = match index_of(tab.cursor) {
|
||||
Some(c) if rows_of_cards => {
|
||||
let from = if down { places.iter().rposition(|f| *f == c) } else { places.iter().position(|f| *f == c) };
|
||||
from.and_then(|at| places.get(step_to(at, places.len(), by, down)).copied()).unwrap_or(c)
|
||||
}
|
||||
Some(c) => step_to(c, n, by, down),
|
||||
None if down => 0,
|
||||
None => n.saturating_sub(1),
|
||||
};
|
||||
let Some(&id) = ids.get(next) else { return };
|
||||
if extend {
|
||||
let from = index_of(tab.anchor).unwrap_or(next);
|
||||
tab.selected = ids.get(from.min(next)..=from.max(next)).unwrap_or_default().iter().copied().collect();
|
||||
} else {
|
||||
tab.selected = BTreeSet::from([id]);
|
||||
tab.anchor = Some(id);
|
||||
}
|
||||
tab.cursor = Some(id);
|
||||
tab.reveal = Some(id);
|
||||
}
|
||||
RowAction::MoveUp | RowAction::MoveDown => {
|
||||
let up = matches!(action, RowAction::MoveUp);
|
||||
let selected: Vec<bool> = ids.iter().map(|id| tab.selected.contains(id)).collect();
|
||||
let movable =
|
||||
if up { selected.iter().skip_while(|s| **s).any(|s| *s) } else { selected.iter().rev().skip_while(|s| **s).any(|s| *s) };
|
||||
if !movable {
|
||||
return;
|
||||
}
|
||||
self.combine_record();
|
||||
let list = &mut self.combine_draft;
|
||||
let sel = &self.combine_tab.selected;
|
||||
// Each selected file steps past its unselected neighbour; blocks move together.
|
||||
if up {
|
||||
for i in 1..list.len() {
|
||||
if sel.contains(&list[i].id) && !sel.contains(&list[i - 1].id) {
|
||||
list.swap(i, i - 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for i in (0..list.len().saturating_sub(1)).rev() {
|
||||
if sel.contains(&list[i].id) && !sel.contains(&list[i + 1].id) {
|
||||
list.swap(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.combine_tab.sort = None;
|
||||
}
|
||||
RowAction::Remove => {
|
||||
let Some(first) = ids.iter().position(|id| tab.selected.contains(id)) else { return };
|
||||
self.combine_record();
|
||||
let sel = std::mem::take(&mut self.combine_tab.selected);
|
||||
self.combine_draft.retain(|f| !sel.contains(&f.id));
|
||||
// The file that took the first removed one's place is selected next.
|
||||
let next = self.combine_draft.get(first).or(self.combine_draft.last()).map(|f| f.id);
|
||||
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::PreviewAt(id, at) => {
|
||||
if ids.contains(&id) {
|
||||
self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None });
|
||||
}
|
||||
}
|
||||
RowAction::Expand(id, on) => {
|
||||
if on {
|
||||
if self.combine_draft.iter_mut().any(|f| f.id == id && expandable(f)) {
|
||||
self.combine_tab.expanded.insert(id);
|
||||
}
|
||||
} else {
|
||||
self.combine_tab.expanded.remove(&id);
|
||||
// Its card may be far from where its pages were: brought into view.
|
||||
self.combine_tab.reveal = Some(id);
|
||||
}
|
||||
}
|
||||
RowAction::ExpandAll(on) => {
|
||||
self.combine_tab.expanded =
|
||||
if on { self.combine_draft.iter_mut().filter_map(|f| expandable(f).then_some(f.id)).collect() } else { BTreeSet::new() };
|
||||
if !on {
|
||||
self.combine_tab.reveal = self.combine_tab.cursor;
|
||||
}
|
||||
}
|
||||
RowAction::PreviewSelected => {
|
||||
// The file the keyboard is on, or the first selected; with pages selected, the
|
||||
// page card that has the keyboard, or the first selected page.
|
||||
let first_page = tab.selected_pages.iter().min_by_key(|(id, nth)| (ids.iter().position(|x| x == id), *nth)).copied();
|
||||
let page = tab.card_focus_at.filter(|p| tab.selected_pages.contains(p)).or(first_page);
|
||||
let id = tab
|
||||
.cursor
|
||||
.filter(|c| tab.selected.contains(c))
|
||||
.or_else(|| ids.iter().copied().find(|id| tab.selected.contains(id)))
|
||||
.or(page.map(|(id, _)| 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)) {
|
||||
// From the page card that has the keyboard (or the selected page), if it's one
|
||||
// of this file's.
|
||||
let at = self.combine_tab.card_focus_at.or(page).filter(|(file, _)| *file == id).map_or(0, |(_, nth)| nth);
|
||||
self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None });
|
||||
}
|
||||
}
|
||||
// (Page edits were done above.)
|
||||
RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {}
|
||||
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;
|
||||
}
|
||||
// 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();
|
||||
let rows: Vec<u64> = match row.and_then(|i| ids.get(i).copied()) {
|
||||
// A selected row brings the rest of the selection.
|
||||
Some(id) if tab.selected.contains(&id) => {
|
||||
ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect()
|
||||
}
|
||||
Some(id) => {
|
||||
tab.selected = BTreeSet::from([id]);
|
||||
(tab.anchor, tab.cursor) = (Some(id), Some(id));
|
||||
vec![id]
|
||||
}
|
||||
None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(),
|
||||
};
|
||||
self.combine_ask_password(rows, at);
|
||||
}
|
||||
RowAction::Sort(key) => {
|
||||
let ascending = !matches!(tab.sort, Some((k, true)) if k == key);
|
||||
self.combine_record();
|
||||
self.combine_sort(key, ascending);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
use super::*;
|
||||
|
||||
/// A column of the list: what it shows and what it sorts by.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SortKey {
|
||||
Name,
|
||||
Pages,
|
||||
Size,
|
||||
Modified,
|
||||
Warnings,
|
||||
}
|
||||
|
||||
impl SortKey {
|
||||
pub const ALL: [SortKey; 5] = [SortKey::Name, SortKey::Pages, SortKey::Size, SortKey::Modified, SortKey::Warnings];
|
||||
|
||||
pub(super) fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
match self {
|
||||
SortKey::Name => tl!("File name"),
|
||||
SortKey::Pages => tl!("Pages"),
|
||||
SortKey::Size => tl!("Size"),
|
||||
SortKey::Modified => tl!("Modified"),
|
||||
SortKey::Warnings => tl!("Warnings"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The narrowest the column may be dragged.
|
||||
pub(super) fn min_width(self) -> f32 {
|
||||
match self {
|
||||
SortKey::Name => 120.0,
|
||||
SortKey::Pages => 110.0,
|
||||
SortKey::Size => 60.0,
|
||||
SortKey::Modified => 80.0,
|
||||
SortKey::Warnings => 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The columns' order and widths, as the user arranged them (kept in the settings).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Columns {
|
||||
pub order: [SortKey; 5],
|
||||
/// By [`SortKey::index`]; `None` until a column is resized: fitted to the window until then.
|
||||
pub widths: Option<[f32; 5]>,
|
||||
}
|
||||
|
||||
impl Default for Columns {
|
||||
fn default() -> Self {
|
||||
Columns { order: SortKey::ALL, widths: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Columns {
|
||||
pub(crate) fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "order": self.order, "widths": self.widths })
|
||||
}
|
||||
|
||||
/// Settings written by [`Self::to_json`]; anything malformed gives the default layout.
|
||||
pub(crate) fn from_json(v: &serde_json::Value) -> Self {
|
||||
let order = serde_json::from_value::<Vec<SortKey>>(v["order"].clone())
|
||||
.ok()
|
||||
.filter(|o| SortKey::ALL.iter().all(|k| o.contains(k)))
|
||||
.and_then(|o| <[SortKey; 5]>::try_from(o).ok())
|
||||
.unwrap_or(SortKey::ALL);
|
||||
let widths = serde_json::from_value::<[f32; 5]>(v["widths"].clone())
|
||||
.ok()
|
||||
.filter(|w| w.iter().all(|x| x.is_finite()))
|
||||
.map(|w| std::array::from_fn(|i| w[i].clamp(SortKey::ALL[i].min_width(), 4000.0)));
|
||||
Columns { order, widths }
|
||||
}
|
||||
|
||||
/// The widths to show: the user's, or the window's width shared out.
|
||||
pub(super) fn resolved(&self, available: f32) -> [f32; 5] {
|
||||
if let Some(w) = self.widths {
|
||||
return w;
|
||||
}
|
||||
let fixed = 170.0 + 84.0 + 110.0;
|
||||
let flexible = (available - fixed).max(260.0);
|
||||
[(flexible * 0.55).max(140.0), 170.0, 84.0, 110.0, (flexible * 0.45).max(120.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Secret(pub(super) String);
|
||||
|
||||
impl std::fmt::Debug for Secret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(if self.0.is_empty() { "\"\"" } else { "…" })
|
||||
}
|
||||
}
|
||||
|
||||
/// Which password a protected file still needs before it can be combined.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Lock {
|
||||
/// The password that opens it.
|
||||
Open,
|
||||
/// It opens, but only its permissions (owner) password allows copying its pages.
|
||||
Permissions,
|
||||
}
|
||||
|
||||
/// The combine list's field-name conflicts, computed once per list (it opens the files):
|
||||
/// for each file, the renames combining would make of its form fields. Keyed by the file ids
|
||||
/// and their lock state, so unlocking a file refreshes it.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct ConflictPreview {
|
||||
key: (Vec<u64>, Vec<bool>),
|
||||
pub(super) per_file: Vec<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
impl ConflictPreview {
|
||||
/// Bring the preview in step with `files`, opening them through the engine when the list
|
||||
/// changed. A file split into parts is combined as one file, once — each group's first
|
||||
/// entry stands for it, as the combine does — and every part shows the file's preview.
|
||||
/// A file that cannot be opened previews as nothing (its row says why).
|
||||
pub(super) fn refresh(&mut self, session: &pdfcraft_engine::Session, files: &[CombineFile]) {
|
||||
let key = (files.iter().map(|f| f.id).collect(), files.iter().map(|f| f.password.is_some()).collect());
|
||||
if self.key == key && self.per_file.len() == files.len() {
|
||||
return;
|
||||
}
|
||||
let mut groups: Vec<u64> = Vec::new();
|
||||
let mut sources: Vec<pdfcraft_engine::CombineSource> = Vec::new();
|
||||
let mut passwords: Vec<Option<&str>> = Vec::new();
|
||||
for f in files {
|
||||
if groups.contains(&f.group) {
|
||||
continue;
|
||||
}
|
||||
groups.push(f.group);
|
||||
sources.push((f.name.clone(), f.bytes.clone(), None));
|
||||
passwords.push(f.password.as_ref().map(|p| p.0.as_str()));
|
||||
}
|
||||
let per_group = session.combine_field_conflicts(&sources, &passwords);
|
||||
self.per_file =
|
||||
files.iter().map(|f| groups.iter().position(|g| *g == f.group).and_then(|at| per_group.get(at)).cloned().unwrap_or_default()).collect();
|
||||
self.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock…'s password box: the rows it unlocks and what has been typed.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct UnlockPrompt {
|
||||
pub(super) rows: Vec<u64>,
|
||||
pub(super) input: Secret,
|
||||
pub(super) show: bool,
|
||||
pub(super) error: Option<String>,
|
||||
/// Where the box opens (below the link or button that asked for it).
|
||||
pub(super) at: egui::Pos2,
|
||||
pub(super) focused: bool,
|
||||
}
|
||||
|
||||
/// What can be known about a file up front, opened with a password or not.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Assessed {
|
||||
pub(super) pages: usize,
|
||||
/// Each page's displayed size in points (empty when the inspector couldn't read it).
|
||||
pub(super) sizes: Arc<[(f32, f32)]>,
|
||||
pub(super) problem: Option<String>,
|
||||
pub(super) lock: Option<Lock>,
|
||||
pub(super) notes: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) enum AssessError {
|
||||
WrongPassword,
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
/// Read `bytes` (with `password`, if given) for the list: pages, what stops it being combined
|
||||
/// and which password would help, and warnings.
|
||||
pub(super) fn assess(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<Assessed, AssessError> {
|
||||
// The engine that combines decides whether the password works; the inspector (which may
|
||||
// not accept every password the engine does, e.g. an RC4 owner password) adds the details.
|
||||
let check = pdfcraft_engine::combine_source_check(bytes, password);
|
||||
if check == Err(SourceProblem::WrongPassword) {
|
||||
return Err(AssessError::WrongPassword);
|
||||
}
|
||||
let info = pdfcraft_render::inspect(bytes.clone(), password);
|
||||
if let (Err(SourceProblem::Unreadable(e)), Err(_)) = (&check, &info) {
|
||||
return Err(AssessError::Unreadable(e.clone()));
|
||||
}
|
||||
let info = info.ok();
|
||||
let pages = match &info {
|
||||
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 {
|
||||
Ok(()) => (None, None),
|
||||
Err(SourceProblem::Password) => (Some(tl!("Password-protected").to_string()), Some(Lock::Open)),
|
||||
Err(SourceProblem::WrongPassword) => return Err(AssessError::WrongPassword),
|
||||
Err(SourceProblem::NotPermitted) if password.is_some() => (
|
||||
Some(tl!("Opened, but its security settings don't allow combining. Enter the permissions password.").to_string()),
|
||||
Some(Lock::Permissions),
|
||||
),
|
||||
Err(SourceProblem::NotPermitted) => (Some(tl!("Its security settings don't allow copying pages").to_string()), Some(Lock::Permissions)),
|
||||
Err(SourceProblem::Unreadable(e)) => (Some(crate::i18n::fmt(tl!("Can't be read: {e}"), &[("e", &e)])), None),
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
// The combined file is a new, unprotected document: protection doesn't carry over.
|
||||
if encrypted && problem.is_none() {
|
||||
notes.push(if password.is_some() {
|
||||
tl!("Unlocked: the combined file won't be password-protected").to_string()
|
||||
} else {
|
||||
tl!("Protected: the combined file won't keep its security settings").to_string()
|
||||
});
|
||||
}
|
||||
notes.extend(warnings.into_iter().take(5));
|
||||
Ok(Assessed { pages, sizes, problem, lock, notes })
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Snapshot {
|
||||
pub(super) files: Vec<CombineFile>,
|
||||
pub(super) selected: BTreeSet<u64>,
|
||||
pub(super) sort: Option<(SortKey, bool)>,
|
||||
}
|
||||
|
||||
/// A file arriving in the list (picked, dropped or already open).
|
||||
pub(crate) struct Incoming {
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub modified: Option<SystemTime>,
|
||||
/// Something to tell the user about where it came from.
|
||||
pub note: Option<String>,
|
||||
/// The open document it is, when it came from a tab.
|
||||
pub open_doc: Option<pdfcraft_engine::DocId>,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
pub id: u64,
|
||||
/// The file it is (part of): the entries a file is split into (a page moved between another
|
||||
/// file's pages) share it. Combined as one file, unlocked together, counted once.
|
||||
pub(crate) group: u64,
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// The file as it was added: `bytes` itself for a PDF, the image or text it was made from
|
||||
/// otherwise (converted anew, with its own date, each time it is added).
|
||||
pub(super) source: 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).
|
||||
pub modified: Option<SystemTime>,
|
||||
/// Why the file can't be combined (password, security settings), if so.
|
||||
pub problem: Option<String>,
|
||||
/// The password that would let it be combined, while one is needed.
|
||||
pub lock: Option<Lock>,
|
||||
/// The password it was unlocked with.
|
||||
pub(super) password: Option<Secret>,
|
||||
/// Warnings that don't stop it being combined.
|
||||
pub notes: Vec<String>,
|
||||
/// Where it came from, shown among the warnings (e.g. an open document's unsaved changes).
|
||||
pub(super) origin_note: Option<String>,
|
||||
/// The open document it is, when it came from a tab: Add open documents leaves it out.
|
||||
pub(super) open_doc: Option<pdfcraft_engine::DocId>,
|
||||
/// 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.
|
||||
pub(super) checked: Option<(String, RangeCheck)>,
|
||||
}
|
||||
|
||||
impl CombineFile {
|
||||
pub(super) 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;
|
||||
}
|
||||
|
||||
/// Whether the file can't be read at all (damaged, or a type that can't be made into a PDF):
|
||||
/// it stays in the list, with why, and Combine leaves it out once the user agrees. A
|
||||
/// password-protected file can be unlocked instead, so it isn't one.
|
||||
pub(crate) fn unreadable(&self) -> bool {
|
||||
self.problem.is_some() && self.lock.is_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)
|
||||
}
|
||||
|
||||
/// The first page the range takes, without updating the cache (read-only callers).
|
||||
pub(crate) fn first_taken(&self) -> Option<usize> {
|
||||
match &self.checked {
|
||||
Some((range, result)) if *range == self.range => result.as_ref().ok().map(|(_, first)| *first),
|
||||
_ => self.pages_taken().ok()?.first().copied(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
// (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) => match p.first() {
|
||||
Some(first) => Ok((p.len(), *first)),
|
||||
None => Err(tl!("No pages selected").to_string()),
|
||||
},
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
self.checked = Some((self.range.clone(), result.clone()));
|
||||
result
|
||||
}
|
||||
|
||||
/// 0 = can't be combined, 1 = has warnings, 2 = fine: the Warnings column's sort order.
|
||||
pub(super) fn severity(&mut self) -> u8 {
|
||||
if self.problem.is_some() || self.selection().is_err() {
|
||||
0
|
||||
} else if !self.notes.is_empty() {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum RowAction {
|
||||
/// A click on row `i` (Ctrl/⌘ toggles it, Shift selects up to it).
|
||||
Click(usize, Modifiers),
|
||||
SelectAll,
|
||||
/// 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,
|
||||
/// A page card's magnifier: show its file large, from that page (its place among the pages
|
||||
/// the file adds).
|
||||
PreviewAt(u64, usize),
|
||||
/// Show one file as its pages in the grid (`true`), or as its card again.
|
||||
Expand(u64, bool),
|
||||
/// Every file that adds more than one page, as its pages (`true`), or all as cards again.
|
||||
ExpandAll(bool),
|
||||
/// A click on a page card: that page (by entry id and place among its pages) selected,
|
||||
/// Ctrl/⌘ toggling it, Shift selecting up to it.
|
||||
ClickPage(u64, usize, Modifiers),
|
||||
/// A page card (and the rest of the selected pages, if it is selected) dropped before page
|
||||
/// `gap` of the list (see [`moved_pages`]), if the list hasn't changed since the drag began.
|
||||
MovePages {
|
||||
page: (u64, usize),
|
||||
revision: u64,
|
||||
gap: usize,
|
||||
},
|
||||
/// One page's trash (`Some`), or the selected pages: out of the list.
|
||||
RemovePages(Option<(u64, usize)>),
|
||||
/// 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.
|
||||
Unlock(Option<usize>, egui::Pos2),
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
use super::*;
|
||||
|
||||
/// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not
|
||||
/// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn combinable_in(dir: &std::path::Path, recursive: bool) -> (Vec<std::path::PathBuf>, bool) {
|
||||
let mut found = Vec::new();
|
||||
let mut more = false;
|
||||
let mut folders = vec![(dir.to_path_buf(), 0usize)];
|
||||
'walk: while let Some((folder, depth)) = folders.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&folder) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let Ok(kind) = entry.file_type() else { continue };
|
||||
let path = entry.path();
|
||||
if kind.is_dir() {
|
||||
let hidden = entry.file_name().to_string_lossy().starts_with('.');
|
||||
if recursive && !hidden && depth < MAX_FOLDER_DEPTH {
|
||||
folders.push((path, depth + 1));
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| combinable(&e.to_string_lossy())) && path.is_file() {
|
||||
if found.len() >= MAX_FOLDER_FILES {
|
||||
more = true;
|
||||
break 'walk;
|
||||
}
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| natural(&a.to_string_lossy(), &b.to_string_lossy()));
|
||||
(found, more)
|
||||
}
|
||||
|
||||
/// Whether a file with extension `ext` can join the list: a PDF, or an image or text file that
|
||||
/// becomes one.
|
||||
fn combinable(ext: &str) -> bool {
|
||||
std::iter::once("pdf").chain(pdfcraft_engine::CONVERTIBLE).any(|c| ext.eq_ignore_ascii_case(c))
|
||||
}
|
||||
|
||||
/// A file's bookmark in the combined PDF: its name without ".pdf", or without the extension of
|
||||
/// the image or text file it was made from.
|
||||
pub(super) fn title_of(name: &str) -> &str {
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() && combinable(ext) => stem,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// File names in the order people expect: case-insensitive, with numbers by value
|
||||
/// ("scan2" before "scan10").
|
||||
pub(super) fn natural(a: &str, b: &str) -> Ordering {
|
||||
let (mut a, mut b) = (a.chars().peekable(), b.chars().peekable());
|
||||
loop {
|
||||
match (a.peek().copied(), b.peek().copied()) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
(Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => {
|
||||
let take = |it: &mut std::iter::Peekable<std::str::Chars>| {
|
||||
let mut digits = String::new();
|
||||
while let Some(c) = it.peek().copied().filter(char::is_ascii_digit) {
|
||||
digits.push(c);
|
||||
it.next();
|
||||
}
|
||||
digits.trim_start_matches('0').to_owned()
|
||||
};
|
||||
let (x, y) = (take(&mut a), take(&mut b));
|
||||
// Without leading zeros, a longer run of digits is a larger number.
|
||||
match x.len().cmp(&y.len()).then_with(|| x.cmp(&y)) {
|
||||
Ordering::Equal => {}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
(Some(x), Some(y)) => {
|
||||
match x.to_lowercase().cmp(y.to_lowercase()) {
|
||||
Ordering::Equal => {}
|
||||
other => return other,
|
||||
}
|
||||
a.next();
|
||||
b.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// A page range (1-based: "1-3, 6, 9-7") that takes exactly `pages` (0-based, in that order)
|
||||
/// from a file of `count` pages; empty (every page) when that is all of them in order.
|
||||
pub(crate) fn range_of(pages: &[usize], count: usize) -> String {
|
||||
if pages.len() == count && pages.iter().enumerate().all(|(i, p)| i == *p) {
|
||||
return String::new();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(&start) = pages.get(i) {
|
||||
// The longest run going up (or down) one page at a time from here.
|
||||
let step = |a: usize, b: usize| {
|
||||
if b == a.wrapping_add(1) {
|
||||
1
|
||||
} else if a == b.wrapping_add(1) {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
let dir = pages.get(i + 1).map_or(0, |&next| step(start, next));
|
||||
let mut end = i;
|
||||
while dir != 0 && pages.get(end + 1).is_some_and(|&next| pages.get(end).is_some_and(|&cur| step(cur, next) == dir)) {
|
||||
end += 1;
|
||||
}
|
||||
let last = pages.get(end).copied().unwrap_or(start);
|
||||
parts.push(if end == i { format!("{}", start.saturating_add(1)) } else { format!("{}-{}", start.saturating_add(1), last.saturating_add(1)) });
|
||||
i = end + 1;
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
/// One list entry, for moving and removing single pages: its file (`group`: the parts of one
|
||||
/// file split around share it) and the pages it takes, in order; `None` when they can't be
|
||||
/// listed (a locked file, a bad range): such an entry only ever moves whole.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Part {
|
||||
pub(crate) id: u64,
|
||||
pub(crate) group: u64,
|
||||
pub(crate) pages: Option<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// One page in the list's order, or an entry that moves whole.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Item {
|
||||
Page { group: u64, from: u64, nth: usize, page: usize },
|
||||
Whole(u64),
|
||||
}
|
||||
|
||||
/// Every page in the list's order (and the entries that can't be split, whole).
|
||||
pub(super) fn items_of(parts: &[Part]) -> Vec<Item> {
|
||||
let mut items = Vec::new();
|
||||
for p in parts {
|
||||
match &p.pages {
|
||||
Some(pages) => items.extend(pages.iter().enumerate().map(|(nth, &page)| Item::Page { group: p.group, from: p.id, nth, page })),
|
||||
None => items.push(Item::Whole(p.id)),
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Entries again from `items`: a file's pages side by side are one entry (its first unused id
|
||||
/// among them, else a new one from `new_id`); entries that move whole keep theirs.
|
||||
pub(super) fn parts_of(items: &[Item], parts: &[Part], new_id: &mut dyn FnMut() -> u64) -> Vec<Part> {
|
||||
let mut out: Vec<Part> = Vec::new();
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut run: Vec<Item> = Vec::new();
|
||||
let mut close = |run: &mut Vec<Item>, out: &mut Vec<Part>, used: &mut BTreeSet<u64>| {
|
||||
let Some(Item::Page { group, .. }) = run.first().copied() else { return };
|
||||
let id = run.iter().find_map(|i| match i {
|
||||
Item::Page { from, .. } if !used.contains(from) => Some(*from),
|
||||
_ => None,
|
||||
});
|
||||
let id = id.unwrap_or_else(&mut *new_id);
|
||||
used.insert(id);
|
||||
let pages = run.iter().filter_map(|i| if let Item::Page { page, .. } = i { Some(*page) } else { None }).collect();
|
||||
out.push(Part { id, group, pages: Some(pages) });
|
||||
run.clear();
|
||||
};
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Whole(id) => {
|
||||
close(&mut run, &mut out, &mut used);
|
||||
if let Some(p) = parts.iter().find(|p| p.id == *id) {
|
||||
used.insert(*id);
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
Item::Page { group, .. } => {
|
||||
if run.first().is_some_and(|f| !matches!(f, Item::Page { group: g, .. } if g == group)) {
|
||||
close(&mut run, &mut out, &mut used);
|
||||
}
|
||||
run.push(*item);
|
||||
}
|
||||
}
|
||||
}
|
||||
close(&mut run, &mut out, &mut used);
|
||||
out
|
||||
}
|
||||
|
||||
/// The entries after moving the pages `moving` (by entry id and place among its pages), in
|
||||
/// their order, to before page `gap` of the list (counted over every page, an entry that can't be
|
||||
/// split counting as one; 0 = before the first): a page moved between another file's pages
|
||||
/// splits its file into parts, and parts of one file brought side by side join. `None` when
|
||||
/// nothing would change.
|
||||
pub(crate) fn moved_pages(parts: &[Part], moving: &BTreeSet<(u64, usize)>, gap: usize, new_id: &mut dyn FnMut() -> u64) -> Option<Vec<Part>> {
|
||||
let items = items_of(parts);
|
||||
let gap = gap.min(items.len());
|
||||
let picked = |i: &Item| matches!(i, Item::Page { from, nth, .. } if moving.contains(&(*from, *nth)));
|
||||
let before = items.get(..gap).map_or(0, |s| s.iter().filter(|i| picked(i)).count());
|
||||
let (taken, mut rest): (Vec<Item>, Vec<Item>) = items.iter().partition(|i| picked(i));
|
||||
if taken.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let at = gap.saturating_sub(before).min(rest.len());
|
||||
rest.splice(at..at, taken);
|
||||
let out = parts_of(&rest, parts, new_id);
|
||||
(out != parts).then_some(out)
|
||||
}
|
||||
|
||||
/// The entries after taking out the pages `removing` (by entry id and place among its pages);
|
||||
/// an entry left with none goes, and parts of one file left side by side join. `None` when
|
||||
/// nothing would change.
|
||||
pub(crate) fn removed_pages(parts: &[Part], removing: &BTreeSet<(u64, usize)>, new_id: &mut dyn FnMut() -> u64) -> Option<Vec<Part>> {
|
||||
let items: Vec<Item> =
|
||||
items_of(parts).into_iter().filter(|i| !matches!(i, Item::Page { from, nth, .. } if removing.contains(&(*from, *nth)))).collect();
|
||||
let out = parts_of(&items, parts, new_id);
|
||||
(out != parts).then_some(out)
|
||||
}
|
||||
|
||||
/// How long ago `time` was ("5 min ago"); a date after a week. Relative times need no time zone.
|
||||
pub(crate) fn ago(time: SystemTime) -> String {
|
||||
let secs = SystemTime::now().duration_since(time).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (mins, hours, days) = (secs / 60, secs / 3600, secs / 86_400);
|
||||
match () {
|
||||
_ if mins == 0 => tl!("just now").to_string(),
|
||||
_ if hours == 0 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]),
|
||||
_ if days == 0 => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &hours.to_string())]),
|
||||
_ if days == 1 => tl!("yesterday").to_string(),
|
||||
_ if days < 7 => crate::i18n::fmt(tl!("{n} days ago"), &[("n", &days.to_string())]),
|
||||
_ => utc_stamp(time).get(..10).unwrap_or_default().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `time` as "2026-10-08 14:05 UTC". A clock before 1970 reads as the epoch; past chrono's year
|
||||
/// 262143 range the proleptic Gregorian date is kept via the 400-year cycle, not the epoch.
|
||||
pub(super) fn utc_stamp(time: SystemTime) -> String {
|
||||
let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count
|
||||
/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still
|
||||
/// produced a date. The Gregorian calendar repeats every 400 years (146_097 days), so chrono
|
||||
/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then
|
||||
/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old
|
||||
/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
use super::files::{AssessError, Assessed, CombineFile, Incoming, Secret, Snapshot, SortKey, UnlockPrompt, assess};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::parts::combinable_in;
|
||||
use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, title_of};
|
||||
use super::*;
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Show the Combine files tab (File ▸ Combine files…), opening it if needed.
|
||||
pub fn open_combine_tab(&mut self) {
|
||||
self.combine_tab.open = true;
|
||||
self.combine_tab.focused = true;
|
||||
self.active = None;
|
||||
}
|
||||
|
||||
/// Whether the Combine files tab is the one showing.
|
||||
pub fn combine_showing(&self) -> bool {
|
||||
self.active.is_none() && self.combine_tab.open && self.combine_tab.focused
|
||||
}
|
||||
|
||||
/// The selected rows, top to bottom (tests and automation).
|
||||
pub fn combine_selection(&self) -> Vec<usize> {
|
||||
self.combine_draft.iter().enumerate().filter(|(_, f)| self.combine_tab.selected.contains(&f.id)).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Select these rows (tests and automation).
|
||||
pub fn select_combine_rows(&mut self, rows: &[usize]) {
|
||||
// Files picked: no page stays picked (the trash and Delete act on these files).
|
||||
self.combine_tab.selected_pages.clear();
|
||||
self.combine_tab.page_anchor = None;
|
||||
self.combine_tab.selected = rows.iter().filter_map(|i| self.combine_draft.get(*i)).map(|f| f.id).collect();
|
||||
let last = rows.last().and_then(|i| self.combine_draft.get(*i)).map(|f| f.id);
|
||||
(self.combine_tab.anchor, self.combine_tab.cursor) = (last, last);
|
||||
}
|
||||
|
||||
pub(super) fn combine_snapshot(&self) -> Snapshot {
|
||||
Snapshot { files: self.combine_draft.clone(), selected: self.combine_tab.selected.clone(), sort: self.combine_tab.sort }
|
||||
}
|
||||
|
||||
fn combine_restore(&mut self, s: Snapshot) {
|
||||
self.combine_draft = s.files;
|
||||
self.combine_tab.selected = s.selected;
|
||||
self.combine_tab.selected_pages.clear();
|
||||
self.combine_tab.page_anchor = None;
|
||||
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 {
|
||||
ctx.memory_mut(|m| m.stop_text_input());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) 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 {
|
||||
undo.remove(0);
|
||||
}
|
||||
self.combine_tab.redo.clear();
|
||||
}
|
||||
|
||||
/// Remember the list before a change, for Undo.
|
||||
pub(super) fn combine_record(&mut self) {
|
||||
let now = self.combine_snapshot();
|
||||
self.combine_push_undo(now);
|
||||
self.combine_tab.range_edit = None;
|
||||
}
|
||||
|
||||
pub(crate) fn combine_can_undo(&self, undo: bool) -> bool {
|
||||
if undo { !self.combine_tab.undo.is_empty() } else { !self.combine_tab.redo.is_empty() }
|
||||
}
|
||||
|
||||
/// Edit ▸ Undo / Redo (⌘Z, ⇧⌘Z) while the Combine files tab shows.
|
||||
pub(crate) fn combine_history_step(&mut self, undo: bool) {
|
||||
// ⌘Z in the password box is not about the list.
|
||||
if self.combine_tab.unlock.is_some() {
|
||||
return;
|
||||
}
|
||||
let (from, to) =
|
||||
if undo { (&mut self.combine_tab.undo, &mut self.combine_tab.redo) } else { (&mut self.combine_tab.redo, &mut self.combine_tab.undo) };
|
||||
let Some(state) = from.pop() else { return };
|
||||
let now = Snapshot { files: self.combine_draft.clone(), selected: self.combine_tab.selected.clone(), sort: self.combine_tab.sort };
|
||||
to.push(now);
|
||||
self.combine_restore(state);
|
||||
}
|
||||
|
||||
pub(super) fn combine_sort(&mut self, key: SortKey, ascending: bool) {
|
||||
let list = &mut self.combine_draft;
|
||||
let mut keyed: Vec<(u8, CombineFile)> = list.drain(..).map(|mut f| (f.severity(), f)).collect();
|
||||
keyed.sort_by(|(sa, a), (sb, b)| {
|
||||
let order = match key {
|
||||
SortKey::Name => natural(&a.name, &b.name),
|
||||
SortKey::Pages => a.pages.cmp(&b.pages),
|
||||
SortKey::Size => a.bytes.len().cmp(&b.bytes.len()),
|
||||
SortKey::Modified => match (a.modified, b.modified) {
|
||||
(Some(x), Some(y)) => x.cmp(&y),
|
||||
// Unknown times last, whichever the direction.
|
||||
(x, y) => return x.is_none().cmp(&y.is_none()).then_with(|| natural(&a.name, &b.name)),
|
||||
},
|
||||
SortKey::Warnings => sa.cmp(sb),
|
||||
};
|
||||
let order = if ascending { order } else { order.reverse() };
|
||||
order.then_with(|| natural(&a.name, &b.name))
|
||||
});
|
||||
*list = keyed.into_iter().map(|(_, f)| f).collect();
|
||||
self.combine_tab.sort = Some((key, ascending));
|
||||
}
|
||||
|
||||
/// The list as parts, for page edits: each entry's pages, or `None` for one that can't be
|
||||
/// split (locked, unreadable, a bad range).
|
||||
pub(super) fn combine_parts(&self) -> Vec<Part> {
|
||||
self.combine_draft
|
||||
.iter()
|
||||
.map(|f| Part {
|
||||
id: f.id,
|
||||
group: f.group,
|
||||
// (No page listed, e.g. its pages couldn't be counted: it moves whole, never lost.)
|
||||
pages: (f.lock.is_none() && f.problem.is_none()).then(|| f.pages_taken().ok()).flatten().filter(|p| !p.is_empty()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Make the list `parts`, as one undo step: entries kept by id, new parts made from their
|
||||
/// file's other entries, each taking exactly its pages (its range rewritten). The parts of a
|
||||
/// file shown as its pages stay shown so; the page selection is let go of.
|
||||
pub(super) fn combine_set_parts(&mut self, parts: Vec<Part>) {
|
||||
self.combine_record();
|
||||
self.combine_put_parts(parts);
|
||||
let tab = &mut self.combine_tab;
|
||||
tab.selected_pages.clear();
|
||||
tab.page_anchor = None;
|
||||
// An order made by hand isn't sorted any more.
|
||||
tab.sort = None;
|
||||
}
|
||||
|
||||
/// Join the parts of a file that have come to be side by side (no undo step of its own: it
|
||||
/// belongs to the change that brought them together).
|
||||
pub(super) fn combine_join_parts(&mut self) {
|
||||
let parts = self.combine_parts();
|
||||
// Nothing to join unless some file is in more than one part.
|
||||
let groups: BTreeSet<u64> = parts.iter().map(|p| p.group).collect();
|
||||
if groups.len() == parts.len() {
|
||||
return;
|
||||
}
|
||||
let next = &mut self.combine_tab.next_id;
|
||||
let mut new_id = || {
|
||||
let id = *next;
|
||||
*next = next.wrapping_add(1);
|
||||
id
|
||||
};
|
||||
let joined = parts_of(&items_of(&parts), &parts, &mut new_id);
|
||||
if joined != parts {
|
||||
self.combine_put_parts(joined);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the list `parts`: entries kept by id, new parts made from their file's other entries,
|
||||
/// each taking exactly its pages (its range rewritten only when they differ).
|
||||
fn combine_put_parts(&mut self, parts: Vec<Part>) {
|
||||
let old = std::mem::take(&mut self.combine_draft);
|
||||
let expanded: BTreeSet<u64> = old.iter().filter(|f| self.combine_tab.expanded.contains(&f.id)).map(|f| f.group).collect();
|
||||
for p in parts {
|
||||
let Some(base) = old.iter().find(|f| f.id == p.id).or_else(|| old.iter().find(|f| f.group == p.group)) else { continue };
|
||||
let mut f = base.clone();
|
||||
f.id = p.id;
|
||||
// (A range that already takes these pages stays as the user typed it.)
|
||||
if let Some(pages) = &p.pages
|
||||
&& f.pages_taken().ok().as_ref() != Some(pages)
|
||||
{
|
||||
f.range = range_of(pages, f.pages);
|
||||
f.checked = None;
|
||||
}
|
||||
if expanded.contains(&f.group) {
|
||||
self.combine_tab.expanded.insert(f.id);
|
||||
}
|
||||
self.combine_draft.push(f);
|
||||
}
|
||||
let ids: BTreeSet<u64> = self.combine_draft.iter().map(|f| f.id).collect();
|
||||
self.combine_tab.selected.retain(|id| ids.contains(id));
|
||||
}
|
||||
|
||||
/// A page card clicked: that page selected (Ctrl/⌘ toggles it, Shift selects every page from
|
||||
/// the last one clicked, in the list's order); the files' selection is let go of.
|
||||
pub(super) fn combine_click_page(&mut self, id: u64, nth: usize, m: Modifiers) {
|
||||
let page = (id, nth);
|
||||
let tab = &mut self.combine_tab;
|
||||
tab.selected.clear();
|
||||
tab.cursor = Some(id);
|
||||
if m.shift
|
||||
&& let Some(from) = tab.page_anchor
|
||||
{
|
||||
// Every page between the two, as the grid shows them.
|
||||
let order: Vec<(u64, usize)> = self
|
||||
.combine_draft
|
||||
.iter()
|
||||
.filter(|f| tab.expanded.contains(&f.id))
|
||||
.flat_map(|f| (0..f.pages_taken().map_or(0, |p| p.len())).map(move |n| (f.id, n)))
|
||||
.collect();
|
||||
let (a, b) = (order.iter().position(|p| *p == from), order.iter().position(|p| *p == page));
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
if !m.command {
|
||||
tab.selected_pages.clear();
|
||||
}
|
||||
tab.selected_pages.extend(order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if m.command {
|
||||
if !tab.selected_pages.remove(&page) {
|
||||
tab.selected_pages.insert(page);
|
||||
}
|
||||
} else {
|
||||
tab.selected_pages = BTreeSet::from([page]);
|
||||
}
|
||||
tab.page_anchor = Some(page);
|
||||
}
|
||||
|
||||
/// Move `moving` to `gap`, as one undo step; a move that changes nothing leaves the list,
|
||||
/// its sort and the history alone.
|
||||
pub(super) 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;
|
||||
}
|
||||
|
||||
/// 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();
|
||||
// (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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add picked files to the Combine files list (and show it).
|
||||
pub(crate) fn stage_combine(&mut self, files: Vec<(String, Vec<u8>)>) {
|
||||
let incoming =
|
||||
files.into_iter().map(|(name, bytes)| Incoming { name, bytes: Arc::new(bytes), modified: None, note: None, open_doc: None }).collect();
|
||||
self.stage_combine_with(incoming);
|
||||
}
|
||||
|
||||
/// Add files to the Combine files list, with what can be known about them up front.
|
||||
pub(crate) fn stage_combine_with(&mut self, files: Vec<Incoming>) {
|
||||
let mut added = Vec::new();
|
||||
for Incoming { name, bytes, modified, note, open_doc } in files {
|
||||
// Images and text files become PDFs as they join the list (#569): from then on they
|
||||
// are ordinary files, with pages, thumbnails and page ranges.
|
||||
// A file that can't be read joins the list all the same, with why (the Warnings
|
||||
// column, its card); Combine asks before leaving it out.
|
||||
let unreadable = |e: &str| Assessed {
|
||||
pages: 0,
|
||||
sizes: Arc::from(Vec::new()),
|
||||
problem: Some(crate::i18n::fmt(tl!("Can't be read: {e}"), &[("e", e)])),
|
||||
lock: None,
|
||||
notes: Vec::new(),
|
||||
};
|
||||
let source = bytes.clone();
|
||||
let (bytes, assessed) = match self.session.as_pdf(&name, &bytes) {
|
||||
Ok(pdf) => {
|
||||
let assessed = assess(&pdf, None);
|
||||
(pdf, assessed)
|
||||
}
|
||||
Err(e) => {
|
||||
// (The engine's message names the file, which the row already shows.)
|
||||
let e = match e {
|
||||
pdfcraft_engine::EditError::Source(m) => m,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let why = e.strip_prefix(&format!("{name}: ")).unwrap_or(&e).to_string();
|
||||
(bytes, Ok(unreadable(&why)))
|
||||
}
|
||||
};
|
||||
let assessed = match assessed {
|
||||
Ok(a) => a,
|
||||
Err(AssessError::Unreadable(e)) => unreadable(&e),
|
||||
Err(AssessError::WrongPassword) => {
|
||||
self.notify_fmt("Couldn't add {name}: {e}", &[("name", &name), ("e", "the password is wrong")]);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut f = CombineFile {
|
||||
id: 0,
|
||||
group: 0,
|
||||
name,
|
||||
bytes,
|
||||
source,
|
||||
pages: 0,
|
||||
range: String::new(),
|
||||
modified,
|
||||
problem: None,
|
||||
lock: None,
|
||||
password: None,
|
||||
sizes: Arc::from(Vec::new()),
|
||||
auth: 0,
|
||||
notes: Vec::new(),
|
||||
origin_note: note,
|
||||
open_doc,
|
||||
checked: None,
|
||||
};
|
||||
f.take(assessed, None);
|
||||
added.push(f);
|
||||
}
|
||||
if !added.is_empty() {
|
||||
self.combine_record();
|
||||
for mut f in added {
|
||||
f.id = self.combine_tab.next_id;
|
||||
f.group = f.id;
|
||||
self.combine_tab.next_id = self.combine_tab.next_id.wrapping_add(1);
|
||||
self.combine_draft.push(f);
|
||||
}
|
||||
// A sorted list stays sorted; new files simply join the end otherwise.
|
||||
if let Some((key, ascending)) = self.combine_tab.sort {
|
||||
self.combine_sort(key, ascending);
|
||||
}
|
||||
}
|
||||
self.open_combine_tab();
|
||||
}
|
||||
|
||||
/// Open Unlock…'s password box for `rows`, at `at`.
|
||||
pub(super) fn combine_ask_password(&mut self, rows: Vec<u64>, at: egui::Pos2) {
|
||||
if rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.combine_tab.unlock = Some(UnlockPrompt { rows, input: Secret::default(), show: false, error: None, at, focused: false });
|
||||
}
|
||||
|
||||
/// Try `password` on the protected files `rows` (as one undo step). Returns false when it
|
||||
/// opened none of them.
|
||||
pub fn combine_unlock(&mut self, rows: &[u64], password: &str) -> bool {
|
||||
// A file split into parts is unlocked as one: each file once, all its parts alike.
|
||||
let groups: BTreeSet<u64> =
|
||||
rows.iter().filter_map(|id| self.combine_draft.iter().find(|f| f.id == *id && f.lock.is_some())).map(|f| f.group).collect();
|
||||
let mut accepted = Vec::new();
|
||||
for group in &groups {
|
||||
let Some(f) = self.combine_draft.iter().find(|f| f.group == *group) else { continue };
|
||||
if let Ok(a) = assess(&f.bytes, Some(password)) {
|
||||
accepted.push((*group, a));
|
||||
}
|
||||
}
|
||||
if accepted.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.combine_record();
|
||||
let tried = groups.len();
|
||||
let mut unlocked = 0;
|
||||
for (group, a) in accepted {
|
||||
let mut auth = None;
|
||||
for f in self.combine_draft.iter_mut().filter(|f| f.group == group) {
|
||||
f.take(a.clone(), Some(Secret(password.to_owned())));
|
||||
// Read once: one way of reading for all its parts (their thumbnails are shared).
|
||||
f.auth = *auth.get_or_insert(f.auth);
|
||||
}
|
||||
unlocked += usize::from(self.combine_draft.iter().any(|f| f.group == group && f.lock.is_none()));
|
||||
}
|
||||
if tried > 1 {
|
||||
self.notify_fmt("Unlocked {n} of {m} files", &[("n", &unlocked.to_string()), ("m", &tried.to_string())]);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Unlock… for the files the user named in tests and automation (by row).
|
||||
pub fn combine_unlock_rows(&mut self, rows: &[usize], password: &str) -> bool {
|
||||
let ids: Vec<u64> = rows.iter().filter_map(|i| self.combine_draft.get(*i)).map(|f| f.id).collect();
|
||||
self.combine_unlock(&ids, password)
|
||||
}
|
||||
|
||||
/// Add every PDF, image and text file in a folder the user picks (and its subfolders, if
|
||||
/// `recursive`).
|
||||
pub fn combine_add_folder(&mut self, recursive: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let title = if recursive { tl!("Add folder and subfolders") } else { tl!("Add folder") };
|
||||
let dialog = rfd::AsyncFileDialog::new().set_title(title);
|
||||
self.ask_one(crate::pickers::Ask::Folder(dialog), None, move |app, dir| app.add_folder_to_combine(&dir, recursive));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = recursive;
|
||||
}
|
||||
|
||||
/// Add the PDFs, images and text files in `dir` to the Combine files list, as one undo step.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) {
|
||||
let (paths, more) = combinable_in(dir, recursive);
|
||||
let folder = dir.file_name().map_or_else(|| dir.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned());
|
||||
if paths.is_empty() {
|
||||
self.notify_fmt("No PDFs, images or text files found in {folder}", &[("folder", &folder)]);
|
||||
self.open_combine_tab();
|
||||
return;
|
||||
}
|
||||
self.use_paths(crate::files::FilePurpose::Combine, &paths);
|
||||
if more {
|
||||
self.notify_fmt("Added the first {n} files in {folder}", &[("n", &MAX_FOLDER_FILES.to_string()), ("folder", &folder)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add documents open in tabs (by tab index), as they are now; those already in the list
|
||||
/// stay out (#1356). The same document, not the same name: two files called alike are two.
|
||||
pub(crate) fn add_open_documents(&mut self, tabs: &[usize]) {
|
||||
let mut incoming = Vec::new();
|
||||
for &i in tabs {
|
||||
let Some(doc) = self.views.get(i).and_then(|v| self.session.get(v.id)) else { continue };
|
||||
if self.combine_draft.iter().any(|f| f.open_doc == Some(doc.id)) {
|
||||
continue;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let modified = if doc.dirty { None } else { doc.path.as_ref().and_then(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok()) };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let modified = None;
|
||||
let note = doc.dirty.then(|| tl!("Includes unsaved changes").to_string());
|
||||
let name = if doc.name.to_ascii_lowercase().ends_with(".pdf") { doc.name.clone() } else { format!("{}.pdf", doc.name) };
|
||||
incoming.push(Incoming { name, bytes: doc.bytes.clone(), modified, note, open_doc: Some(doc.id) });
|
||||
}
|
||||
self.stage_combine_with(incoming);
|
||||
}
|
||||
|
||||
/// Combine the listed files into a new, unsaved document; the tab makes way for it.
|
||||
/// Files that can't be read are left out (the user was asked).
|
||||
pub fn combine_staged(&mut self) {
|
||||
let files: Vec<&CombineFile> = self.combine_draft.iter().filter(|f| !f.unreadable()).collect();
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each file once, however many parts it is in.
|
||||
let count = files.iter().map(|f| f.group).collect::<BTreeSet<u64>>().len();
|
||||
let groups: Vec<u64> = files.iter().map(|f| f.group).collect();
|
||||
let sources: Vec<(String, Arc<Vec<u8>>, Option<String>)> =
|
||||
files.iter().map(|f| (title_of(&f.name).to_string(), f.bytes.clone(), Some(f.range.clone()).filter(|r| !r.trim().is_empty()))).collect();
|
||||
let passwords: Vec<Option<&str>> = files.iter().map(|f| f.password.as_ref().map(|p| p.0.as_str())).collect();
|
||||
match self.session.combine_grouped(&sources, &groups, &passwords) {
|
||||
Ok(bytes) => {
|
||||
self.close_combine_tab();
|
||||
let message = crate::i18n::fmt(tl!("Combined {n} files"), &[("n", &count.to_string())]);
|
||||
self.open_created("Combined.pdf", bytes, &message)
|
||||
}
|
||||
// The list stays, so the user can fix it.
|
||||
Err(e) => self.notify_fmt("Couldn't combine files: {e}", &[("e", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use super::files::{Columns, Lock, RowAction, SortKey};
|
||||
use super::parts::{ago, utc_stamp};
|
||||
use super::*;
|
||||
|
||||
/// A column heading being dragged to a new place.
|
||||
#[derive(Clone, Copy)]
|
||||
struct HeadingDrag(SortKey);
|
||||
|
||||
enum HeadingEvent {
|
||||
Sort(SortKey),
|
||||
/// Put column `kind` before (or after) column `to`.
|
||||
Move {
|
||||
kind: SortKey,
|
||||
to: SortKey,
|
||||
after: bool,
|
||||
},
|
||||
Reset,
|
||||
}
|
||||
|
||||
/// What the table reports besides row actions.
|
||||
#[derive(Default)]
|
||||
pub(super) struct TableEvents {
|
||||
pub(super) action: Option<RowAction>,
|
||||
pub(super) range_focused: bool,
|
||||
pub(super) range_changed: bool,
|
||||
}
|
||||
|
||||
/// The file table.
|
||||
pub(super) fn table(
|
||||
app: &mut PdfCraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
checks: &[Result<usize, String>],
|
||||
selected: &[bool],
|
||||
conflicts: &[Vec<(String, String)>],
|
||||
twice: &[bool],
|
||||
) -> TableEvents {
|
||||
let mut events = TableEvents::default();
|
||||
let dragging = egui::DragAndDrop::has_payload_of_type::<usize>(ui.ctx());
|
||||
// Until the user resizes a column, the widths follow the window.
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let available = ui.available_width() - 18.0 - gap * 6.0;
|
||||
if app.combine_columns.widths.is_none() && (available - app.combine_tab.fitted).abs() > 1.0 {
|
||||
app.combine_tab.fitted = available;
|
||||
app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1);
|
||||
}
|
||||
let widths = app.combine_columns.resolved(available);
|
||||
let rows = Rows { checks, selected, twice, conflicts, dragging, sort: app.combine_tab.sort };
|
||||
let mut heading_event = None;
|
||||
let mut shown = None;
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("combine-files-h")
|
||||
.show(ui, |ui| table_body(app, ui, t, &rows, widths, &mut events, &mut heading_event, &mut shown));
|
||||
// A column resized by hand keeps its width from now on.
|
||||
if let Some(shown) = shown
|
||||
&& shown.iter().zip(&widths).any(|(a, b)| (a - b).abs() > 0.5)
|
||||
{
|
||||
app.combine_columns.widths = Some(shown);
|
||||
}
|
||||
match heading_event {
|
||||
Some(HeadingEvent::Sort(key)) => events.action = Some(RowAction::Sort(key)),
|
||||
Some(HeadingEvent::Move { kind, to, after }) => {
|
||||
let mut order: Vec<SortKey> = app.combine_columns.order.iter().copied().filter(|k| *k != kind).collect();
|
||||
let at = order.iter().position(|k| *k == to).map_or(order.len(), |p| if after { p + 1 } else { p });
|
||||
order.insert(at, kind);
|
||||
if let Ok(order) = <[SortKey; 5]>::try_from(order) {
|
||||
app.combine_columns.order = order;
|
||||
app.combine_tab.layout = app.combine_tab.layout.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
Some(HeadingEvent::Reset) => {
|
||||
app.combine_columns = Columns::default();
|
||||
app.combine_tab.fitted = 0.0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// What every row needs to know.
|
||||
struct Rows<'a> {
|
||||
checks: &'a [Result<usize, String>],
|
||||
selected: &'a [bool],
|
||||
twice: &'a [bool],
|
||||
/// What combining would rename of this file's form fields (the field-name conflict preview).
|
||||
conflicts: &'a [Vec<(String, String)>],
|
||||
dragging: bool,
|
||||
sort: Option<(SortKey, bool)>,
|
||||
}
|
||||
|
||||
/// A column heading: the whole cell sorts by its column when clicked, moves the column when
|
||||
/// dragged, and offers Reset columns on a right-click.
|
||||
fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, bool)>) -> Option<HeadingEvent> {
|
||||
let text = key.label();
|
||||
let rect = ui.max_rect();
|
||||
let response = ui.allocate_rect(rect, Sense::click_and_drag());
|
||||
let arrow = sort.filter(|(k, _)| *k == key).map(|(_, ascending)| ascending);
|
||||
let state = match arrow {
|
||||
Some(true) => tl!("sorted ascending"),
|
||||
Some(false) => tl!("sorted descending"),
|
||||
None => "",
|
||||
};
|
||||
let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") };
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a11y));
|
||||
let being_dragged = egui::DragAndDrop::payload::<HeadingDrag>(ui.ctx()).is_some_and(|p| p.0 == key);
|
||||
if being_dragged || response.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed);
|
||||
} else if response.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let colour = if arrow.is_some() { t.text } else { t.text_muted };
|
||||
let galley = ui.painter().layout_no_wrap(text.to_owned(), theme::medium(12.5), colour);
|
||||
let text_pos = egui::pos2(rect.left() + 4.0, rect.center().y - galley.size().y / 2.0);
|
||||
let text_w = galley.size().x;
|
||||
ui.painter().with_clip_rect(rect).galley(text_pos, galley, colour);
|
||||
if let Some(ascending) = arrow {
|
||||
let r = Rect::from_center_size(egui::pos2(rect.left() + text_w + 14.0, rect.center().y), vec2(12.0, 12.0));
|
||||
icons::paint(ui, r, if ascending { "chevron-up" } else { "chevron-down" }, 12.0, t.text);
|
||||
}
|
||||
if response.drag_started() {
|
||||
egui::DragAndDrop::set_payload(ui.ctx(), HeadingDrag(key));
|
||||
}
|
||||
// Another heading dragged over this one: a line where it would go.
|
||||
let after = |r: &egui::Response| r.ctx.pointer_latest_pos().is_some_and(|p| p.x > rect.center().x);
|
||||
if let Some(dragged) = response.dnd_hover_payload::<HeadingDrag>()
|
||||
&& dragged.0 != key
|
||||
{
|
||||
let x = if after(&response) { rect.right() } else { rect.left() };
|
||||
ui.painter().with_clip_rect(rect.expand(3.0)).vline(x, rect.y_range(), Stroke::new(2.5, t.accent));
|
||||
}
|
||||
let mut event = None;
|
||||
if let Some(dragged) = response.dnd_release_payload::<HeadingDrag>()
|
||||
&& dragged.0 != key
|
||||
{
|
||||
event = Some(HeadingEvent::Move { kind: dragged.0, to: key, after: after(&response) });
|
||||
}
|
||||
let response = response
|
||||
.on_hover_cursor(if being_dragged { egui::CursorIcon::Grabbing } else { egui::CursorIcon::PointingHand })
|
||||
.on_hover_text(tl!("Click to sort, drag to move the column. Drag its edge to resize it."));
|
||||
response.context_menu(|ui| {
|
||||
if ui.button(tl!("Reset columns")).clicked() {
|
||||
event = Some(HeadingEvent::Reset);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if response.clicked() {
|
||||
event = Some(HeadingEvent::Sort(key));
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata
|
||||
/// and per-row warnings.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn table_body(
|
||||
app: &mut PdfCraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
rows: &Rows,
|
||||
widths: [f32; 5],
|
||||
events: &mut TableEvents,
|
||||
heading_event: &mut Option<HeadingEvent>,
|
||||
shown: &mut Option<[f32; 5]>,
|
||||
) {
|
||||
let n = app.combine_draft.len();
|
||||
let order = app.combine_columns.order;
|
||||
// The heading being dragged follows the pointer.
|
||||
if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::<HeadingDrag>(ui.ctx()), ui.ctx().pointer_latest_pos()) {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("combine-heading-drag")));
|
||||
let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text);
|
||||
let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0));
|
||||
painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
painter.galley(r.min + vec2(8.0, 4.0), galley, t.text);
|
||||
}
|
||||
let mut builder = TableBuilder::new(ui)
|
||||
// Columns moved, reset or refitted: a new table state, built from `widths`.
|
||||
.id_salt(("combine-files", app.combine_tab.layout))
|
||||
.striped(true)
|
||||
.sense(Sense::click())
|
||||
.auto_shrink([false, true])
|
||||
.cell_layout(Layout::left_to_right(Align::Center))
|
||||
.column(Column::exact(18.0));
|
||||
for kind in order {
|
||||
builder = builder.column(Column::initial(widths[kind.index()]).at_least(kind.min_width()).resizable(true).clip(true));
|
||||
}
|
||||
builder
|
||||
.header(26.0, |mut row| {
|
||||
row.col(|_| {});
|
||||
for kind in order {
|
||||
row.col(|ui| {
|
||||
if let Some(e) = heading(ui, t, kind, rows.sort) {
|
||||
*heading_event = Some(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|mut body| {
|
||||
if !body.ui_mut().is_sizing_pass() {
|
||||
let w = body.widths();
|
||||
let mut out = widths;
|
||||
for (kind, width) in order.iter().zip(w.iter().skip(1)) {
|
||||
out[kind.index()] = *width;
|
||||
}
|
||||
*shown = Some(out);
|
||||
}
|
||||
for i in 0..n {
|
||||
let Some(f) = app.combine_draft.get_mut(i) else { break };
|
||||
let check = rows.checks.get(i).cloned().unwrap_or(Ok(f.pages));
|
||||
body.row(32.0, |mut row| {
|
||||
row.set_selected(rows.selected.get(i).copied().unwrap_or(false));
|
||||
// Drag handle.
|
||||
row.col(|ui| {
|
||||
let id = ui.scope_id().with(("combine-drag", f.id));
|
||||
ui.dnd_drag_source(id, i, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover());
|
||||
grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint });
|
||||
})
|
||||
.response
|
||||
.on_hover_cursor(egui::CursorIcon::Grab)
|
||||
.on_hover_text(tl!("Drag to reorder"));
|
||||
});
|
||||
for kind in order {
|
||||
row.col(|ui| match kind {
|
||||
SortKey::Name => {
|
||||
ui.add(icons::image("file-text", 16.0, t.icon));
|
||||
// A file name is file/path chrome: explicitly LTR, extension last.
|
||||
crate::widgets::label_ltr_truncated(ui, f.name.clone(), theme::regular(13.0), t.text);
|
||||
}
|
||||
// The range field and how many pages it takes.
|
||||
SortKey::Pages => {
|
||||
crate::widgets::begin_dialog(ui);
|
||||
let edit = ui.add_enabled(
|
||||
f.problem.is_none(),
|
||||
crate::widgets::line(&mut f.range).frame(egui::Frame::NONE).hint_text(tl!("All pages")).desired_width(88.0),
|
||||
);
|
||||
// A visible field on plain and striped rows alike; red when the range is wrong.
|
||||
let border = if check.is_err() { Stroke::new(1.5, ERROR) } else { Stroke::new(1.0, t.border) };
|
||||
ui.painter().rect_stroke(edit.rect, CornerRadius::same(4), border, egui::StrokeKind::Inside);
|
||||
if let Err(e) = &check {
|
||||
edit.clone().on_hover_text(e);
|
||||
} else {
|
||||
edit.clone()
|
||||
.on_hover_text(crate::i18n::fmt(tl!("Pages of {name} to combine, e.g. 1-3, 6"), &[("name", &f.name)]));
|
||||
}
|
||||
if edit.gained_focus() {
|
||||
events.range_focused = true;
|
||||
events.action = Some(RowAction::Click(i, Modifiers::NONE));
|
||||
}
|
||||
if edit.changed() {
|
||||
events.range_changed = true;
|
||||
}
|
||||
let count = match (&check, f.problem.is_some()) {
|
||||
(_, true) => "—".to_string(),
|
||||
(Ok(k), _) => crate::i18n::fmt(tl!("{k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]),
|
||||
(Err(_), _) => crate::i18n::fmt(tl!("? of {n}"), &[("n", &f.pages.to_string())]),
|
||||
};
|
||||
ui.label(egui::RichText::new(count).small().color(if check.is_err() { ERROR } else { t.text_faint }));
|
||||
}
|
||||
SortKey::Size => {
|
||||
ui.label(egui::RichText::new(crate::panels::human_size(f.bytes.len())).color(t.text_muted));
|
||||
}
|
||||
SortKey::Modified => match f.modified {
|
||||
Some(m) => {
|
||||
ui.label(egui::RichText::new(ago(m)).color(t.text_muted)).on_hover_text(utc_stamp(m));
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new("—").color(t.text_faint));
|
||||
}
|
||||
},
|
||||
// Blocking problems first, in red.
|
||||
SortKey::Warnings => {
|
||||
let mut errors: Vec<String> = f.problem.iter().cloned().collect();
|
||||
if let Err(e) = &check {
|
||||
errors.push(e.clone());
|
||||
}
|
||||
let mut warnings = f.notes.clone();
|
||||
if rows.twice.get(i).copied().unwrap_or(false) {
|
||||
warnings.push(tl!("Added more than once").to_string());
|
||||
}
|
||||
if let Some(renames) = rows.conflicts.get(i).filter(|r| !r.is_empty()) {
|
||||
let shown: Vec<String> = renames.iter().take(3).map(|(old, new)| format!("{old} → {new}")).collect();
|
||||
let list = shown.join(", ");
|
||||
warnings.push(crate::i18n::fmt(
|
||||
&crate::i18n::trn(
|
||||
crate::i18n::current(),
|
||||
renames.len() as u64,
|
||||
"{n} field name collides with earlier files and will be renamed: {list}",
|
||||
"{n} field names collide with earlier files and will be renamed: {list}",
|
||||
),
|
||||
&[("list", &list)],
|
||||
));
|
||||
}
|
||||
let (icon, colour, first) = match (errors.first(), warnings.first()) {
|
||||
(Some(e), _) => ("circle-x", ERROR, e.clone()),
|
||||
(None, Some(w)) => ("triangle-alert", WARNING, w.clone()),
|
||||
(None, None) => return,
|
||||
};
|
||||
let all = errors.iter().chain(&warnings).cloned().collect::<Vec<_>>().join("\n");
|
||||
ui.add(icons::image(icon, 15.0, colour));
|
||||
if f.lock.is_some() {
|
||||
let link =
|
||||
ui.add(egui::Link::new(egui::RichText::new(tl!("Unlock…")).color(t.accent))).on_hover_text(match f.lock {
|
||||
Some(Lock::Permissions) => tl!("Enter the permissions password to allow combining"),
|
||||
_ => tl!("Enter the password that opens it"),
|
||||
});
|
||||
if link.clicked() {
|
||||
events.action = Some(RowAction::Unlock(Some(i), link.rect.left_bottom() + vec2(0.0, 6.0)));
|
||||
}
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(first).color(if colour == ERROR { ERROR } else { t.text }))
|
||||
.truncate()
|
||||
.selectable(false),
|
||||
)
|
||||
.on_hover_text(all);
|
||||
}
|
||||
});
|
||||
}
|
||||
let response = row.response();
|
||||
if f.lock.is_some() {
|
||||
let at = response.rect.left_bottom();
|
||||
response.context_menu(|ui| {
|
||||
if ui
|
||||
.add(egui::Button::image_and_text(icons::image("lock-open", 15.0, ui.visuals().text_color()), tl!("Unlock…")))
|
||||
.clicked()
|
||||
{
|
||||
events.action = Some(RowAction::Unlock(Some(i), at));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
if response.clicked() {
|
||||
let modifiers = response.ctx.input(|i| i.modifiers);
|
||||
events.action = Some(RowAction::Click(i, modifiers));
|
||||
}
|
||||
// A row dragged over this one goes where the line is drawn.
|
||||
if let Some(from) = response.dnd_hover_payload::<usize>().map(|p| *p) {
|
||||
let r = response.rect;
|
||||
let y = if from > i { r.top() } else { r.bottom() };
|
||||
if from != i {
|
||||
response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("combine-drop-line"))).hline(
|
||||
r.x_range(),
|
||||
y,
|
||||
Stroke::new(2.0, t.accent),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(from) = response.dnd_release_payload::<usize>().map(|p| *p) {
|
||||
events.action = Some(RowAction::Drop { from, to: i });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Six dots: the drag handle.
|
||||
fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) {
|
||||
for dx in [-2.5, 2.5] {
|
||||
for dy in [-5.0, 0.0, 5.0] {
|
||||
ui.painter().circle_filled(rect.center() + vec2(dx, dy), 1.4, colour);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
use super::files::{CombineFile, CombineView, Lock, RowAction};
|
||||
use super::table::{TableEvents, table};
|
||||
use super::*;
|
||||
|
||||
/// The Combine files page.
|
||||
pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let files_hovering = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
|
||||
// Ranges are checked before anything below reads them.
|
||||
let checks: Vec<Result<usize, String>> = app.combine_draft.iter_mut().map(CombineFile::selection).collect();
|
||||
// Same-named form fields across the files are renamed when combining: preview it once per
|
||||
// list, so the Warnings column can say so before anything is combined.
|
||||
app.combine_conflicts.refresh(&app.session, &app.combine_draft);
|
||||
let conflicts = app.combine_conflicts.per_file.clone();
|
||||
let twice = app.combine_tab.duplicate_warnings(&app.combine_draft);
|
||||
let n = app.combine_draft.len();
|
||||
let ids: Vec<u64> = app.combine_draft.iter().map(|f| f.id).collect();
|
||||
app.combine_tab.selected.retain(|id| ids.contains(id));
|
||||
// Pages stay picked only while they show (a grid card of an expanded file), so the trash or
|
||||
// Delete never takes a page the user can't see.
|
||||
{
|
||||
let tab = &mut app.combine_tab;
|
||||
if app.combine_view == CombineView::Grid {
|
||||
let shown: BTreeSet<(u64, usize)> = app
|
||||
.combine_draft
|
||||
.iter_mut()
|
||||
.filter(|f| tab.expanded.contains(&f.id))
|
||||
.flat_map(|f| {
|
||||
let id = f.id;
|
||||
(0..f.selection().unwrap_or(0)).map(move |nth| (id, nth))
|
||||
})
|
||||
.collect();
|
||||
tab.selected_pages.retain(|p| shown.contains(p));
|
||||
} else {
|
||||
tab.selected_pages.clear();
|
||||
}
|
||||
if tab.selected_pages.is_empty() {
|
||||
tab.page_anchor = None;
|
||||
}
|
||||
}
|
||||
// A file stays expanded only while it can show as its pages (not locked again by undo, nor
|
||||
// edited down to one page or a bad range): it doesn't come back expanded by itself.
|
||||
{
|
||||
let tab = &mut app.combine_tab;
|
||||
let draft = &app.combine_draft;
|
||||
let lists = &mut tab.page_lists;
|
||||
tab.expanded.retain(|id| draft.iter().find(|f| f.id == *id).is_some_and(|f| crate::combine_grid::pages_shown(lists, f).is_some()));
|
||||
let expanded = &tab.expanded;
|
||||
tab.page_lists.retain(|id, _| expanded.contains(id));
|
||||
}
|
||||
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 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() || app.combine_tab.confirm_skip;
|
||||
let focus = ui.memory(|m| m.focused());
|
||||
let space = focus.is_none() || focus == app.combine_tab.card_focus;
|
||||
let any_picked = count > 0 || !app.combine_tab.selected_pages.is_empty();
|
||||
let keys = if covered || slider_focused { None } else { keyboard(ui, n, any_picked, 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| {
|
||||
// Title and Combine.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Combine files")).font(theme::semibold(20.0)));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let response = ui.add_enabled_ui(blocker.is_none(), |ui| widgets::icon_pill(ui, "files", tl!("Combine"), true)).inner;
|
||||
if let Some(why) = &blocker {
|
||||
response.clone().on_disabled_hover_text(why);
|
||||
}
|
||||
if response.clicked() {
|
||||
combine = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
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: 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, _)| crate::canvas::tab_label(app, i).map(|name| (i, name))).collect();
|
||||
let response = ui.add_enabled_ui(!open_docs.is_empty(), |ui| widgets::icon_pill(ui, "file-plus", tl!("Add open documents"), false)).inner;
|
||||
response.clone().on_disabled_hover_text(tl!("No documents are open"));
|
||||
egui::Popup::menu(&response).show(|ui| {
|
||||
if open_docs.len() > 1 && ui.button(tl!("All open documents")).clicked() {
|
||||
app.add_open_documents(&open_docs.iter().map(|(i, _)| *i).collect::<Vec<_>>());
|
||||
ui.close();
|
||||
}
|
||||
for (i, name) in &open_docs {
|
||||
if ui.button(name.as_str()).clicked() {
|
||||
app.add_open_documents(&[*i]);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
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);
|
||||
let why = tl!("Select a file first");
|
||||
let pages_picked = app.combine_tab.selected_pages.len();
|
||||
let remove = match (pages_picked, count) {
|
||||
(1, _) => tl!("Remove page").to_string(),
|
||||
(k, _) if k > 1 => crate::i18n::fmt(tl!("Remove {n} pages"), &[("n", &k.to_string())]),
|
||||
(_, 1) => {
|
||||
let name = app.combine_draft.iter().find(|f| app.combine_tab.selected.contains(&f.id)).map_or("", |f| f.name.as_str());
|
||||
crate::i18n::fmt(tl!("Remove {name}"), &[("name", name)])
|
||||
}
|
||||
(_, 0) => tl!("Remove").to_string(),
|
||||
(_, k) => crate::i18n::fmt(tl!("Remove {n} files"), &[("n", &k.to_string())]),
|
||||
};
|
||||
if tool(ui, can_up, "chevron-up", tl!("Move up"), why).clicked() {
|
||||
events.action = Some(RowAction::MoveUp);
|
||||
}
|
||||
if tool(ui, can_down, "chevron-down", tl!("Move down"), why).clicked() {
|
||||
events.action = Some(RowAction::MoveDown);
|
||||
}
|
||||
if tool(ui, count > 0 || pages_picked > 0, "trash-2", &remove, why).clicked() {
|
||||
events.action = Some(RowAction::Remove);
|
||||
}
|
||||
let locked = app.combine_draft.iter().any(|f| f.lock.is_some() && app.combine_tab.selected.contains(&f.id));
|
||||
let unlock = tool(ui, locked, "lock-open", tl!("Unlock…"), tl!("Select a password-protected file first"));
|
||||
if unlock.clicked() {
|
||||
events.action = Some(RowAction::Unlock(None, unlock.rect.left_bottom() + vec2(0.0, 6.0)));
|
||||
}
|
||||
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");
|
||||
}
|
||||
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 {
|
||||
// Every file as its pages, or all as cards again.
|
||||
group(ui, &t, edge, 2.0 * 30.0 + 6.0);
|
||||
let can_expand = app.combine_draft.iter_mut().any(|f| !app.combine_tab.expanded.contains(&f.id) && expandable(f));
|
||||
if ui.add_enabled_ui(can_expand, |ui| icons::button(ui, "maximize-2", 30.0, false, tl!("Expand all"))).inner.clicked() {
|
||||
events.action = Some(RowAction::ExpandAll(true));
|
||||
}
|
||||
let can_collapse = !app.combine_tab.expanded.is_empty();
|
||||
if ui.add_enabled_ui(can_collapse, |ui| icons::button(ui, "minimize-2", 30.0, false, tl!("Collapse all"))).inner.clicked() {
|
||||
events.action = Some(RowAction::ExpandAll(false));
|
||||
}
|
||||
zoom_controls(app, ui, &t, edge);
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
|
||||
// The list, in a card that highlights while files are dragged over the window.
|
||||
let footer_h = 34.0;
|
||||
let card_h = (ui.available_height() - footer_h).max(120.0);
|
||||
let stroke = if files_hovering { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
|
||||
egui::Frame::NONE.fill(t.card).stroke(stroke).corner_radius(CornerRadius::same(t.radius)).inner_margin(egui::Margin::symmetric(8, 6)).show(
|
||||
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, &twice) {
|
||||
events.action = Some(action);
|
||||
}
|
||||
} else {
|
||||
let table_events = table(app, ui, &t, &checks, &selected, &conflicts, &twice);
|
||||
events.range_focused |= table_events.range_focused;
|
||||
events.range_changed |= table_events.range_changed;
|
||||
if table_events.action.is_some() {
|
||||
events.action = table_events.action;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// A file shown large, over everything.
|
||||
crate::combine_grid::preview(app, ui.ctx(), &t);
|
||||
|
||||
// Totals.
|
||||
ui.add_space(8.0);
|
||||
if n > 0 {
|
||||
ui.horizontal(|ui| {
|
||||
let pages: usize = checks.iter().filter_map(|c| c.as_ref().ok()).sum();
|
||||
// Each file's size once, however many parts it is in.
|
||||
let mut counted = BTreeSet::new();
|
||||
let size: usize = app.combine_draft.iter().filter(|f| counted.insert(f.group)).map(|f| f.bytes.len()).sum();
|
||||
// A file split into parts counts once.
|
||||
let n = app.combine_draft.iter().map(|f| f.group).collect::<BTreeSet<u64>>().len();
|
||||
let files = if n == 1 { tl!("1 file").to_string() } else { crate::i18n::fmt(tl!("{n} files"), &[("n", &n.to_string())]) };
|
||||
let pages = if pages == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.to_string())]) };
|
||||
ui.label(egui::RichText::new(format!("{files} · {pages} · {}", crate::panels::human_size(size))).color(t.text_muted));
|
||||
let bad = app
|
||||
.combine_draft
|
||||
.iter()
|
||||
.zip(&checks)
|
||||
.filter(|(f, c)| f.problem.is_some() || c.is_err())
|
||||
.map(|(f, _)| f.group)
|
||||
.collect::<BTreeSet<u64>>()
|
||||
.len();
|
||||
if bad > 0 {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let text = if bad == 1 {
|
||||
tl!("1 file needs attention").to_string()
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("{n} files need attention"), &[("n", &bad.to_string())])
|
||||
};
|
||||
ui.label(egui::RichText::new(text).color(ERROR));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// A Pages field taking the keyboard remembers the list; its first change makes the undo step.
|
||||
if events.range_focused {
|
||||
app.combine_tab.range_edit = Some(app.combine_snapshot());
|
||||
}
|
||||
if events.range_changed
|
||||
&& let Some(before) = app.combine_tab.range_edit.take()
|
||||
{
|
||||
app.combine_push_undo(before);
|
||||
}
|
||||
unlock_prompt(app, ui.ctx());
|
||||
if let Some(action) = events.action {
|
||||
app.combine_apply(action);
|
||||
}
|
||||
if combine && blocker.is_none() {
|
||||
if app.combine_draft.iter().any(CombineFile::unreadable) {
|
||||
app.combine_tab.confirm_skip = true;
|
||||
} else {
|
||||
app.combine_staged();
|
||||
}
|
||||
}
|
||||
skip_prompt(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// Most files the prompt names; the rest are counted.
|
||||
const SKIP_PROMPT_NAMES: usize = 8;
|
||||
|
||||
/// "Leave out files that can't be read?": names them and why, then combines the others, or not.
|
||||
fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
if !app.combine_tab.confirm_skip {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut left_out: Vec<(&str, &str)> = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for f in app.combine_draft.iter().filter(|f| f.unreadable() && seen.insert(f.group)) {
|
||||
left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default()));
|
||||
}
|
||||
let mut choice: Option<bool> = None;
|
||||
let modal = egui::Modal::new(egui::Id::unique("combine-skip")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("triangle-alert", 22.0, ERROR));
|
||||
ui.add(egui::Label::new(egui::RichText::new(tl!("Leave out files that can't be read?")).font(theme::semibold(16.0))).wrap());
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("These files can't be read, so they won't be in the combined PDF:")).color(t.text_muted));
|
||||
ui.add_space(6.0);
|
||||
for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(format!("• {name}")).strong());
|
||||
ui.label(egui::RichText::new(*why).color(t.text_muted));
|
||||
});
|
||||
}
|
||||
if left_out.len() > SKIP_PROMPT_NAMES {
|
||||
let more = (left_out.len() - SKIP_PROMPT_NAMES).to_string();
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("and {n} more"), &[("n", &more)])).color(t.text_muted));
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("Combine the rest"), true).clicked() {
|
||||
choice = Some(true);
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
choice = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
if choice.is_none() && modal.should_close() {
|
||||
choice = Some(false);
|
||||
}
|
||||
if let Some(go) = choice {
|
||||
app.combine_tab.confirm_skip = false;
|
||||
if go {
|
||||
app.combine_staged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock…'s password box, floating below what opened it. Enter unlocks, Escape or a click
|
||||
/// elsewhere closes it.
|
||||
fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let Some(mut prompt) = app.combine_tab.unlock.take() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let files: Vec<&CombineFile> = app.combine_draft.iter().filter(|f| prompt.rows.contains(&f.id)).collect();
|
||||
let title = match files.as_slice() {
|
||||
[one] => crate::i18n::fmt(tl!("Unlock {name}"), &[("name", &one.name)]),
|
||||
many => crate::i18n::fmt(tl!("Unlock {n} files"), &[("n", &many.len().to_string())]),
|
||||
};
|
||||
let explain = if files.iter().all(|f| f.lock == Some(Lock::Permissions)) {
|
||||
tl!("Enter the permissions password to allow combining")
|
||||
} else {
|
||||
tl!("Enter the password that opens it")
|
||||
};
|
||||
let batch = files.len() > 1;
|
||||
let (mut submit, mut cancel) = (false, false);
|
||||
let area = egui::Area::new(egui::Id::unique("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
|
||||
ui.set_width(340.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("lock", 16.0, t.icon));
|
||||
ui.add(egui::Label::new(egui::RichText::new(&title).font(theme::semibold(14.0))).truncate());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(explain).color(t.text_muted));
|
||||
if batch {
|
||||
ui.label(egui::RichText::new(tl!("The same password is tried on each file.")).small().color(t.text_faint));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
crate::widgets::begin_dialog(ui);
|
||||
let field = ui.add(crate::widgets::line(&mut prompt.input.0).password(!prompt.show).hint_text(tl!("Password")).desired_width(290.0));
|
||||
if !prompt.focused {
|
||||
field.request_focus();
|
||||
prompt.focused = true;
|
||||
}
|
||||
if field.changed() {
|
||||
prompt.error = None;
|
||||
}
|
||||
if field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
submit = true;
|
||||
}
|
||||
let (icon, tip) = if prompt.show { ("eye-off", tl!("Hide password")) } else { ("eye", tl!("Show password")) };
|
||||
if icons::button(ui, icon, 26.0, false, tip).clicked() {
|
||||
prompt.show = !prompt.show;
|
||||
}
|
||||
});
|
||||
if let Some(e) = &prompt.error {
|
||||
ui.label(egui::RichText::new(e).color(ERROR));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(tl!("The password is only used to read the file; it isn't saved.")).small().color(t.text_faint));
|
||||
ui.add_space(8.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if ui.add_enabled_ui(!prompt.input.0.is_empty(), |ui| widgets::pill_button(ui, tl!("Unlock"), true)).inner.clicked() {
|
||||
submit = true;
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) || area.response.clicked_elsewhere() {
|
||||
cancel = true;
|
||||
}
|
||||
if submit && !prompt.input.0.is_empty() {
|
||||
let rows = prompt.rows.clone();
|
||||
if app.combine_unlock(&rows, &prompt.input.0) {
|
||||
return;
|
||||
}
|
||||
prompt.error = Some(tl!("Wrong password").to_string());
|
||||
prompt.focused = false;
|
||||
}
|
||||
if !cancel {
|
||||
app.combine_tab.unlock = Some(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
fn separator(ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.add_space(4.0);
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Whether a file can be shown as its pages: readable, unlocked, and adding more than one.
|
||||
pub(super) fn expandable(f: &mut CombineFile) -> bool {
|
||||
f.lock.is_none() && f.problem.is_none() && f.selection().is_ok_and(|k| k > 1 && k <= crate::combine_grid::MAX_EXPAND)
|
||||
}
|
||||
|
||||
/// Why Combine can't run yet, if so.
|
||||
fn blocker(files: &[CombineFile], checks: &[Result<usize, String>]) -> Option<String> {
|
||||
// (A file split into parts is still one file.) Files that can't be read are left out, so
|
||||
// they don't count.
|
||||
if files.iter().filter(|f| !f.unreadable()).map(|f| f.group).collect::<BTreeSet<u64>>().len() < 2 {
|
||||
return Some(match files.iter().find(|f| f.unreadable()) {
|
||||
Some(f) => {
|
||||
crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", f.problem.as_deref().unwrap_or_default())])
|
||||
}
|
||||
None => tl!("Add at least two files").to_string(),
|
||||
});
|
||||
}
|
||||
for (f, check) in files.iter().zip(checks) {
|
||||
if f.unreadable() {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = &f.problem {
|
||||
return Some(crate::i18n::fmt(tl!("{name} can't be combined: {why}"), &[("name", &f.name), ("why", p)]));
|
||||
}
|
||||
if let Err(e) = check {
|
||||
return Some(crate::i18n::fmt(tl!("Check the pages of {name}: {why}"), &[("name", &f.name), ("why", e)]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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. 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;
|
||||
ui.ctx().input_mut(|i| {
|
||||
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);
|
||||
}
|
||||
if i.consume_key(Modifiers::ALT, Key::ArrowDown) {
|
||||
return Some(RowAction::MoveDown);
|
||||
}
|
||||
if i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace) {
|
||||
return Some(RowAction::Remove);
|
||||
}
|
||||
}
|
||||
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, 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::Role::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));
|
||||
ui.add(icons::image("files", 40.0, if files_hovering { t.accent } else { t.text_faint }));
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new(tl!("Add the files to combine")).font(theme::medium(15.0)));
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Drop PDFs, images, text files or folders here, or choose them.")).color(t.text_faint));
|
||||
ui.add_space(12.0);
|
||||
let folders = cfg!(not(target_arch = "wasm32"));
|
||||
// Centre the buttons: measure them first.
|
||||
let font = theme::medium(12.5);
|
||||
let width = |s: &str| ui.fonts_mut(|f| f.layout_no_wrap(s.to_owned(), font.clone(), t.text).size().x);
|
||||
let total = width(tl!("Add files…")) + 26.0 + if folders { width(tl!("Add folder…")) + 46.0 + 8.0 } else { 0.0 };
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(((ui.available_width() - total) / 2.0).max(0.0));
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if widgets::pill_button(ui, tl!("Add files…"), true).clicked() {
|
||||
app.combine_dialog();
|
||||
}
|
||||
if folders && widgets::icon_pill(ui, "folder", tl!("Add folder…"), false).clicked() {
|
||||
app.combine_add_folder(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// "Add files…" with a menu of the other ways to add: folders (desktop).
|
||||
fn add_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if !cfg!(not(target_arch = "wasm32")) {
|
||||
if widgets::icon_pill(ui, "plus", tl!("Add files…"), false).clicked() {
|
||||
app.combine_dialog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (main, more) = widgets::split_pill(ui, "plus", tl!("Add files…"), tl!("More ways to add files"));
|
||||
if main.clicked() {
|
||||
app.combine_dialog();
|
||||
}
|
||||
egui::Popup::menu(&more).show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
let item = |ui: &mut egui::Ui, icon: &str, label: &str| {
|
||||
ui.add(egui::Button::image_and_text(icons::image(icon, 15.0, ui.visuals().text_color()), label))
|
||||
};
|
||||
if item(ui, "file-plus", tl!("Add files…")).clicked() {
|
||||
app.combine_dialog();
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "folder", tl!("Add folder…")).on_hover_text(tl!("Every PDF, image and text file in the folder")).clicked() {
|
||||
app.combine_add_folder(false);
|
||||
ui.close();
|
||||
}
|
||||
if item(ui, "folder-open", tl!("Add folder and subfolders…"))
|
||||
.on_hover_text(tl!("Every PDF, image and text file in the folder and the folders inside it"))
|
||||
.clicked()
|
||||
{
|
||||
app.combine_add_folder(true);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user