diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bcc4123f25..2294745ad7 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -8,644 +8,17 @@ use pdfcraft_render::{DocInfo, FieldKind, OutlineItem}; use crate::theme::{self, Tokens}; use crate::{LeftPanel, PdfCraftApp, RightPanel, icons, widgets}; -const COLLAPSED_TOOLS: usize = 14; +mod bookmarks; +mod edit; +mod pages; +mod right; +mod tools; -fn hue(g: &ToolGroup) -> Color32 { - Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]) -} - -pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Organize closed another way (the grid's Close, or a page opened from it) closes the tool too, - // as its panel's Close does, back to the workspace's own panel. - if app.left == LeftPanel::Tool("organize") && app.active.and_then(|i| app.views.get(i)).is_some_and(|v| !v.organize) { - app.left = crate::organize_panel::workspace_panel(app.mode); - } - egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 14, right: 10, top: 12, bottom: 10 }).stroke(Stroke::new(1.0, t.divider)).show( - ui, - |ui| match app.left { - // Organize pages has its own panel whenever the grid shows (with a document open). - _ if app.active.is_some() && crate::organize_panel::showing(app) => crate::organize_panel::panel(app, ui, &t), - LeftPanel::AllTools => all_tools(app, ui, &t), - LeftPanel::Tool(id) => match catalog::group(id) { - Some(g) => tool_detail(app, ui, &t, g), - None => app.left = LeftPanel::AllTools, - }, - LeftPanel::CalculationOrder => crate::calc_order_ui::panel(app, ui, &t), - }, - ); -} - -/// A panel's title row: a leading button (`lead`: its icon and tooltip) when given, the title -/// and Close. Returns whether the leading button and Close were clicked. -pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Option<(&str, &str)>) -> (bool, bool) { - let mut go_back = false; - let mut close = false; - ui.horizontal(|ui| { - if let Some((icon, tip)) = lead - && icons::button(ui, icon, 26.0, false, tip).clicked() - { - go_back = true; - } - // Reserve space for Close before laying out the translated title. Long tool names - // must never expand the fixed-width sidebar or push the Close control off-screen. - let title_width = (ui.available_width() - 26.0 - ui.spacing().item_spacing.x).max(1.0); - ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(tl!(title)).font(theme::semibold(15.5)).color(t.text)).truncate()) - .on_hover_text(tl!(title)); - if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() { - close = true; - } - }); - ui.add_space(4.0); - ui.separator(); - ui.add_space(6.0); - (go_back, close) -} - -/// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few -/// tools until View more shows them all. -fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - // Labels distinct from the Combine grid's Expand and Collapse, which can show beside them. - let (expand, close) = panel_header(ui, t, "All tools", Some(("maximize-2", tl!("Expand all tools")))); - if expand { - app.expand_all_tools(); - } - if close { - app.left_open = false; - } - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - ui.add_space(2.0); - let shown = if app.all_tools_expanded { TOOL_GROUPS.len() } else { COLLAPSED_TOOLS.min(TOOL_GROUPS.len()) }; - for g in &TOOL_GROUPS[..shown] { - if tool_row(ui, t, g).clicked() { - app.select_tool(g.id); - } - } - ui.add_space(4.0); - let more = if app.all_tools_expanded { "View less" } else { "View more" }; - if ui - .add( - egui::Label::new(egui::RichText::new(tl!(more)).color(t.accent_text).font(theme::medium(13.0))) - .selectable(false) - .sense(Sense::click()), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .clicked() - { - app.all_tools_expanded = !app.all_tools_expanded; - } - }); -} - -/// Draw a one-line row label without painting into the status chip or beyond the sidebar. -/// Accessibility retains the complete name through the row's WidgetInfo. -fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, left_center: egui::Pos2, max_width: f32) -> bool { - let mut job = egui::text::LayoutJob::simple_singleline(text.to_owned(), font, color); - job.wrap = egui::text::TextWrapping { max_width: max_width.max(1.0), max_rows: 1, break_anywhere: true, ..Default::default() }; - let galley = ui.fonts_mut(|f| f.layout_job(job)); - let elided = galley.elided; - ui.painter().galley(left_center - vec2(0.0, galley.size().y / 2.0), galley, color); - elided -} - -/// Draw one tool-group row with its icon, label, badge and availability hint. -fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); - let press = theme::Press::track(ui, &resp); - press.wash(ui, rect, 6, false); - let body = rect.translate(press.offset()); - icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 7.0), vec2(20.0, 20.0)), g.icon, 19.0, hue(g)); - let right_reserved = match (g.badge, g.availability) { - (Some(b), _) => ui.fonts_mut(|f| f.layout_no_wrap(tl!(b).to_owned(), theme::semibold(9.5), Color32::WHITE).size().x) + 26.0, - (None, Availability::Planned(m)) if resp.hovered() => { - let hint = format!("{} · {m}", tl!("Planned")); - ui.fonts_mut(|f| f.layout_no_wrap(hint, theme::medium(10.5), t.text_faint).size().x) + 18.0 - } - _ => 8.0, - }; - let clipped = - row_label(ui, tl!(g.label), theme::regular(13.5), t.text, body.left_center() + vec2(36.0, 0.0), rect.width() - 36.0 - right_reserved); - match (g.badge, g.availability) { - (Some(b), _) => { - let font = theme::semibold(9.5); - let badge = tl!(b); - let w = ui.fonts_mut(|f| f.layout_no_wrap(badge.to_string(), font.clone(), Color32::WHITE).size().x); - let r = Rect::from_center_size(body.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0)); - ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new); - ui.painter().text(r.center(), Align2::CENTER_CENTER, badge, font, Color32::WHITE); - } - // Planned tools: a quiet milestone hint instead of a chip, so the list stays calm. - (None, Availability::Planned(m)) if resp.hovered() => { - ui.painter().text( - body.right_center() - vec2(10.0, 0.0), - Align2::RIGHT_CENTER, - format!("{} · {m}", tl!("Planned")), - theme::medium(10.5), - t.text_faint, - ); - } - _ => {} - } - let tip = match g.availability { - Availability::Ready => tl!("Available").to_string(), - Availability::Planned(m) => format!("{} {m} — {}", tl!("Planned for milestone"), tl!("open to see what it will include")), - Availability::Provider => tl!("Optional: needs an AI provider you configure").into(), - }; - let tip = if clipped { format!("{} — {tip}", tl!(g.label)) } else { tip }; - theme::hand(resp).on_hover_text(tip) -} - -/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels. -fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { - if g.id == "edit" { - edit_panel(app, ui, t, g); - return; - } - let (back, close) = panel_header(ui, t, g.label, Some(("chevron-left", tl!("Back to all tools")))); - if back { - app.left = LeftPanel::AllTools; - app.enter_mode(crate::Mode::AllTools); - } - if close { - app.left_open = false; - } - if let Availability::Planned(m) = g.availability { - egui::Frame::NONE.fill(t.accent_soft).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.label( - egui::RichText::new(format!("{} {m}. {}", tl!("Coming in milestone"), tl!("Items marked Ready work today."))) - .color(t.text) - .font(theme::regular(12.0)), - ); - }); - } - if g.id == "measure" { - crate::measure_ui::panel(app, ui, t); - return; - } - // Add a stamp: the palette. - if g.id == "stamp" { - stamp_palette(app, ui, t); - return; - } - // Prepare a form: Preview fills the form as a reader would; Edit goes back. - if g.id == "form" { - ui.horizontal(|ui| { - let label = if app.form_preview { "Edit fields" } else { "Preview" }; - if widgets::pill_button(ui, tl!(label), app.form_preview).on_hover_text(tl!("Try the form as people filling it in will see it")).clicked() - { - app.form_preview = !app.form_preview; - if app.form_preview { - app.quick_tool = crate::QuickTool::Select; - } - } - }); - ui.add_space(6.0); - } - let mut run = None; - // Redact a PDF has Acrobat's footer: Clear all / Redact all. - let footer = g.id == "redact"; - let marks = if footer { app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()) } else { 0 }; - let list_h = if footer { (ui.available_height() - 52.0).max(80.0) } else { ui.available_height() }; - egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| { - ui.add_space(2.0); - for s in g.sections { - widgets::section_title(ui, tl!(s.title)); - for item in s.items { - if g.id == "fill_sign" && (item.command.starts_with("sign.fill.signature") || item.command.starts_with("sign.fill.initials")) { - continue; - } - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(item.label))); - let ready = item.availability == Availability::Ready; - let press = theme::Press::track(ui, &resp); - press.wash(ui, rect, 6, false); - let body = rect.translate(press.offset()); - let fg = if ready { t.text } else { t.text_muted }; - icons::paint( - ui, - Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), - item.icon, - 17.0, - if ready { hue(g) } else { t.text_faint }, - ); - let (chip, fill, cfg) = match item.availability { - Availability::Ready => (tl!("Ready"), Color32::from_rgb(0xDD, 0xF3, 0xE4), Color32::from_rgb(0x1E, 0x7B, 0x43)), - Availability::Planned(m) => (m, t.pressed, t.text_muted), - Availability::Provider => ("AI", t.pressed, t.text_muted), - }; - let font = theme::semibold(9.5); - let chip_label = tl!(chip).to_string(); - let w = crate::widgets::text_width(ui, &chip_label, &font); - let r = Rect::from_center_size(body.right_center() - vec2(w / 2.0 + 10.0, 0.0), vec2(w + 10.0, 16.0)); - row_label(ui, tl!(item.label), theme::regular(13.0), fg, body.left_center() + vec2(34.0, 0.0), r.left() - body.left() - 42.0); - ui.painter().rect_filled(r, CornerRadius::same(4), fill); - ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg); - let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command)); - // An entry with further commands opens a menu; its caret goes before the chip. - if item.actions.is_empty() { - if resp.clicked() { - run = Some(item.command); - } - } else if let Some(cmd) = actions_menu(app, ui, &resp, item, r.left_center() - vec2(10.0, 0.0), fg) { - run = Some(cmd); - } - } - } - if g.id == "fill_sign" { - widgets::section_title(ui, tl!("Sign yourself")); - if let Some(cmd) = crate::fill_sign::signature_entries(ui, app, t) { - run = Some(cmd); - } - } - }); - if footer { - ui.add_space(8.0); - ui.horizontal(|ui| { - if marks > 0 { - ui.label( - egui::RichText::new(if marks == 1 { - crate::i18n::fmt(tl!("1 mark"), &[]) - } else { - crate::i18n::fmt(tl!("{n} marks"), &[("n", &marks.to_string())]) - }) - .color(t.text_muted), - ); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, tl!("Redact all"), true)).inner.clicked() { - run = Some("redact.apply"); - } - if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, tl!("Clear all"), false)).inner.clicked() { - run = Some("redact.clear"); - } - }); - }); - } - if let Some(cmd) = run { - app.run_command(cmd); - } -} - -/// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. -const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; -/// Add content items shown before More. -const EDIT_CONTENT_SHOWN: usize = 5; -/// Other options: the tools Acrobat's Edit panel links to. -const EDIT_OTHER_TOOLS: [&str; 3] = ["combine", "redact", "form"]; - -/// Edit a PDF, laid out as Acrobat's Edit panel: the title with Preferences and Close; Format text -/// while text is being edited or added; Modify page as a row of icon buttons and Organize pages; -/// Add content with the rarer items under More; and Other options, the neighbouring tools. -/// Edit text & images itself is on the floating toolbar, and on from the moment Edit opens. -/// Every item comes from the catalogue, so its label, icon and availability have one source. -fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { - let items = || g.sections.iter().flat_map(|s| s.items.iter()); - let item = |command: &str| items().find(|i| i.command == command); - let mut run = None; - let mut tool = None; - // Back to All tools only when Edit was opened from there; the Edit tab is its own way back. - let from_all_tools = app.mode != crate::Mode::Edit; - ui.horizontal(|ui| { - if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { - app.left = LeftPanel::AllTools; - app.enter_mode(crate::Mode::AllTools); - } - // "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools. - let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") }; - let title_width = (ui.available_width() - 2.0 * (26.0 + ui.spacing().item_spacing.x)).max(1.0); - ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)).truncate()) - .on_hover_text(title); - if icons::button(ui, "settings", 26.0, false, tl!("Preferences")).clicked() { - run = Some("app.preferences"); - } - if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() { - app.left_open = false; - } - }); - ui.add_space(4.0); - ui.separator(); - ui.add_space(6.0); - // Several objects selected: how many, and how to move them. Acrobat's panel has no idle hint. - let count = app.active_ids().filter(|_| app.quick_tool == crate::QuickTool::EditText).map_or(0, |(i, _)| app.views[i].objects.count()); - if count > 0 { - ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected")); - ui.label(tl!("Drag the selection to move it. Esc clears the selection.")); - ui.add_space(6.0); - } - // Format text at the top while text is selected or being added. - format_section(app, ui, t); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - widgets::section_title(ui, tl!("Modify page")); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - for i in EDIT_PAGE_BUTTONS.iter().filter_map(|c| item(c)) { - // Enabled as the command itself is: a document that forbids page assembly refuses these. - let enabled = pdfcraft_engine::commands::command(i.command).is_some_and(|spec| app.command_enabled(spec)); - let resp = ui.add_enabled_ui(enabled, |ui| icons::button(ui, i.icon, 34.0, false, tl!(i.label))).inner; - if resp.clicked() { - run = Some(i.command); - } - } - }); - ui.add_space(2.0); - // Organize pages is a workspace of its own: its panel replaces this one, with the grid. - if let Some(i) = item("page.organize") - && edit_row(ui, t, i.icon, tl!(i.label), i.availability).clicked() - { - // With no document there is no grid to open: the command asks for a file first. - if app.active.is_some() { - tool = Some("organize"); - } else { - run = Some("page.organize"); - } - } - - widgets::section_title(ui, tl!("Add content")); - let content: Vec<_> = - g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); - let more_id = egui::Id::unique("edit_panel_more"); - let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); - let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; - for i in content.iter().take(shown) { - let resp = edit_row(ui, t, i.icon, tl!(i.label), i.availability); - // Header and footer, Watermark and Background open their Add / Update / Remove menu. - if i.actions.is_empty() { - if resp.clicked() { - run = Some(i.command); - } - } else if let Some(cmd) = actions_menu(app, ui, &resp, i, resp.rect.right_center() - vec2(14.0, 0.0), t.text) { - run = Some(cmd); - } - } - if content.len() > EDIT_CONTENT_SHOWN { - ui.add_space(4.0); - let label = if more { tl!("Less") } else { tl!("More") }; - let resp = ui - .add( - egui::Label::new(egui::RichText::new(label).color(t.accent_text).font(theme::medium(13.0))) - .selectable(false) - .sense(Sense::click()), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand); - if resp.clicked() { - ui.data_mut(|d| d.insert_temp(more_id, !more)); - } - } - - widgets::section_title(ui, tl!("Other options")); - for other in EDIT_OTHER_TOOLS.iter().filter_map(|id| catalog::group(id)) { - if edit_row(ui, t, other.icon, tl!(other.label), other.availability).clicked() { - tool = Some(other.id); - } - } - }); - if let Some(id) = tool { - app.select_tool(id); - } - if let Some(cmd) = run { - app.run_command(cmd); - } -} - -/// A tool entry with further commands (Add / Update / Remove): a caret at `caret` says it opens a -/// menu, and each command is enabled by its own needs. Returns the command chosen. -fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: &ToolItem, caret: egui::Pos2, color: Color32) -> Option<&'static str> { - ui.painter().add(egui::Shape::convex_polygon( - vec![caret + vec2(-3.5, -2.0), caret + vec2(3.5, -2.0), caret + vec2(0.0, 2.5)], - color, - egui::Stroke::NONE, - )); - let mut run = None; - egui::Popup::menu(resp).show(|ui| { - ui.set_min_width(180.0); - for (n, id) in item.actions.iter().enumerate() { - let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; - // Add stays usable with no document open: it asks for a file first. - let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec); - let label = crate::i18n::menu_label(spec.id, spec.label); - if ui.add_enabled(enabled, egui::Button::new(label)).clicked() { - run = Some(spec.id); - ui.close(); - } - } - }); - run -} - -/// One row of the Edit panel: a monochrome icon and the label, as Acrobat draws them. A planned -/// item is muted and says when it ships when hovered. -fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); - let press = theme::Press::track(ui, &resp); - press.wash(ui, rect, 6, false); - let body = rect.translate(press.offset()); - let ready = availability == Availability::Ready; - let fg = if ready { t.text } else { t.text_muted }; - icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), icon, 17.0, if ready { t.text } else { t.text_faint }); - row_label(ui, label, theme::regular(13.5), fg, body.left_center() + vec2(34.0, 0.0), rect.width() - 42.0); - let resp = theme::hand(resp); - match availability { - Availability::Ready => resp, - Availability::Planned(m) => resp.on_hover_text(format!("{} {m}", tl!("Planned for milestone"))), - Availability::Provider => resp.on_hover_text(tl!("Optional: needs an AI provider you configure")), - } -} - -/// Add a stamp: Dynamic, Sign Here and Standard Business stamps; click one, then click on the -/// page to place it. -fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - use pdfcraft_engine::{StampGroup, StampKind}; - ui.label(egui::RichText::new(tl!("Choose a stamp, then click on the page to place it.")).small().color(t.text_faint)); - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - for (group, title) in [ - (StampGroup::Dynamic, tl!("Dynamic")), - (StampGroup::SignHere, tl!("Sign Here")), - (StampGroup::StandardBusiness, tl!("Standard Business")), - ] { - widgets::section_title(ui, title); - for kind in StampKind::ALL.into_iter().filter(|k| k.group() == group) { - let active = app.quick_tool == crate::QuickTool::Stamp(kind); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, kind.label())); - let press = theme::Press::track(ui, &resp); - press.wash(ui, rect, 6, active); - let body = rect.translate(press.offset()); - let [r, g, b] = kind.color().map(|v| (v * 255.0) as u8); - let col = Color32::from_rgb(r, g, b); - let chip = Rect::from_min_size(body.min + vec2(8.0, 5.0), vec2((rect.width() - 16.0).min(200.0), 28.0)); - if group == StampGroup::SignHere { - let tip = chip.height() * 0.45; - let pts = vec![ - chip.left_center(), - chip.left_top() + vec2(tip, 0.0), - chip.right_top(), - chip.right_bottom(), - chip.left_bottom() + vec2(tip, 0.0), - ]; - ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); - let label = kind.label(); - let font = theme::semibold(11.0); - let shown = crate::widgets::fit_line(label, (chip.width() - 10.0).max(0.0), |s| crate::widgets::text_width(ui, s, &font)); - ui.painter().with_clip_rect(chip.shrink(3.0)).text( - chip.center() + vec2(tip / 2.0, 0.0), - Align2::CENTER_CENTER, - shown, - font, - Color32::WHITE, - ); - } else { - ui.painter().rect(chip, CornerRadius::same(5), col.gamma_multiply(0.1), Stroke::new(1.5, col), egui::StrokeKind::Inside); - let y = if group == StampGroup::Dynamic { chip.center().y - 4.0 } else { chip.center().y }; - let font = theme::semibold(11.0); - let shown = crate::widgets::fit_line(kind.label(), (chip.width() - 10.0).max(0.0), |s| crate::widgets::text_width(ui, s, &font)); - ui.painter().with_clip_rect(chip.shrink(3.0)).text(egui::pos2(chip.center().x, y), Align2::CENTER_CENTER, shown, font, col); - if group == StampGroup::Dynamic { - let by = "By name at time, date"; - let by_font = theme::regular(7.5); - let by_shown = crate::widgets::fit_line(by, (chip.width() - 8.0).max(0.0), |s| crate::widgets::text_width(ui, s, &by_font)); - ui.painter().with_clip_rect(chip.shrink(2.0)).text( - egui::pos2(chip.center().x, chip.bottom() - 6.0), - Align2::CENTER_CENTER, - by_shown, - by_font, - col, - ); - } - } - if theme::hand(resp).clicked() { - app.quick_tool = crate::QuickTool::Stamp(kind); - } - } - } - crate::stamps_ui::palette_section(app, ui, t); - }); -} - -/// Edit a PDF ▸ Format text: for the selected added text (one undoable change), or the style -/// new text gets. -fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - // Editing a paragraph of existing text: its formatting, applied as it changes. - if let Some((i, _)) = app.active_ids() - && let Some(ed) = app.views[i].line_editor.clone() - { - use crate::format_text_ui::{FontChoice, Fonts}; - let mut ed = ed; - let sources: Vec<(String, String)> = app - .session - .get(app.views[i].id) - .map(|d| d.text_editor_fonts(ed.page)) - .unwrap_or_default() - .into_iter() - .map(|f| (f.resource, f.face)) - .collect(); - let shown = ed.font_shown(&sources); - let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }); - let mut changed = out.changed; - match out.font { - Some(FontChoice::KeepSource) => { - ed.keep_source_font(); - changed = true; - } - Some(FontChoice::Resource(resource)) => { - ed.font_resource = Some(resource); - changed = true; - } - Some(FontChoice::Family(family)) => { - ed.choose_family(family); - changed = true; - } - Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false), - #[cfg(not(target_arch = "wasm32"))] - Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]), - #[cfg(target_arch = "wasm32")] - Some(FontChoice::Installed(_)) => {} - None => {} - } - if changed { - app.apply_line_format(i, ed, Some(ui.ctx())); - } - return; - } - let image = app.active_ids().and_then(|(i, id)| { - let (page, index) = app.views[i].content.selected?; - let doc = app.session.get(id)?; - match &doc.added().iter().filter(|a| a.page == page).nth(index)?.content { - pdfcraft_engine::AddedContent::Image(img) => Some((page, index, img.clone())), - _ => None, - } - }); - if let Some((page, index, img)) = image { - match crate::content_ui::image_panel(ui, t, &img) { - Some(crate::content_ui::ImageAction::Update(content)) => { - app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content }); - } - Some(crate::content_ui::ImageAction::Replace) => app.replace_image_dialog(page, index), - None => {} - } - ui.add_space(6.0); - ui.separator(); - return; - } - let selected = app.active_ids().and_then(|(i, id)| { - let (page, index) = app.views[i].content.selected?; - let doc = app.session.get(id)?; - let a = doc.added().iter().filter(|a| a.page == page).nth(index)?; - match &a.content { - pdfcraft_engine::AddedContent::Text(text) => Some((page, index, text.clone())), - _ => None, - } - }); - match selected { - Some((page, index, text)) => { - let mut style = text.clone(); - added_text_format(ui, t, &mut style); - if style != text { - // The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later. - if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) { - app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style }; - } - } - } - None if app.quick_tool == crate::QuickTool::AddText => { - crate::content_ui::hint(ui, t); - let mut style = app.text_style.clone(); - added_text_format(ui, t, &mut style); - app.text_style = style; - } - None => {} - } -} - -/// Format text for added text: the standard families, size, colour, style and alignment. -fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) { - let shown = style.family.label().to_string(); - let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }); - if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { - style.family = family; - } -} - -impl PdfCraftApp { - /// Apply the open paragraph's formatting as one change; the paragraph stays open. - pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { - let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; - if self.apply_edit(edit) { - ed.applied(); - if let Some(doc) = self.session.get(self.views[index].id) - && let Some(block) = doc.text_blocks(ed.page).get(ed.block) - { - ed.refresh_source(block, doc.text_lines(ed.page)); - if let Some(ctx) = ctx { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); - } - } - } - self.views[index].line_editor = Some(ed); - } -} +pub use bookmarks::BmAction; +pub(crate) use bookmarks::ellipsized_prefix; +pub use right::right_panel; +pub use tools::left_panel; +pub(crate) use tools::panel_header; // ───────────────────────────────────────────────────────────────────────────── right panels @@ -656,1181 +29,6 @@ pub(crate) enum Nav { Flash(usize, [f32; 4]), } -/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. -pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let Some((index, id)) = app.active_ids() else { return }; - let Some(panel) = app.right else { return }; - let mut nav: Option