mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
* M4: Combine files in its own tab, with a sortable table and unlocking of protected files Combine files was a modal dialog with a cramped list and up/down buttons on every row. It is now a tab next to Home and the documents, showing a table of the files: File name | Pages | Size | Modified | Warnings. - Columns sort when their heading is clicked (anywhere in the cell; again to reverse; names in natural order), resize from their edges and move by dragging the heading. Order and widths are kept in the settings; a right-click on a heading resets them. - Rows select like a file manager (click, Ctrl/Cmd+click, Shift+click, Shift+arrows, Ctrl/Cmd+A). One toolbar moves, removes and unlocks the selection; Alt+arrows and dragging move it too. Every change to the list undoes and redoes with Cmd/Ctrl+Z (also toolbar buttons). - Add files, Add folder, Add folder and subfolders (a split button), Add open documents, or drop files and folders on the tab. - Problems show before Combine: password-protected files, security settings that withhold page assembly, page ranges (checked as typed, with the pages they take), duplicates. Files that open but are protected say that the combined file won't keep their security; the result is never encrypted. - Unlock... asks for a file's open or permissions password (one password can be tried on several files). Passwords stay in memory, are never printed, and reach the engine through combine_unlocked; doc_combine takes them as `passwords`. The engine decides whether a password works, since the inspector rejects some it accepts (RC4 owner passwords). organize.combine-sort is shipped and organize.combine-options partial (include open files). Strings are translated for es, fr, ja, zh-hans, zh-hant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Credit Marlon Konrad (mlkonrad) in the About window Adds my own entry to contributors/people.toml, as docs/contributors.md asks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
192 lines
10 KiB
Rust
192 lines
10 KiB
Rust
//! Small custom widgets built on the design tokens.
|
|
|
|
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, vec2};
|
|
|
|
use crate::theme::{self, Tokens};
|
|
use crate::{PdfCraftApp, icons};
|
|
|
|
/// A mode-bar tab: text with an underline when active.
|
|
pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) };
|
|
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label));
|
|
if resp.hovered() && !active {
|
|
ui.painter().rect_filled(rect.shrink2(vec2(2.0, 9.0)), CornerRadius::same(6), t.hover);
|
|
}
|
|
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, if active { t.text } else { t.text_muted });
|
|
if active {
|
|
let r = Rect::from_min_max(rect.left_bottom() + vec2(11.0, -3.0), rect.right_bottom() - vec2(11.0, 0.0));
|
|
ui.painter().rect_filled(r, CornerRadius::same(1), t.text);
|
|
}
|
|
resp
|
|
}
|
|
|
|
/// Rounded pill button; `primary` fills with the accent.
|
|
pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = theme::medium(12.5);
|
|
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
|
let (fill, stroke, text) = if primary {
|
|
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
|
|
} else {
|
|
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
|
|
};
|
|
ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside);
|
|
ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, font, text);
|
|
resp
|
|
}
|
|
|
|
/// Icon + label, transparent until hovered.
|
|
pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = theme::medium(13.0);
|
|
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, 30.0), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
|
if resp.hovered() {
|
|
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
|
}
|
|
icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(18.0, 18.0)), icon, 17.0, t.icon);
|
|
ui.painter().text(rect.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
|
resp
|
|
}
|
|
|
|
/// A search-field lookalike that opens the command palette.
|
|
pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder));
|
|
let fill = if resp.hovered() { t.hover } else { t.field };
|
|
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
|
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted);
|
|
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, placeholder, theme::regular(13.0), t.text_faint);
|
|
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, "⌘K", theme::regular(11.5), t.text_faint);
|
|
resp.on_hover_cursor(egui::CursorIcon::Text)
|
|
}
|
|
|
|
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
|
|
ui.add(egui::Button::new(label).shortcut_text(shortcut))
|
|
}
|
|
|
|
pub fn section_title(ui: &mut egui::Ui, text: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.add_space(10.0);
|
|
// Section titles across every panel go through here, so one translation point covers them.
|
|
ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
|
|
ui.add_space(2.0);
|
|
}
|
|
|
|
/// Transient message at the bottom centre.
|
|
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
|
let Some((msg, start)) = app.toast.clone() else { return };
|
|
let now = ctx.input(|i| i.time);
|
|
let start = if start == 0.0 { now } else { start };
|
|
app.toast = Some((msg.clone(), start));
|
|
if now - start > 3.5 {
|
|
app.toast = None;
|
|
return;
|
|
}
|
|
let t = Tokens::get(ctx);
|
|
let screen = ctx.content_rect();
|
|
egui::Area::new(egui::Id::new("toast"))
|
|
.order(egui::Order::Tooltip)
|
|
.pivot(Align2::CENTER_BOTTOM)
|
|
.fixed_pos(screen.center_bottom() - vec2(0.0, 28.0))
|
|
.show(ctx, |ui| {
|
|
egui::Frame::NONE
|
|
.fill(if t.dark() { Color32::from_rgb(0xEC, 0xEC, 0xEF) } else { Color32::from_rgb(0x2A, 0x2A, 0x2F) })
|
|
.corner_radius(CornerRadius::same(8))
|
|
.inner_margin(egui::Margin::symmetric(16, 10))
|
|
.show(ui, |ui| {
|
|
ui.label(egui::RichText::new(msg).color(if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }));
|
|
});
|
|
});
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
|
}
|
|
|
|
/// The ArtCraft wordmark (Storyteller's brand, docs/brand/; not open source), sized to `height`.
|
|
pub fn artcraft_logo(ui: &mut egui::Ui, height: f32) -> Response {
|
|
let dark = ui.visuals().dark_mode;
|
|
let (uri, bytes): (&str, &'static [u8]) = if dark {
|
|
("bytes://artcraft-logo-white.svg", include_bytes!("../../../docs/brand/artcraft-logo-white.svg"))
|
|
} else {
|
|
("bytes://artcraft-logo.svg", include_bytes!("../../../docs/brand/artcraft-logo.svg"))
|
|
};
|
|
ui.add(egui::Image::from_bytes(uri, bytes).max_height(height).alt_text("ArtCraft"))
|
|
}
|
|
|
|
/// The ArtCraft mark (brand blue, works on light and dark), `size` points square.
|
|
pub fn artcraft_mark(ui: &mut egui::Ui, size: f32) -> Response {
|
|
ui.add(
|
|
egui::Image::from_bytes("bytes://artcraft-mark.svg", include_bytes!("../../../docs/brand/artcraft-mark.svg"))
|
|
.fit_to_exact_size(vec2(size, size))
|
|
.alt_text("ArtCraft"),
|
|
)
|
|
}
|
|
|
|
/// Buttons for every community link (`pdfcraft_engine::links`), Discord first and prominent.
|
|
/// Returns the registry command of the one clicked.
|
|
pub fn community_links(ui: &mut egui::Ui) -> Option<&'static str> {
|
|
let mut clicked = None;
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
|
|
for (i, l) in pdfcraft_engine::links::LINKS.iter().enumerate() {
|
|
let resp = if i == 0 { icon_pill(ui, l.icon, tl!("Join our Discord"), true) } else { icon_pill(ui, l.icon, tl!(l.label), false) };
|
|
if resp.on_hover_text(l.url).clicked() {
|
|
clicked = Some(l.command);
|
|
}
|
|
}
|
|
});
|
|
clicked
|
|
}
|
|
|
|
/// [`icon_pill`] with a ▾ part at its end that opens a menu of related choices. Returns the
|
|
/// main button's response and the ▾'s (give it to `egui::Popup::menu`).
|
|
pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Response, Response) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = theme::medium(12.5);
|
|
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
|
let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover());
|
|
let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0);
|
|
let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click());
|
|
let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click());
|
|
main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
|
arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more));
|
|
let radius = CornerRadius::same(15);
|
|
ui.painter().rect(rect, radius, t.card, Stroke::new(1.2, t.text_muted), egui::StrokeKind::Inside);
|
|
let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 };
|
|
let right = CornerRadius { nw: 0, sw: 0, ne: 15, se: 15 };
|
|
for (r, resp, corners) in [(main_rect, &main, left), (more_rect, &arrow, right)] {
|
|
if resp.hovered() {
|
|
ui.painter().rect_filled(r.shrink(1.2), corners, t.hover);
|
|
}
|
|
}
|
|
ui.painter().vline(more_rect.left(), rect.y_range().shrink(7.0), Stroke::new(1.0, t.text_muted));
|
|
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, t.text);
|
|
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, t.text);
|
|
crate::icons::paint(ui, Rect::from_center_size(more_rect.center(), vec2(14.0, 14.0)), "chevron-down", 13.0, t.text);
|
|
(main.on_hover_text(label), arrow.on_hover_text(more))
|
|
}
|
|
|
|
/// A pill button with an icon (primary = filled accent).
|
|
pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> Response {
|
|
let t = Tokens::get(ui.ctx());
|
|
let font = theme::medium(12.5);
|
|
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
|
|
let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click());
|
|
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
|
let (fill, stroke, text) = if primary {
|
|
(if resp.hovered() { t.accent_text } else { t.accent }, Stroke::NONE, Color32::WHITE)
|
|
} else {
|
|
(if resp.hovered() { t.hover } else { t.card }, Stroke::new(1.2, t.text_muted), t.text)
|
|
};
|
|
ui.painter().rect(rect, CornerRadius::same(15), fill, stroke, egui::StrokeKind::Inside);
|
|
crate::icons::paint(ui, Rect::from_min_size(rect.min + vec2(12.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, text);
|
|
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, label, font, text);
|
|
resp
|
|
}
|