feat(ui): split view: two panes, one document or two (#2518)

This commit is contained in:
regendorf-ux authored and GitHub committed 2026-11-09 18:11:13 +08:00
1 parent c028b51b3d
commit 08c3d3a5ab
38 files changed
+1833 -199

No files matched your search

+1
View File
@@ -442,6 +442,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
+3
View File
@@ -228,6 +228,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
// Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one;
// a tab's right-click menu also splits right or down with any tab.
c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"),
// Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS,
// where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and
// also run from Combine files, which has no document open.
+8 -11
View File
@@ -38,16 +38,18 @@ impl PdfCraftApp {
// The window shows the active document's name (or title, if it asks for that), with •
// while it has unsaved changes, as the other Craft apps write it (#3640). Unsaved means
// what closing would ask about, so a draft not yet applied counts too.
let dirty = self.active.is_some_and(|i| self.has_unsaved_work(i));
// In split view it names the main pane's document.
let front = self.pane_front(panes::Place::Main);
let dirty = front.is_some_and(|i| self.has_unsaved_work(i));
let mark = if dirty { " •" } else { "" };
let titled = |name: Option<String>| {
name.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned()))
.map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name}{mark} — PdfCraft"))
};
let title = titled(self.active.and_then(|i| crate::canvas::tab_label(self, i)));
let title = titled(front.and_then(|i| crate::canvas::tab_label(self, i)));
// The web page's title is what an embedding host reads to tell which file opened, so it
// names the file even when the document asks to show its own title.
let page_title = titled(self.active.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone()));
let page_title = titled(front.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone()));
if title != self.window_title {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.window_title = title;
@@ -65,6 +67,7 @@ impl PdfCraftApp {
/// Construct an application with the default session, panels, theme and control state.
pub fn new() -> Self {
Self {
panes: Default::default(),
session: Session::new(),
status_captions_lang: None,
views: Vec::new(),
@@ -735,6 +738,7 @@ impl PdfCraftApp {
view.pending_pick = None;
}
}
panes::frame(self);
self.sync_titles(&ctx);
// The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on
// Windows) can tell it apart (#4614). egui builds the root node afresh every pass.
@@ -799,14 +803,7 @@ impl PdfCraftApp {
if self.mode == Mode::AllTools && self.all_tools_full {
all_tools_ui::show(self, ui);
} else {
match self.active {
None if self.combine_showing() => combine_ui::page(self, ui),
None => home::show(self, ui),
Some(index) => match compare_ui::side_by_side(self, index) {
Some((old, new)) => compare_ui::compare_area(self, old, new, ui),
None => canvas::document_area(self, index, ui),
},
}
panes::page_area(self, ui);
}
});
self.process_pending_edits();
+14 -9
View File
@@ -840,20 +840,25 @@ impl PdfCraftApp {
return;
}
self.remember_view(index);
let id = self.views.remove(index).id;
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) {
if self.closed_tabs.len() == 10 {
self.closed_tabs.remove(0);
let view = self.views.remove(index);
let (id, place) = (view.id, view.place());
// Another tab showing the same document (split view) keeps it open.
if !self.views.iter().any(|v| v.id == id) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) {
if self.closed_tabs.len() == 10 {
self.closed_tabs.remove(0);
}
self.closed_tabs.push(path);
}
self.closed_tabs.push(path);
self.forget_recovery(id);
self.session.close(id);
}
self.forget_recovery(id);
self.session.close(id);
self.active = match self.active {
_ if self.views.is_empty() => None,
Some(a) if a > index => Some(a - 1),
Some(a) if a >= self.views.len() => Some(self.views.len() - 1),
// The tab after it in its strip comes forward (or the last).
Some(a) if a == index => self.after_close(place, index),
other => other,
};
}
+28 -12
View File
@@ -97,23 +97,39 @@ impl PdfCraftApp {
self.finish_document_copy(ctx);
// The Combine grid's thumbnails, whichever tab shows (renders under way still finish).
self.combine_thumbs_frame(ctx);
// Retire hidden documents before admitting the visible document's textures, so the
// Retire hidden documents before admitting the visible documents' textures, so the
// three cache limits apply to the application, regardless of how many tabs are open.
// Side by side: the comparison's old file renders alongside the active one.
let partner = self.active.and_then(|i| compare_ui::side_by_side(self, i)).map(|(old, _)| old);
// On screen: the active tab, the comparison's old file side by side with it, and what
// split view's other pane shows.
let shown = self.views_on_screen();
let shown_docs: std::collections::HashSet<DocId> = shown.iter().filter_map(|&i| self.views.get(i)).map(|v| v.id).collect();
for (i, view) in self.views.iter_mut().enumerate() {
if Some(i) != self.active
&& Some(i) != partner
&& let Some(doc) = self.session.get(view.id)
{
if shown.contains(&i) {
continue;
}
if shown_docs.contains(&view.id) {
// Another tab of this document is on screen, and renders through its pool.
view.release_rasters();
} else if let Some(doc) = self.session.get(view.id) {
view.suspend_rendering(&doc.renderer);
}
}
for i in self.active.into_iter().chain(partner) {
if let Some(view) = self.views.get_mut(i)
&& let Some(doc) = self.session.get(view.id)
{
view.finish_render_frame(ctx, &doc.info, &doc.renderer);
// Each document on screen once: two views of it (split view) share its render pool.
let mut done = std::collections::HashSet::new();
for &i in &shown {
let Some(id) = self.views.get(i).map(|v| v.id) else { continue };
if !done.insert(id) {
continue;
}
let Some(doc) = self.session.get(id) else { continue };
let group: Vec<usize> = shown.iter().copied().filter(|&k| self.views.get(k).is_some_and(|v| v.id == id)).collect();
match group.as_slice() {
[only] => {
if let Some(view) = self.views.get_mut(*only) {
view.finish_render_frame(ctx, &doc.info, &doc.renderer);
}
}
_ => DocView::finish_shared_render_frame(ctx, &doc.info, &doc.renderer, &mut self.views, &group),
}
}
}
+56 -15
View File
@@ -500,6 +500,9 @@ pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapsho
}
pub struct DocView {
/// Where the tab shows (split view's panes) and the key that tells two
/// views of one document apart (`crate::panes`).
pub(crate) pane: crate::panes::ViewPane,
pub id: DocId,
pub zoom: f32,
pub fit: Fit,
@@ -993,6 +996,7 @@ impl DocView {
/// any layout or zoom the PDF itself asks for.
pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self {
Self {
pane: Default::default(),
id,
zoom: defaults.zoom,
fit: defaults.fit,
@@ -1185,7 +1189,7 @@ impl DocView {
fresh.organize = self.organize;
fresh.grid_zoom = self.grid_zoom;
fresh.thumbnail_zoom = self.thumbnail_zoom;
// Same document id, so the scroll offset egui keeps for it is still this document's.
// Same view key (`pane`), so the scroll offset egui keeps for it is still this view's.
fresh.shown = self.shown;
fresh.highlight_fields = self.highlight_fields;
fresh.notice_dismissed = self.notice_dismissed;
@@ -1203,6 +1207,7 @@ impl DocView {
fresh.viewport_w = self.viewport_w;
fresh.viewport_h = self.viewport_h;
fresh.viewport_screen = self.viewport_screen;
fresh.pane = self.pane.clone();
*self = fresh;
// Marks the kept images out of date and clamps the page to the new page count.
self.document_changed(info);
@@ -1210,6 +1215,45 @@ impl DocView {
self.page_input_reselect = true;
}
/// Another view of this document changed it (split view: an edit, Undo,
/// a reload): start afresh as a reload does, keeping the reader's place, so nothing here
/// points into the old version.
pub(crate) fn follow_document(&mut self, info: &DocInfo) {
let continuous = self.continuous;
self.document_replaced(info);
self.continuous = continuous;
}
/// A second view of this document (split view), showing what this one shows.
pub(crate) fn twin(&self, info: &DocInfo) -> Self {
let mut v = Self::new(self.id, info, ViewDefaults::default());
v.zoom = self.zoom;
v.fit = self.fit;
v.layout = self.layout;
v.continuous = self.continuous;
v.show_page_gaps = self.show_page_gaps;
v.rotation = self.rotation;
v.cover = self.cover;
v.current = self.current;
v.page_input = self.page_input.clone();
v.highlight_fields = self.highlight_fields;
v.notice_dismissed = self.notice_dismissed;
v.scripts_off_noted = self.scripts_off_noted;
v.goto = Some((self.current, 0.0));
v
}
/// The key that tells this view apart from another view of the same document (split view):
/// unique for the app's life. Ids of what is drawn over the page are keyed by it.
pub fn view_key(&self) -> u64 {
self.pane.key
}
/// Where this tab shows: the main pane or split view's second pane.
pub fn place(&self) -> crate::panes::Place {
self.pane.place
}
/// Select pages in the organize grid (tests, automation). Empty clears the selection.
pub(crate) fn page_count(&self) -> usize {
self.page_count
@@ -2488,20 +2532,14 @@ fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option<Notice>) {
}
/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The
/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own
/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about).
/// tab is looked up by its view key (indices may have shifted since the click, and split view can
/// show one document in two tabs), and the flag rides its own view, so a stale flag can never
/// close the wrong tab. Whether a tab was closed (or asked about).
fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool {
let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id));
let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.view_key()).and_then(|key| app.tab_with_key(key));
let Some(i) = pending else { return false };
app.views[i].close_requested = false;
if app.hold_close_for_ocr_review(i) {
return true;
}
if app.has_unsaved_work(i) {
app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id));
} else {
app.close_tab(i);
}
app.request_close_tab(i);
true
}
@@ -2530,8 +2568,9 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
},
..Default::default()
});
// Each document keeps its own scroll position when switching tabs (#189).
scroll = scroll.id_salt(("page-view", view.id));
// Each tab keeps its own scroll position when switching tabs (#189), and so does each view of
// one document in split view.
scroll = scroll.id_salt(("page-view", view.view_key()));
// Every scroll here asks for no animation. An animated area would still queue a zero-length
// offset target for the next frame, which overwrites the jump a page turn just set.
scroll = scroll.animated(false);
@@ -3229,7 +3268,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
can_paste_image,
};
let mut page_out = PageOut::default();
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| {
// Keyed by the view, not the document: with one document in both panes of split view, shared
// ids gave the main pane's clicks on the page to the second pane's widgets.
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.view_key())), app.show_page_edge_shadows, |ui, viewport| {
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
});
let PageOut {
+124 -26
View File
@@ -253,6 +253,12 @@ impl DocView {
self.pages.get(&page).map(|p| p.tex.id())
}
/// The size of `page`'s whole-page raster, when one is drawn and up to date (not waiting for
/// its replacement after a change; tests and automation).
pub fn drawn_page_size(&self, page: usize) -> Option<[usize; 2]> {
self.pages.get(&page).filter(|p| p.tag != STALE_TAG).map(|p| p.tex.size())
}
/// Whether `page`'s whole-page raster (a tiled page's backdrop too) is still the one from
/// before its last change, whatever its tiles are.
pub(crate) fn page_raster_out_of_date(&self, page: usize) -> bool {
@@ -317,6 +323,22 @@ impl DocView {
/// Only the displayed document owns raster textures. Tabs keep their document and view
/// state, but do not each retain a separate full application texture allowance.
pub(crate) fn suspend_rendering(&mut self, pool: &RenderPool) {
let queued = !self.last_queue.is_empty();
self.release_rasters();
if queued {
pool.set_queue(Vec::new());
}
// Results already finished for this tab would otherwise sit in its pool, rasters and
// all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.)
if !pool.is_inline() {
while pool.try_recv().is_some() {}
}
}
/// The view's half of [`DocView::suspend_rendering`]: its rasters and its demand go, and its
/// document's pool is left alone. On its own for a view whose document another view on screen
/// renders through the same pool (split view).
pub(crate) fn release_rasters(&mut self) {
self.begin_render_frame();
self.pages.clear();
self.tiles.clear();
@@ -330,15 +352,8 @@ impl DocView {
self.signature_drag = Default::default();
self.text_background = Default::default();
self.move_background = Default::default();
if !self.last_queue.is_empty() {
pool.set_queue(Vec::new());
self.last_queue.clear();
}
// Results already finished for this tab would otherwise sit in its pool, rasters and
// all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.)
if !pool.is_inline() {
while pool.try_recv().is_some() {}
}
self.last_queue.clear();
self.pane.shared = false;
}
pub(super) fn thumbnail_requests(&self, info: &DocInfo, ppp: f32) -> Vec<RenderRequest> {
@@ -535,7 +550,8 @@ impl DocView {
return;
}
let queue = self.prepare_render_queue(info, ctx.pixels_per_point());
if queue != self.last_queue {
// (Shared with another view of the document last frame, the pool's queue was both views'.)
if queue != self.last_queue || std::mem::take(&mut self.pane.shared) {
pool.set_queue(queue.clone());
self.last_queue = queue;
}
@@ -547,25 +563,69 @@ impl DocView {
}
}
/// [`DocView::finish_render_frame`] for several views of one document on screen at once (split
/// view), `group` in `views`: the document's one render pool works for
/// all of them, and a request they share (the same page at the same scale) is rendered once
/// and handed to each.
pub(crate) fn finish_shared_render_frame(ctx: &egui::Context, info: &DocInfo, pool: &RenderPool, views: &mut [DocView], group: &[usize]) {
// As for one view (#3149): an edit after the canvases collected demand leaves its scales
// and indexes on the old pages, in every view of the document (the pool's queue is theirs).
if group.iter().any(|&i| views.get(i).is_some_and(|v| v.frame_geometry_changed)) {
for &i in group {
if let Some(view) = views.get_mut(i) {
view.begin_render_frame();
view.last_queue.clear();
}
}
pool.set_queue(Vec::new());
ctx.request_repaint();
return;
}
let ppp = ctx.pixels_per_point();
let mut changed = false;
let mut union: Vec<RenderRequest> = Vec::new();
for &i in group {
let Some(view) = views.get_mut(i) else { continue };
let queue = view.prepare_render_queue(info, ppp);
changed |= !view.pane.shared || queue != view.last_queue;
for r in &queue {
if !union.contains(r) {
union.push(*r);
}
}
view.last_queue = queue;
view.pane.shared = true;
}
if changed {
pool.set_queue(union);
}
// A result can go to both views, so one view holding its rasters holds them for both.
if !group.iter().any(|&i| views.get(i).is_some_and(|v| v.hold_rasters)) {
receive_batch(pool, |r| {
let wanting: Vec<usize> =
group.iter().copied().filter(|&i| views.get(i).is_some_and(|v| v.last_queue.contains(&r.request))).collect();
let Some((&last, rest)) = wanting.split_last() else { return 0 };
let mut uploaded = 0usize;
for &i in rest {
if let Some(view) = views.get_mut(i) {
uploaded = uploaded.saturating_add(view.receive_result(ctx, copy_result(&r)));
}
}
if let Some(view) = views.get_mut(last) {
uploaded = uploaded.saturating_add(view.receive_result(ctx, r));
}
uploaded
});
}
if group.iter().any(|&i| views.get(i).is_some_and(|v| !v.last_queue.is_empty())) {
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
/// Pull a bounded batch of current results into textures. Demand is committed first, so
/// offscreen results and obsolete scale/configuration tags never allocate a ColorImage.
pub fn receive(&mut self, ctx: &egui::Context, pool: &RenderPool) {
let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME };
let mut uploaded = 0usize;
#[cfg(not(target_arch = "wasm32"))]
let started = std::time::Instant::now();
for _ in 0..budget {
let Some(r) = pool.try_recv() else { break };
uploaded = uploaded.saturating_add(self.receive_result(ctx, r));
// One whole-page upload may exceed the frame allowance; it must still progress.
if uploaded >= UPLOAD_BYTES_PER_FRAME {
break;
}
#[cfg(not(target_arch = "wasm32"))]
if started.elapsed() >= std::time::Duration::from_millis(4) {
break;
}
}
receive_batch(pool, |r| self.receive_result(ctx, r));
}
pub(super) fn receive_result(&mut self, ctx: &egui::Context, r: pdfcraft_render::RenderedPage) -> usize {
@@ -757,6 +817,44 @@ impl DocView {
}
}
/// Take a bounded batch of finished renders from `pool` and hand each to `receive`, which
/// returns the bytes it uploaded: at most a few results, one frame's upload allowance, and 4 ms
/// a frame.
fn receive_batch(pool: &RenderPool, mut receive: impl FnMut(pdfcraft_render::RenderedPage) -> usize) {
let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME };
let mut uploaded = 0usize;
#[cfg(not(target_arch = "wasm32"))]
let started = std::time::Instant::now();
for _ in 0..budget {
let Some(r) = pool.try_recv() else { break };
uploaded = uploaded.saturating_add(receive(r));
// One whole-page upload may exceed the frame allowance; it must still progress.
if uploaded >= UPLOAD_BYTES_PER_FRAME {
break;
}
#[cfg(not(target_arch = "wasm32"))]
if started.elapsed() >= std::time::Duration::from_millis(4) {
break;
}
}
}
/// A copy of a render result, for a second view of the document that asked for the same request
/// (split view). Not `Clone` on purpose: page rasters are large, and this is the one place they
/// are copied.
fn copy_result(r: &pdfcraft_render::RenderedPage) -> pdfcraft_render::RenderedPage {
pdfcraft_render::RenderedPage {
request: r.request,
width: r.width,
height: r.height,
rgba: r.rgba.clone(),
error: r.error.clone(),
text: r.text.clone(),
warnings: r.warnings.clone(),
millis: r.millis,
}
}
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
pub(super) fn scale_tag(scale: f32) -> u64 {
(f64::from(scale) * 65536.0).round() as u64
+15 -2
View File
@@ -204,6 +204,19 @@ impl DocView {
}
}
/// The id of a pane's find field: one per view, so two find bars open at once do not share an id
/// (and so the focus).
fn find_input_id(key: u64) -> egui::Id {
egui::Id::unique(("find-input", key))
}
impl DocView {
/// The id of this view's find field, for what needs to give it the focus or take it away.
pub fn find_input_id(&self) -> egui::Id {
find_input_id(self.pane.key)
}
}
/// Acrobat-style floating find bar at the top-right of the document area.
pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) {
let Some(find) = view.find.as_mut() else { return };
@@ -214,7 +227,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg
let mut close = false;
let mut step: Option<bool> = None;
let searched = view.texts.len() + view.text_failed.len();
egui::Area::new(egui::Id::unique("find-bar"))
egui::Area::new(egui::Id::unique(("find-bar", view.pane.key)))
.order(egui::Order::Middle)
.pivot(Align2::RIGHT_TOP)
.fixed_pos(area.right_top() + vec2(-18.0, 12.0))
@@ -229,7 +242,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg
ui.horizontal(|ui| {
ui.add(icons::image("search", 16.0, t.text_muted));
let edit = egui::TextEdit::singleline(&mut find.query)
.id(egui::Id::unique("find-input"))
.id(find_input_id(view.pane.key))
.hint_text(tl!("Find text"))
.desired_width(220.0)
.frame(egui::Frame::NONE);
+1 -1
View File
@@ -206,7 +206,7 @@ pub(super) fn selection_actions(
let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0));
let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) };
let mut chosen = None;
egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(
egui::Area::new(egui::Id::unique(("text-selection-actions", view.view_key()))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
+147 -44
View File
@@ -4,6 +4,8 @@ use craft_ui::panes::{Rail, RailItem, RailRequest};
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
use crate::canvas::{DocView, Fit, PageLayout};
use crate::panes::{Place, TabDrop};
use crate::tab_context::Action;
use crate::theme::{self, ThemePreference, Tokens};
use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets, window_controls};
@@ -41,6 +43,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
.show(ui, |ui| {
let full = ui.max_rect();
app.panes.main_strip = Some(full);
let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag());
if drag.drag_started() {
window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag);
@@ -80,7 +83,8 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
// Tabs shrink to share the strip (names end in "…") and scroll only once they reach
// their narrowest; then arrows either side say so and step through them.
let mut natural: Vec<f32> = Vec::with_capacity(app.views.len() + 1);
for i in 0..app.views.len() {
// (The main pane's tabs: split view's second pane has a strip of its own.)
for i in app.tabs_in(Place::Main) {
if let Some(name) = crate::canvas::tab_label(app, i) {
natural.push(tab_natural_width(ui, &t, &name));
}
@@ -126,7 +130,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
// (with whether it was just released).
let mut slots = Vec::with_capacity(app.views.len());
let mut dragged = None;
for i in 0..app.views.len() {
for i in app.tabs_in(Place::Main) {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let Some(name) = crate::canvas::tab_label(app, i) else { continue };
let dirty = doc.dirty;
@@ -141,7 +145,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
name: &name,
file,
dirty,
active: app.active == Some(i),
active: app.pane_front(Place::Main) == Some(i),
index: i,
cap,
height: tab_h,
@@ -156,23 +160,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
let id = app.views[i].id;
let reveal = cfg!(not(target_arch = "wasm32")) && doc.path.is_some();
response.context_menu(|ui| {
if ui.button(tl!("Close Tab")).clicked() {
action = Some(crate::tab_context::Action::Close(id));
ui.close();
}
if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() {
action = Some(crate::tab_context::Action::Reopen);
ui.close();
}
let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") };
if ui.add_enabled(reveal, egui::Button::new(label)).clicked() {
action = Some(crate::tab_context::Action::Reveal(id));
ui.close();
}
});
response.context_menu(|ui| tab_menu(app, ui, i, &mut action));
if response.clicked() {
app.active = Some(i);
}
@@ -182,33 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
close = Some(i);
}
}
// Dragging a tab moves it (#1537): a line marks the gap it will land
// in, and releasing the button drops it there.
if let Some((from, released)) = dragged
&& let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x)
{
let to = drop_index(&slots, from, x);
if released {
if close.is_none() && action.is_none() {
app.move_tab(from, to);
}
} else {
drag_x = Some(x);
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) {
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
// In front of the first tab the gap lies outside the scroll area's
// clip rect (#2225): keep the whole line inside it.
let clip = ui.clip_rect();
let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0));
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
}
}
}
drag_x = tab_drag(app, ui, &slots, dragged, Place::Main, close.is_some() || action.is_some());
if let Some(i) = close
&& let Some(view) = app.views.get(i)
{
action.get_or_insert(crate::tab_context::Action::Close(view.id));
action.get_or_insert(Action::Close(view.view_key()));
}
if let Some(action) = action {
app.tab_action(action);
@@ -326,6 +292,143 @@ fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
}
/// A document tab's right-click menu, in any strip: the choice is left in `action`.
fn tab_menu(app: &PdfCraftApp, ui: &mut egui::Ui, index: usize, action: &mut Option<Action>) {
let Some(view) = app.views.get(index) else { return };
let (tab, id) = (view.view_key(), view.id);
if ui.button(tl!("Close Tab")).clicked() {
*action = Some(Action::Close(tab));
ui.close();
}
if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() {
*action = Some(Action::Reopen);
ui.close();
}
ui.separator();
// Split view (view.split-window).
if ui.button(tl!("Split right")).clicked() {
*action = Some(Action::Split { tab, down: false });
ui.close();
}
if ui.button(tl!("Split down")).clicked() {
*action = Some(Action::Split { tab, down: true });
ui.close();
}
ui.separator();
let reveal = cfg!(not(target_arch = "wasm32")) && app.session.get(id).is_some_and(|d| d.path.is_some());
let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") };
if ui.add_enabled(reveal, egui::Button::new(label)).clicked() {
*action = Some(Action::Reveal(id));
ui.close();
}
}
/// A tab of `place`'s strip (its tabs `slots`) being dragged (`dragged`: its index, and whether it
/// was let go this frame). It moves the tab along the strip (#1537): a line marks the gap it will
/// land in, and letting go drops it there. Over the right half of a page area that isn't split, or
/// over split view's other pane, a hint shows where it would go instead, and letting go puts it
/// there (`crate::panes`). `busy` is a close or menu choice this frame, which wins. Returns the
/// pointer's x while the tab moves along the strip, for scrolling at the strip's edges.
fn tab_drag(app: &mut PdfCraftApp, ui: &egui::Ui, slots: &[(usize, Rect)], dragged: Option<(usize, bool)>, place: Place, busy: bool) -> Option<f32> {
let (from, released) = dragged?;
let pos = ui.ctx().pointer_latest_pos()?;
match app.drop_target(pos, place) {
TabDrop::Strip => {
let x = pos.x;
let to = drop_index(slots, from, x);
if released {
if !busy {
app.move_tab_in_strip(from, slots, to);
}
return None;
}
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
if let Some(marker) = drop_marker(slots, from, to, ui.spacing().item_spacing.x) {
let t = Tokens::get(ui.ctx());
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
// In front of the first tab the gap lies outside the scroll area's clip rect
// (#2225): keep the whole line inside it.
let clip = ui.clip_rect();
let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0));
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
}
Some(x)
}
target => {
if !released {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
app.panes.drop_hint = app.drop_hint_rect(target);
} else if !busy {
app.drop_tab(from, target);
}
None
}
}
}
/// The tab strip of split view's second pane, in `strip`: its tabs share the width (no scrolling),
/// and are clicked, closed, moved along and dragged to the other pane as in the main strip.
pub(crate) fn pane_tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui, strip: Rect, place: Place) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(strip, 0.0, t.titlebar);
ui.painter().hline(strip.x_range(), strip.bottom() - 0.5, Stroke::new(1.0, t.divider));
let tabs = app.tabs_in(place);
let front = app.pane_front(place);
let inner = Rect::from_min_max(strip.min + vec2(6.0, 2.0), strip.max - vec2(6.0, 0.0));
let mut row = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::left_to_right(Align::Max)).id_salt(("tab-row", place)));
row.spacing_mut().item_spacing.x = 4.0;
let natural: Vec<f32> = tabs.iter().filter_map(|&i| crate::canvas::tab_label(app, i)).map(|name| tab_natural_width(&row, &t, &name)).collect();
let cap = tab_cap(&natural, row.available_width(), 4.0, TAB_MIN_WIDTH);
let mut close = None;
let mut action = None;
let mut slots = Vec::with_capacity(tabs.len());
let mut dragged = None;
for &i in &tabs {
let Some(view) = app.views.get(i) else { continue };
let Some(doc) = app.session.get(view.id) else { continue };
let Some(name) = crate::canvas::tab_label(app, i) else { continue };
let dirty = doc.dirty;
let file = (name != doc.name).then(|| doc.name.clone());
let id = row.scope_id().with(("pane-tab", view.view_key()));
let spec = TabSpec {
id,
theme: &t,
icon: "file-text",
name: &name,
file: file.as_deref(),
dirty,
active: front == Some(i),
index: i,
cap,
height: inner.height(),
draggable: true,
};
let response = tab(&mut row, spec, &mut close);
slots.push((i, response.rect));
if response.dragged() || response.drag_stopped() {
dragged = Some((i, response.drag_stopped()));
}
response.context_menu(|ui| tab_menu(app, ui, i, &mut action));
if response.clicked() {
app.active = Some(i);
app.combine_tab.focused = false;
}
// A middle click closes the tab, as in the main strip (#1853).
if response.middle_clicked() {
close = Some(i);
}
}
tab_drag(app, &row, &slots, dragged, place, close.is_some() || action.is_some());
if let Some(i) = close
&& let Some(view) = app.views.get(i)
{
action.get_or_insert(Action::Close(view.view_key()));
}
if let Some(action) = action {
app.tab_action(action);
}
}
/// A tab's chrome around its name: the icon on the left, the close button on the right.
const TAB_CHROME: f32 = 64.0;
/// The narrowest a tab shrinks to before the strip scrolls: room for most of a name.
+23 -6
View File
@@ -331,6 +331,7 @@ impl PdfCraftApp {
}
}
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
"view.split" => self.toggle_split(),
"view.previous_view" | "view.next_view" => {
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
return Some(v.view_history(id == "view.next_view"));
@@ -801,13 +802,15 @@ impl PdfCraftApp {
/// end (#1542). The Combine files tab, when open, comes after the documents. From Home the
/// next tab is the first and the previous one the last.
fn step_tab(&mut self, forward: bool) {
let count = self.views.len() + usize::from(self.combine_tab.open);
let current = if self.combine_showing() { Some(self.views.len()) } else { self.active };
// The tabs of the strip being worked in: split view's second pane steps through its own.
let place = self.active.and_then(|i| self.place_of(i)).unwrap_or_default();
let tabs = self.tabs_in(place);
let count = tabs.len() + usize::from(place == crate::panes::Place::Main && self.combine_tab.open);
let current = if self.combine_showing() { Some(tabs.len()) } else { self.active.and_then(|a| tabs.iter().position(|&i| i == a)) };
let Some(next) = tab_after(current, count, forward) else { return };
if next < self.views.len() {
self.active = Some(next);
} else {
self.open_combine_tab();
match tabs.get(next) {
Some(&i) => self.active = Some(i),
None => self.open_combine_tab(),
}
}
@@ -838,6 +841,9 @@ impl PdfCraftApp {
continue;
}
let Some(key) = Key::from_name(s.key) else { continue };
if ctx.input(|i| altgr_pressed(i, key)) {
continue;
}
let mut m = Modifiers::NONE;
if s.command {
m |= Modifiers::COMMAND;
@@ -874,6 +880,17 @@ impl PdfCraftApp {
}
}
/// Whether `key` was pressed with Ctrl+Alt this frame. That is how AltGr can reach us, and egui's
/// shortcut match ignores an extra Alt, so German or Spanish AltGr+ß or AltGr+º, which type a
/// backslash, would run ⌘\ (and AltGr+8 ⌘[) instead. No registered shortcut uses Alt. On macOS
/// the command key is not Ctrl, and Option stays free to type a shortcut's character.
fn altgr_pressed(input: &egui::InputState, key: egui::Key) -> bool {
input
.events
.iter()
.any(|e| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.ctrl && modifiers.alt))
}
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
/// no tab, forward is the first and back the last. `None` when there are no tabs.
fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usize> {
+1 -1
View File
@@ -75,7 +75,7 @@ pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) ->
pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(req) = app.close_request else { return };
let index = match req {
CloseRequest::Tab(id) => app.views.iter().position(|v| v.id == id),
CloseRequest::Tab(id) => app.asking_tab(id),
CloseRequest::Quit | CloseRequest::All => app.first_dirty(),
};
let Some(name) = index.and_then(|i| app.views.get(i)).and_then(|v| app.session.get(v.id)).map(|d| d.name.clone()) else {
+101 -24
View File
@@ -292,13 +292,13 @@ impl PdfCraftApp {
fn undo_in_page_box(&mut self) -> bool {
let Some(ctx) = self.ctx.clone() else { return false };
let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false };
let doc = view.id;
let (doc, place) = (view.id, view.place());
if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) {
ed.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false };
ed.claim_typing(&ctx, doc, place);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, true) else { return false };
let before = std::mem::replace(&mut ed.text, text);
ed.selection = None;
ed.follow_typed_rows(&ctx, &before);
ed.follow_typed_rows(&ctx, &before, place);
return true;
}
if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) {
@@ -327,13 +327,13 @@ impl PdfCraftApp {
fn redo_in_page_box(&mut self) -> bool {
let Some(ctx) = self.ctx.clone() else { return false };
let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false };
let doc = view.id;
let (doc, place) = (view.id, view.place());
if let Some(ed) = view.line_editor.as_mut() {
ed.claim_typing(&ctx, doc);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() };
ed.claim_typing(&ctx, doc, place);
let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, false) else { return ed.has_unsaved_text() };
let before = std::mem::replace(&mut ed.text, text);
ed.selection = None;
ed.follow_typed_rows(&ctx, &before);
ed.follow_typed_rows(&ctx, &before, place);
return true;
}
if let Some(d) = view.content.draft.as_mut() {
@@ -432,7 +432,8 @@ impl PdfCraftApp {
/// text.
pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool {
let focused = ctx.memory(|m| m.focused());
focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id())
let paragraph = self.active.and_then(|i| self.views.get(i)).map(|v| crate::edit_text_ui::input_id(v.place()));
focused.is_some() && (focused == paragraph || focused == Some(crate::content_ui::input_id()))
}
/// Whether the focused box on the page has typing its own Redo would put back: after Undo in
@@ -441,10 +442,10 @@ impl PdfCraftApp {
pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool {
let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
let focused = ctx.memory(|m| m.focused());
let (id, text) = if focused == Some(crate::edit_text_ui::input_id())
let (id, text) = if focused == Some(crate::edit_text_ui::input_id(view.place()))
&& let Some(ed) = &view.line_editor
{
(crate::edit_text_ui::input_id(), &ed.text)
(crate::edit_text_ui::input_id(view.place()), &ed.text)
} else if focused == Some(crate::content_ui::input_id())
&& let Some(d) = &view.content.draft
{
@@ -462,7 +463,7 @@ impl PdfCraftApp {
let Some(ctx) = &self.ctx else { return false };
let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false };
if let Some(ed) = &view.line_editor {
return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text);
return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(view.place()), &ed.text);
}
view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text))
}
@@ -479,7 +480,11 @@ impl PdfCraftApp {
/// Returns false when a field refused the typing queued for it (its editor is open again).
pub(crate) fn process_pending_edits(&mut self) -> bool {
let Some(i) = self.active else { return true };
let applied = self.apply_queued_edit(i);
// Split view: the other pane's tab can hold an edit too (its field committed when the
// click that brought this pane forward took the focus). It goes in first, as it came first.
let (other_applied, stay) = self.apply_other_pane_edit(i);
let modal = self.modal_open();
let applied = self.apply_queued_edit(i) && other_applied;
if !applied {
// Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal.
self.deferred_commands.clear();
@@ -519,9 +524,56 @@ impl PdfCraftApp {
}
None => {}
}
// That tab comes forward when its value was refused (its editor is open again with the
// text) or its edit opened a dialog for its document, unless this tab's action opened one.
if let Some(key) = stay
&& self.active == Some(i)
&& (modal || !self.modal_open())
{
self.active = self.tab_with_key(key).or(self.active);
}
applied
}
/// Split view: apply the edit queued on the tab the other pane shows, with that tab active
/// while it goes in (tab `i`, the active one, is active again afterwards). Left queued, it was
/// lost when the next frame replaced that tab to follow a change made through tab `i`
/// (`settle_panes`). Returns false when a field refused it, and that tab's view key when it
/// should come forward: its value was refused, or its edit opened a dialog.
fn apply_other_pane_edit(&mut self, i: usize) -> (bool, Option<u64>) {
use crate::panes::Place;
let Some(place) = self.views.get(i).map(|v| v.place()) else { return (true, None) };
let other = if place == Place::Main { Place::Second } else { Place::Main };
let Some((k, key, id)) = self
.panes
.split
.and_then(|_| self.pane_front(other))
.filter(|&k| k != i)
.and_then(|k| self.views.get(k).filter(|v| v.pending_edit.is_some()).map(|v| (k, v.view_key(), v.id)))
else {
return (true, None);
};
let generation = |app: &Self| app.session.get(id).map(|d| d.edit_generation());
let (before, modal) = (generation(self), self.modal_open());
self.active = Some(k);
let applied = self.apply_queued_edit(k);
let opened = !modal && self.modal_open();
self.active = Some(i);
// Tab `i` on the same document catches up now, keeping what it holds (this frame's click
// may have queued an edit or opened a field): the next frame replaces only the tab that
// isn't active, as if this change had come through the active one.
if generation(self) != before
&& let Some(doc) = self.session.get(id)
{
for (j, v) in self.views.iter_mut().enumerate() {
if j != k && v.id == id {
v.document_changed(&doc.info);
}
}
}
(applied, (!applied || opened).then_some(key))
}
/// Enhance ▸ Camera image ▸ clean-up slider: undo the enhancement (and the turns made since)
/// and enhance again from the photo at the new level, turning it as before. Only when the
/// top of the undo history is still those steps; otherwise the slider goes back.
@@ -752,8 +804,27 @@ impl PdfCraftApp {
/// [`Self::commit_form_typing`] for document `id`, which may not be the active tab (a save
/// panel answering later, Close All). If its tab has typing or a queued edit, the tab is
/// brought forward first, so the commit and any field scripts act on that document.
///
/// Each tab of the document holds typing of its own (split view). The tab in use stays active
/// when the others have been committed.
pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool {
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
let keep = self.active_ids().filter(|(_, active)| *active == id).and_then(|(i, _)| self.views.get(i)).map(|v| v.pane.key);
let tabs: Vec<u64> = self.views.iter().filter(|v| v.id == id).map(|v| v.pane.key).collect();
for key in tabs {
if let Some(i) = self.tab_with_key(key)
&& !self.commit_typing_of_tab(i, id)
{
return false;
}
}
if let Some(i) = keep.and_then(|k| self.tab_with_key(k)) {
self.active = Some(i);
}
true
}
/// [`Self::commit_typing_in`] for one tab of the document.
fn commit_typing_of_tab(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
// Added text the standard fonts can't draw can't be saved: show it rather than drop it.
// Its page is brought into view, so the text, the warning and Discard are on screen.
let blocked = self.views.get_mut(i).and_then(|v| {
@@ -782,15 +853,15 @@ impl PdfCraftApp {
}
self.active = Some(i);
}
self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id)
self.commit_form_typing() && self.commit_open_fill_text(i, id) && self.commit_open_text_edits(i, id)
}
/// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on
/// click-away. Save does that first, so the typed text is in the file instead of being left
/// open on the screen (#3496).
fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool {
fn commit_open_text_edits(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
let Some(doc) = self.session.get(id) else { return true };
let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true };
let Some(view) = self.views.get_mut(i) else { return true };
// Both are taken out of the view before either edit runs, so keep what is needed to put
// back whichever one the document refuses: typed text must not be lost.
let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone());
@@ -798,7 +869,7 @@ impl PdfCraftApp {
let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit);
let added_ok = added.is_none_or(|edit| self.apply_edit(edit));
let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit));
if let Some(view) = self.views.iter_mut().find(|v| v.id == id) {
if let Some(view) = self.views.get_mut(i) {
if !added_ok {
(view.content.draft, view.content.select_added) = (draft, select_added);
}
@@ -811,9 +882,8 @@ impl PdfCraftApp {
/// The Fill & Sign type box commits on click-away. Save does that first, so the text is in
/// the file (and can be flattened) instead of being left on the screen.
fn commit_open_fill_text(&mut self, id: pdfcraft_engine::DocId) -> bool {
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
let Some(tb) = self.views[i].fill_text.clone() else { return true };
fn commit_open_fill_text(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool {
let Some(tb) = self.views.get(i).and_then(|v| v.fill_text.clone()) else { return true };
let text = tb.text.trim().to_string();
if text.is_empty() {
if let Some(view) = self.views.get_mut(i) {
@@ -849,8 +919,9 @@ impl PdfCraftApp {
let after = self.session.get(id).map(|d| d.edit_generation());
if before != after
&& let Some(doc) = self.session.get(id)
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
&& let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i))
{
// The tab in use: `sync_views` brings any other tab of the document up to date.
view.document_changed(&doc.info);
}
true
@@ -858,10 +929,16 @@ impl PdfCraftApp {
/// Whether tab `index` has work that isn't saved: edits, or form typing not committed yet.
pub(crate) fn has_unsaved_work(&self, index: usize) -> bool {
self.views.get(index).and_then(|v| self.session.get(v.id)).is_some_and(|doc| doc.dirty) || self.has_unsaved_typing(index)
}
/// Whether tab `index` holds work of its own that isn't in its document yet: form typing not
/// committed, an edit waiting, text being typed. (The document's own edits are shared with
/// any other tab of it, in split view.)
pub(crate) fn has_unsaved_typing(&self, index: usize) -> bool {
let Some(v) = self.views.get(index) else { return false };
let Some(doc) = self.session.get(v.id) else { return false };
doc.dirty
|| v.pending_edit.is_some()
v.pending_edit.is_some()
|| v.content.blocked().is_some()
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|| v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())
+15 -4
View File
@@ -20,10 +20,15 @@ impl PdfCraftApp {
if self.hold_close_for_ocr_review(index) {
return;
}
// Text typed into a field counts as an unsaved change.
if self.has_unsaved_work(index)
// Text typed into a field counts as an unsaved change. Another tab of the document (split
// view) keeps the document's changes open, so closing this one asks only about its own.
if self.asks_before_closing(index)
&& let Some(id) = self.views.get(index).map(|v| v.id)
{
if self.has_twin(index) {
// The question is about this tab of the document (`asking_tab`).
self.active = Some(index);
}
self.close_request = Some(CloseRequest::Tab(id));
} else {
self.close_tab(index);
@@ -107,7 +112,7 @@ impl PdfCraftApp {
// The next question (another dirty document) starts on Save again.
self.save_prompt_button = Default::default();
let index = match req {
CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) {
CloseRequest::Tab(id) => match self.asking_tab(id) {
Some(i) => i,
None => return, // already closed
},
@@ -142,7 +147,7 @@ impl PdfCraftApp {
// document open.
let ctx = ctx.clone();
self.save_then(index, SaveTarget::InPlace, move |app| {
if let Some(i) = app.views.iter().position(|v| v.id == id) {
if let Some(i) = app.asking_tab(id) {
app.close_and_continue(&ctx, i, req);
}
});
@@ -150,6 +155,12 @@ impl PdfCraftApp {
}
}
/// The tab a close question about document `id` is about: the active one when it shows `id`
/// (in split view two tabs can), else the document's first.
pub(crate) fn asking_tab(&self, id: pdfcraft_engine::DocId) -> Option<usize> {
self.active.filter(|&a| self.views.get(a).is_some_and(|v| v.id == id)).or_else(|| self.views.iter().position(|v| v.id == id))
}
/// Close tab `index` for a close request, then ask about the next dirty document, or quit.
/// The save it waited for may have taken a later frame (the user choosing where), and an OCR
/// review started meanwhile holds the close as it holds a new one: the document stays open,
+5 -2
View File
@@ -194,11 +194,14 @@ impl PdfCraftApp {
match self.session.mark_saved(id, bytes, Some(dest.to_string())) {
Ok(()) => {
self.forget_recovery(id);
// The tab in use: `sync_views` brings any other tab of the document up to date.
if let Some(doc) = self.session.get(id)
&& let Some(view) = self.views.iter_mut().find(|v| v.id == id)
&& let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i))
{
view.document_changed(&doc.info);
// Saving over a version that changed on disk keeps this one: nothing to ask.
}
// Saving over a version that changed on disk keeps this one: nothing to ask.
for view in self.views.iter_mut().filter(|v| v.id == id) {
view.disk_changed = false;
}
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
+1 -1
View File
@@ -549,7 +549,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui
let mut bar = std::mem::take(&mut app.hotbar);
bar.normalize();
let t = Tokens::get(&ctx);
let id = egui::Id::unique("quick-bar");
let id = app.panes.area_id("quick-bar");
let full_side = bar.full_side && bar.dock.is_some();
let bounds = if full_side { area } else { area.shrink(10.0) };
if let Some(side) = bar.dock.clone() {
+3
View File
@@ -58,6 +58,7 @@ mod editing;
mod home;
mod hotbar;
mod panels;
pub mod panes;
pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers};
pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode};
pub mod i18n;
@@ -283,6 +284,8 @@ struct DeferredOpen {
pub type HtmlClipboard = Box<dyn FnMut(&str, &str) -> bool>;
pub struct PdfCraftApp {
/// Split view (`panes`); which pane shows each tab is its view's.
pub panes: panes::Panes,
/// Tools side and pane widths; which panes are open is `left_open` and `right`.
pub side_panels: side_panels::SidePanels,
pub session: Session,
+2 -1
View File
@@ -171,7 +171,7 @@ const DISPLAY: &[&str] = &[
"view.fit_one_page",
];
const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"];
const PANES: &[&str] = &["view.side_panel", "view.tools_pane"];
const PANES: &[&str] = &["view.side_panel", "view.tools_pane", "view.split"];
const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"];
const MODES: &[&str] = &["view.full_screen", "view.read_mode"];
const PAGES: &[&str] = &[
@@ -512,6 +512,7 @@ impl PdfCraftApp {
"view.read_mode" => Some(self.mode == crate::Mode::Read),
"view.side_panel" => Some(self.right.is_some()),
"view.tools_pane" => Some(self.left_open),
"view.split" => Some(self.panes.split.is_some()),
"view.theme.system" => Some(self.theme_preference == ThemePreference::System),
"view.theme.light" => Some(self.theme_preference == ThemePreference::Light),
"view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark),
+18 -6
View File
@@ -2,19 +2,31 @@
use pdfcraft_engine::DocId;
pub(crate) enum Action {
Close(DocId),
/// Close a tab, by its view key (`DocView::view_key`: split view can show one document in two
/// tabs).
Close(u64),
Reopen,
Reveal(DocId),
/// Split right (or down) with a tab, by its view key.
Split {
tab: u64,
down: bool,
},
}
impl crate::PdfCraftApp {
pub(crate) fn tab_action(&mut self, action: Action) {
match action {
Action::Close(id) => {
if let Some(i) = self.views.iter().position(|v| v.id == id) {
Action::Close(tab) => {
if let Some(i) = self.tab_with_key(tab) {
self.request_close_tab(i);
}
}
Action::Split { tab, down } => {
if let Some(i) = self.tab_with_key(tab) {
self.split_tab(i, down);
}
}
Action::Reopen => {
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = self.closed_tabs.last().cloned() {
@@ -162,15 +174,15 @@ mod tests {
fn confirmed_close_uses_id_and_cancel_keeps_history_empty() {
let mut app = crate::PdfCraftApp::new();
app.open_bytes("first.pdf", Some("/tmp/first.pdf".into()), PDF.to_vec()).unwrap();
let first = app.views[0].id;
let (first, first_tab) = (app.views[0].id, app.views[0].view_key());
app.open_bytes("second.pdf", None, PDF.to_vec()).unwrap();
app.session.apply(first, pdfcraft_engine::Edit::InsertBlankPage { at: 0, width: 300.0, height: 400.0 }).unwrap();
app.tab_action(Action::Close(first));
app.tab_action(Action::Close(first_tab));
assert_eq!(app.views.len(), 2);
assert!(app.closed_tabs.is_empty());
app.resolve_close(&egui::Context::default(), None);
assert!(app.closed_tabs.is_empty());
app.tab_action(Action::Close(first));
app.tab_action(Action::Close(first_tab));
app.resolve_close(&egui::Context::default(), Some(false));
assert_eq!(app.views.len(), 1);
assert_eq!(app.closed_tabs, ["/tmp/first.pdf"]);
+641
View File
@@ -0,0 +1,641 @@
//! Split view (`view.split-window`): the main window's page area in two panes, and which pane
//! shows each tab.
//!
//! Every tab is a [`DocView`](crate::DocView) in `PdfCraftApp::views`, and its [`Place`] says where
//! it shows. The window's tab strip holds the [`Place::Main`] tabs; split view adds a second pane,
//! to the right or below, with a small strip of its own ([`Place::Second`]). Each pane shows the
//! tab of it that was active last, and `PdfCraftApp::active` is the tab of the pane last clicked:
//! menus, keyboard shortcuts and the side panels act on it.
//!
//! One document can show in both panes at once (page 2 beside page 40): two views with one
//! `DocId`. The document, its edits and its undo history are the session's, so an edit made in one
//! view is the other's too ([`PdfCraftApp::settle_panes`] refreshes the other view), while each view
//! keeps its own scroll, zoom and selection. Closing one of them closes only that view.
//!
//! Nothing here is kept across restarts: the second pane's tabs are ordinary tabs, so the last
//! session reopens them in the one strip, unsplit.
use std::collections::{HashMap, HashSet};
use egui::{Pos2, Rect, Sense, Stroke, vec2};
use pdfcraft_engine::DocId;
use crate::theme::Tokens;
use crate::{CloseRequest, PdfCraftApp};
/// Where a tab is shown.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Place {
/// The window's tab strip and page area (the left or top pane when split).
#[default]
Main,
/// The second pane of split view (right or bottom).
Second,
}
/// A tab's place among the panes (`DocView::pane`).
#[derive(Clone, Debug)]
pub(crate) struct ViewPane {
/// This view among all of the app's views, unique for the app's life: two views of one
/// document keep their own scroll position and floating bars under it (`DocView::view_key`).
pub(crate) key: u64,
/// Where the tab shows.
pub(crate) place: Place,
/// Whether `place` was chosen yet (a new tab opens in the pane the user was working in).
pub(crate) placed: bool,
/// When this tab was last the active one (each pane shows its latest).
pub(crate) shown_seq: u64,
/// The document's render queue was last set for both of its views at once.
pub(crate) shared: bool,
}
impl Default for ViewPane {
fn default() -> Self {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
Self { key: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), place: Place::Main, placed: false, shown_seq: 0, shared: false }
}
}
/// The page area in two panes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Split {
/// The second pane is below the first (Split down) rather than beside it (Split right).
pub down: bool,
/// The first pane's share of the page area.
pub fraction: f32,
}
impl Split {
fn new(down: bool) -> Self {
Self { down, fraction: 0.5 }
}
}
/// How far the divider between the panes may go, as a share of the page area.
const FRACTION: std::ops::RangeInclusive<f32> = 0.15..=0.85;
/// The divider's width, and the height of the second pane's tab strip.
const DIVIDER: f32 = 1.0;
const PANE_STRIP: f32 = 30.0;
/// Where a dragged tab goes if it is let go now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TabDrop {
/// Its own strip: it moves along it (the strip does that).
Strip,
/// The other pane.
Pane(Place),
/// The right half of a page area that isn't split: split right with it.
SplitRight,
}
/// Split view's state: see the module docs.
#[derive(Default)]
pub struct Panes {
/// Split view, while it is on.
pub split: Option<Split>,
/// The latest [`ViewPane::shown_seq`] handed out.
top_seq: u64,
/// The pane of the tab active last: a new tab opens there.
focus: Place,
/// The main tab strip and the page area, last frame.
pub(crate) main_strip: Option<Rect>,
pub(crate) page_rect: Option<Rect>,
/// Each open document's edit generation as last seen.
generations: HashMap<DocId, u64>,
/// The pane being drawn now (for what belongs to one pane only).
pub(crate) drawing: Place,
/// Where a dragged tab would land, painted over the page area this frame.
pub(crate) drop_hint: Option<Rect>,
}
impl Panes {
/// An egui id for something drawn once per pane (the floating tools bar): the main pane keeps
/// the plain id.
pub(crate) fn area_id(&self, name: &'static str) -> egui::Id {
match self.drawing {
Place::Main => egui::Id::unique(name),
place => egui::Id::unique((name, place)),
}
}
}
/// The two panes and the divider between them, in `area`.
fn layout(area: Rect, split: Split) -> (Rect, Rect, Rect) {
let f = if split.fraction.is_finite() { split.fraction.clamp(*FRACTION.start(), *FRACTION.end()) } else { 0.5 };
if split.down {
let y = (area.top() + area.height() * f).round();
(
Rect::from_min_max(area.min, egui::pos2(area.right(), y)),
Rect::from_min_max(egui::pos2(area.left(), y), egui::pos2(area.right(), y + DIVIDER)),
Rect::from_min_max(egui::pos2(area.left(), y + DIVIDER), area.max),
)
} else {
let x = (area.left() + area.width() * f).round();
(
Rect::from_min_max(area.min, egui::pos2(x, area.bottom())),
Rect::from_min_max(egui::pos2(x, area.top()), egui::pos2(x + DIVIDER, area.bottom())),
Rect::from_min_max(egui::pos2(x + DIVIDER, area.top()), area.max),
)
}
}
impl PdfCraftApp {
/// The tabs shown in `place`, in their strip's order (indexes into `views`).
pub fn tabs_in(&self, place: Place) -> Vec<usize> {
self.views.iter().enumerate().filter(|(_, v)| v.pane.place == place).map(|(i, _)| i).collect()
}
/// The tab `place` shows: the active tab when it is there, else the one active there last.
/// `None` for the main pane while Home (or Combine files) shows.
pub fn pane_front(&self, place: Place) -> Option<usize> {
if let Some(a) = self.active
&& self.views.get(a).is_some_and(|v| v.pane.place == place)
{
return Some(a);
}
if place == Place::Main && self.active.is_none() {
return None;
}
self.latest_in(place)
}
/// The tab of `place` active last (the first one when none has been).
fn latest_in(&self, place: Place) -> Option<usize> {
self.views
.iter()
.enumerate()
.filter(|(_, v)| v.pane.place == place)
.max_by_key(|(i, v)| (v.pane.shown_seq, std::cmp::Reverse(*i)))
.map(|(i, _)| i)
}
/// Where tab `index` shows.
pub fn place_of(&self, index: usize) -> Option<Place> {
self.views.get(index).map(|v| v.pane.place)
}
/// Whether another tab shows the same document as tab `index`.
pub(crate) fn has_twin(&self, index: usize) -> bool {
let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
self.views.iter().enumerate().any(|(k, v)| k != index && v.id == id)
}
/// The tab with view key `key` ([`crate::DocView::view_key`]).
pub(crate) fn tab_with_key(&self, key: u64) -> Option<usize> {
self.views.iter().position(|v| v.pane.key == key)
}
fn active_key(&self) -> Option<u64> {
self.active.and_then(|i| self.views.get(i)).map(|v| v.pane.key)
}
/// Bring forward the tab `place` shows (a click in that pane).
pub fn activate_place(&mut self, place: Place) {
if self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.pane.place == place) {
return;
}
if let Some(i) = self.latest_in(place) {
self.active = Some(i);
self.combine_tab.focused = false;
}
}
/// Keep the panes consistent, once a frame before anything is drawn and before every change
/// here: new tabs go to the pane the user was working in, a pane left without tabs ends split
/// view, the views of a document changed through another view catch up, and the active tab
/// becomes its pane's latest.
pub(crate) fn settle_panes(&mut self) {
let split = self.panes.split.is_some();
let focus = if split { self.panes.focus } else { Place::Main };
for v in &mut self.views {
if !v.pane.placed {
v.pane.place = focus;
v.pane.placed = true;
}
if v.pane.place == Place::Second && !split {
v.pane.place = Place::Main;
}
}
if split {
let has = |p: Place| self.views.iter().any(|v| v.pane.place == p);
if !has(Place::Main) || !has(Place::Second) {
self.unsplit();
}
}
self.sync_views();
if let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) {
self.panes.focus = v.pane.place;
if v.pane.shown_seq != self.panes.top_seq || self.panes.top_seq == 0 {
self.panes.top_seq = self.panes.top_seq.saturating_add(1);
v.pane.shown_seq = self.panes.top_seq;
}
}
}
/// A document changed through one of its views (an edit, Undo, a reload): its other view
/// starts afresh at the same place, as after a reload.
fn sync_views(&mut self) {
let mut seen = HashSet::new();
for k in 0..self.views.len() {
let Some(id) = self.views.get(k).map(|v| v.id) else { continue };
if !seen.insert(id) {
continue;
}
let Some(doc) = self.session.get(id) else { continue };
let generation = doc.edit_generation();
let before = self.panes.generations.insert(id, generation);
if before.is_none_or(|g| g == generation) {
continue;
}
// The view the change came through: the active one, else the first (as the code that
// made the change finds a document's tab).
let acting = self.active.filter(|&a| self.views.get(a).is_some_and(|v| v.id == id)).unwrap_or(k);
for (j, v) in self.views.iter_mut().enumerate() {
if j != acting && v.id == id {
v.follow_document(&doc.info);
}
}
}
self.panes.generations.retain(|id, _| seen.contains(id));
}
/// Move tab `from` along its strip: `to` is where it lands among the strip's tabs (`slots`, the
/// strip's tabs in order, from first to last; `chrome::drop_index`). In split view a strip
/// holds only some of the tabs, so this goes by the tab it lands next to rather than by index
/// in `views`.
pub(crate) fn move_tab_in_strip(&mut self, from: usize, slots: &[(usize, Rect)], to: usize) {
let others: Vec<usize> = slots.iter().map(|(i, _)| *i).filter(|&i| i != from).collect();
let (anchor, before) = match others.get(to) {
Some(&a) => (a, true),
None => match others.last() {
Some(&a) => (a, false),
None => return,
},
};
if from >= self.views.len() || slots.iter().position(|(i, _)| *i == from) == Some(to) {
return;
}
let Some(anchor) = self.views.get(anchor).map(|v| v.pane.key) else { return };
let active = self.active_key();
let view = self.views.remove(from);
let Some(a) = self.tab_with_key(anchor) else {
self.views.insert(from.min(self.views.len()), view);
return;
};
let at = if before { a } else { a.saturating_add(1) };
self.views.insert(at.min(self.views.len()), view);
self.active = active.and_then(|k| self.tab_with_key(k));
}
/// Remove tab `index` but not its document, which another tab still shows. When it was the
/// active tab, that other tab of the document becomes active.
fn drop_view(&mut self, index: usize) {
let Some((key, id)) = self.views.get(index).map(|v| (v.pane.key, v.id)) else { return };
let active = self.active_key();
self.views.remove(index);
self.active = if active == Some(key) { self.views.iter().position(|v| v.id == id) } else { active.and_then(|k| self.tab_with_key(k)) };
}
/// Tab `index`'s right-click Split right (or Split down). The tab the main pane shows gets a
/// second view in the second pane (the same document, twice); another tab moves there (two
/// documents side by side). A tab of the second pane only turns the split.
pub fn split_tab(&mut self, index: usize, down: bool) {
self.settle_panes();
let Some(place) = self.place_of(index) else { return };
match place {
Place::Second => {
if let Some(s) = self.panes.split.as_mut() {
s.down = down;
}
self.active = Some(index);
}
Place::Main => {
let alone = self.tabs_in(Place::Main).len() <= 1;
let shown = self.pane_front(Place::Main) == Some(index) || (self.active.is_none() && self.latest_in(Place::Main) == Some(index));
match self.panes.split.as_mut() {
Some(s) => s.down = down,
None => self.panes.split = Some(Split::new(down)),
}
if shown || alone {
self.show_twin(index, Place::Second);
} else {
self.move_tab_to(index, Place::Second);
}
}
}
}
/// Show tab `index`'s document in `place` too (a second view of it), or bring forward the
/// view of it already there.
fn show_twin(&mut self, index: usize, place: Place) {
let Some(view) = self.views.get(index) else { return };
let id = view.id;
if let Some(there) = self.views.iter().position(|v| v.id == id && v.pane.place == place) {
self.active = Some(there);
return;
}
let Some(doc) = self.session.get(id) else { return };
let mut twin = view.twin(&doc.info);
twin.pane.place = place;
twin.pane.placed = true;
self.views.push(twin);
self.active = Some(self.views.len() - 1);
}
/// View ▸ Split (⌘\): split the current document into two panes, or end split view.
pub fn toggle_split(&mut self) {
self.settle_panes();
if self.panes.split.is_some() {
self.unsplit();
} else if let Some(i) = self.active {
self.split_tab(i, false);
}
}
/// End split view: the second pane's tabs join the main strip, except second views of
/// documents the main pane has open already, which close.
pub fn unsplit(&mut self) {
self.panes.split = None;
for i in (0..self.views.len()).rev() {
let Some((id, place)) = self.views.get(i).map(|v| (v.id, v.pane.place)) else { continue };
if place != Place::Second {
continue;
}
if self.views.iter().any(|v| v.id == id && v.pane.place == Place::Main) {
self.drop_view(i);
} else if let Some(v) = self.views.get_mut(i) {
v.pane.place = Place::Main;
}
}
}
/// Move tab `index` to `place` and make it active. A pane that shows its document already
/// keeps the view it has, and the moving one closes. Returns the tab's index afterwards.
pub fn move_tab_to(&mut self, index: usize, place: Place) -> Option<usize> {
let (id, key, from) = self.views.get(index).map(|v| (v.id, v.pane.key, v.pane.place))?;
if from == place {
self.active = Some(index);
return Some(index);
}
if place == Place::Second && self.panes.split.is_none() {
self.panes.split = Some(Split::new(false));
}
if let Some(there) = self.views.iter().position(|v| v.id == id && v.pane.place == place && v.pane.key != key) {
let keep = self.views.get(there).map(|v| v.pane.key);
self.drop_view(index);
self.active = keep.and_then(|k| self.tab_with_key(k));
return self.active;
}
if let Some(v) = self.views.get_mut(index) {
v.pane.place = place;
v.pane.placed = true;
}
self.active = Some(index);
self.combine_tab.focused = false;
Some(index)
}
/// Quitting or File ▸ Close all with unsaved documents: one tab per document first, so each
/// document is asked about once (a tab with typing of its own stays, to be asked about).
fn one_tab_per_document(&mut self) {
for i in (0..self.views.len()).rev() {
let Some(id) = self.views.get(i).map(|v| v.id) else { continue };
if self.views.iter().take(i).any(|v| v.id == id) && !self.has_unsaved_typing(i) {
self.drop_view(i);
}
}
}
/// The tabs, one per document (the last session lists each file once).
pub(crate) fn first_views(&self) -> impl Iterator<Item = &crate::DocView> {
self.views.iter().enumerate().filter(|(i, v)| !self.views.iter().take(*i).any(|w| w.id == v.id)).map(|(_, v)| v)
}
/// Whether closing tab `index` asks about unsaved changes first: the document's changes are
/// asked about by its last tab only (another tab of it keeps them open); typing a tab holds
/// of its own, by that tab.
pub(crate) fn asks_before_closing(&self, index: usize) -> bool {
if self.has_twin(index) { self.has_unsaved_typing(index) } else { self.has_unsaved_work(index) }
}
/// The tab that comes forward when the active tab closes (it was at `index`, in `place`, and
/// is gone from `views`): the one that took its place in the same strip, else the strip's last,
/// else the tab the other pane shows.
pub(crate) fn after_close(&self, place: Place, index: usize) -> Option<usize> {
let tabs = self.tabs_in(place);
tabs.iter().copied().find(|&i| i >= index).or_else(|| tabs.last().copied()).or_else(|| match place {
Place::Main if self.panes.split.is_some() => self.latest_in(Place::Second),
Place::Main => None,
Place::Second => self.latest_in(Place::Main),
})
}
/// Where a tab dragged from `from`'s strip lands if let go at `pos`: the other pane, the right
/// half of a page area that isn't split, or else its own strip (as before split view, the
/// tab moves along the strip wherever it is let go).
pub(crate) fn drop_target(&self, pos: Pos2, from: Place) -> TabDrop {
if from != Place::Main && self.panes.main_strip.is_some_and(|r| r.contains(pos)) {
return TabDrop::Pane(Place::Main);
}
let Some(page) = self.panes.page_rect.filter(|r| r.contains(pos)) else { return TabDrop::Strip };
match self.panes.split {
Some(split) => {
let (first, _, second) = layout(page, split);
if second.contains(pos) && from != Place::Second {
TabDrop::Pane(Place::Second)
} else if first.contains(pos) && from != Place::Main {
TabDrop::Pane(Place::Main)
} else {
TabDrop::Strip
}
}
None if pos.x >= page.center().x => TabDrop::SplitRight,
None => TabDrop::Strip,
}
}
/// The area a [`TabDrop`] would fill, for the hint drawn while dragging.
pub(crate) fn drop_hint_rect(&self, drop: TabDrop) -> Option<Rect> {
let page = self.panes.page_rect?;
match drop {
TabDrop::Pane(p) => {
let split = self.panes.split?;
let (first, _, second) = layout(page, split);
Some(if p == Place::Second { second } else { first })
}
TabDrop::SplitRight => Some(Rect::from_min_max(egui::pos2(page.center().x, page.top()), page.max)),
TabDrop::Strip => None,
}
}
/// Let tab `index` go at `drop`.
pub(crate) fn drop_tab(&mut self, index: usize, drop: TabDrop) {
match drop {
TabDrop::Strip => {}
TabDrop::Pane(place) => {
self.move_tab_to(index, place);
}
TabDrop::SplitRight => self.split_tab(index, false),
}
}
/// The tabs on screen now: what each pane shows (and a comparison's other half).
pub(crate) fn views_on_screen(&self) -> Vec<usize> {
let mut shown = Vec::new();
let mut add = |i: Option<usize>| {
if let Some(i) = i
&& !shown.contains(&i)
{
shown.push(i);
}
};
let main = if self.full_screen { self.active } else { self.pane_front(Place::Main) };
add(main);
add(main.and_then(|i| crate::compare_ui::side_by_side(self, i)).map(|(old, _)| old));
if !self.full_screen && self.panes.split.is_some() {
add(self.pane_front(Place::Second));
}
shown
}
}
/// Once a frame, before anything is drawn: the panes settle (and a quit or Close all asks about
/// each document once).
pub(crate) fn frame(app: &mut PdfCraftApp) {
if matches!(app.close_request, Some(CloseRequest::Quit | CloseRequest::All)) {
app.one_tab_per_document();
}
app.settle_panes();
}
/// The page area: the main pane's tab (Home or Combine files while it has none, a comparison's
/// two halves), or split view's two panes.
pub(crate) fn page_area(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.panes.page_rect = Some(ui.max_rect());
app.panes.drawing = Place::Main;
if let Some(hint) = app.panes.drop_hint.take() {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("pane-drop-hint")));
painter.rect(hint.shrink(4.0), 6.0, t.accent.gamma_multiply(0.12), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
let main = app.pane_front(Place::Main);
match app.panes.split.and_then(|s| Some((s, app.pane_front(Place::Second)?))) {
Some((split, second)) => split_area(app, split, main, second, ui),
None => main_pane(app, main, ui),
}
}
/// What the main pane shows: tab `main`, or Home or Combine files while it has none.
fn main_pane(app: &mut PdfCraftApp, main: Option<usize>, ui: &mut egui::Ui) {
match main {
None if app.combine_showing() => crate::combine_ui::page(app, ui),
None => crate::home::show(app, ui),
Some(index) => match crate::compare_ui::side_by_side(app, index) {
Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui),
None => crate::canvas::document_area(app, index, ui),
},
}
}
/// Whether a press this frame began in `rect` on the page area itself, or on one of the floating
/// bars of the view drawn there (not on a dialog, menu or panel over it).
fn pressed_in(ui: &egui::Ui, rect: Rect, place: Place, view: Option<&crate::DocView>) -> bool {
let Some(pos) = ui.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None }) else { return false };
if !rect.contains(pos) {
return false;
}
match ui.ctx().layer_id_at(pos) {
None => true,
Some(layer) => {
let quick = if place == Place::Main { egui::Id::unique("quick-bar") } else { egui::Id::unique(("quick-bar", place)) };
layer == ui.layer_id()
|| [quick, egui::Id::unique("camera-bar")].contains(&layer.id)
|| view.is_some_and(|v| egui::Id::unique(("find-bar", v.view_key())) == layer.id)
}
}
}
/// Split view: the main pane, the divider and the second pane with its strip. A press in a pane
/// makes it the active one before it is drawn, so the click acts there.
fn split_area(app: &mut PdfCraftApp, split: Split, main: Option<usize>, second: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let area = ui.max_rect();
let (first, divider, other) = layout(area, split);
if !app.modal_open() && !app.palette_open {
// (While Home shows in the main pane, a click there stays on Home.)
if main.is_some() && pressed_in(ui, first, Place::Main, main.and_then(|i| app.views.get(i))) {
app.activate_place(Place::Main);
} else if pressed_in(ui, other, Place::Second, app.views.get(second)) {
app.activate_place(Place::Second);
}
}
// The divider, dragged to share the area differently.
let grab = if split.down { divider.expand2(vec2(0.0, 3.0)) } else { divider.expand2(vec2(3.0, 0.0)) };
let response = ui.interact(grab, ui.scope_id().with("pane-divider"), Sense::drag());
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(if split.down { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
}
if response.dragged()
&& let Some(p) = ui.ctx().pointer_latest_pos()
&& let Some(s) = app.panes.split.as_mut()
{
let f = if split.down { (p.y - area.top()) / area.height().max(1.0) } else { (p.x - area.left()) / area.width().max(1.0) };
if f.is_finite() {
s.fraction = f.clamp(*FRACTION.start(), *FRACTION.end());
}
}
ui.painter().rect_filled(divider, 0.0, if response.dragged() || response.hovered() { t.accent } else { t.divider });
let active = app.active.and_then(|i| app.views.get(i)).map(|v| v.pane.place);
pane(app, ui, first, Place::Main, main, active == Some(Place::Main));
pane(app, ui, other, Place::Second, Some(second), active == Some(Place::Second));
}
/// One pane of split view: the second has its own tab strip above its page area.
fn pane(app: &mut PdfCraftApp, ui: &mut egui::Ui, rect: Rect, place: Place, front: Option<usize>, active: bool) {
let t = Tokens::get(ui.ctx());
let salt = if place == Place::Second { "second-pane" } else { "main-pane" };
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(salt));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
let body = if place == Place::Second {
let strip = Rect::from_min_size(rect.min, vec2(rect.width(), PANE_STRIP));
crate::chrome::pane_tab_strip(app, &mut child, strip, place);
Rect::from_min_max(egui::pos2(rect.left(), strip.bottom()), rect.max)
} else {
rect
};
// The strip may have closed or moved a tab just now: look the pane's tab up again (a pane
// left empty ends split view next frame).
let front = if place == Place::Second { app.pane_front(place) } else { front.filter(|&i| i < app.views.len()) };
let mut body_ui = child.new_child(egui::UiBuilder::new().max_rect(body).id_salt("body"));
body_ui.set_clip_rect(body.intersect(ui.clip_rect()));
app.panes.drawing = place;
match (place, front) {
(Place::Main, _) => main_pane(app, front, &mut body_ui),
(Place::Second, Some(index)) => crate::canvas::document_area(app, index, &mut body_ui),
(Place::Second, None) => {}
}
app.panes.drawing = Place::Main;
// The active pane is marked along its top edge.
if active {
let edge = Rect::from_min_size(rect.min, vec2(rect.width(), 2.0));
ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(("active-pane", salt)))).rect_filled(edge, 0.0, t.accent);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_panes_share_the_area_and_the_divider_stays_inside() {
let area = Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 600.0));
let (a, d, b) = layout(area, Split { down: false, fraction: 0.5 });
assert_eq!(a.right(), d.left());
assert_eq!(d.right(), b.left());
assert_eq!(b.right(), 1000.0);
let (a, _, b) = layout(area, Split { down: true, fraction: 2.0 });
assert!(a.height() <= 600.0 * 0.85 + 1.0 && b.height() > 0.0, "a fraction past the end is held back");
let (a, _, _) = layout(area, Split { down: false, fraction: f32::NAN });
assert_eq!(a.width(), 500.0, "a fraction that isn't a number shares evenly");
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
{
let path = |v: &crate::DocView| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute);
let files = self.views.iter().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect();
let files = self.first_views().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect();
let active = self.active.and_then(|i| self.views.get(i)).and_then(path);
LastSession { files, active }
}
@@ -55,7 +55,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
anchor = anchor.clamp(viewport.min, viewport.max);
}
let pos = pos2(anchor.x + 12.0, anchor.y);
let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport);
let area = egui::Area::new(egui::Id::unique(("comment-composer", view.pane.key))).order(egui::Order::Foreground).constrain_to(viewport);
area.fixed_pos(pos).show(ctx, |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| {
ui.set_width(260.0);
+1 -1
View File
@@ -725,7 +725,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
// Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over
// the page, so only where the document shows: text scrolled under the mode bar or a side
// panel must leave them their pointer (#2755).
egui::Area::new(egui::Id::unique(("added-text", view.id.0)))
egui::Area::new(egui::Id::unique(("added-text", view.view_key())))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(at)
+15 -11
View File
@@ -105,9 +105,10 @@ pub struct LineEditor {
typing_from: Option<String>,
}
/// The paragraph editor's text box.
pub(crate) fn input_id() -> egui::Id {
egui::Id::unique("edit-text-line-input")
/// The paragraph editor's text box: one for the tabs of a pane (they share it), and one for each pane
/// of split view, which can show an editor in each.
pub(crate) fn input_id(place: crate::panes::Place) -> egui::Id {
egui::Id::unique(("edit-text-line-input", place))
}
/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
@@ -354,8 +355,8 @@ impl LineEditor {
/// Take the shared text box for this paragraph ([`forget_typing_before`]): its own history,
/// or none once the page has its text.
pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) {
forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.original);
pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId, place: crate::panes::Place) {
forget_typing_before(ctx, input_id(place), doc, &mut self.typing_from, &self.original);
}
/// Adopt the rewritten paragraph's current geometry before the next overlay frame.
@@ -488,8 +489,8 @@ impl LineEditor {
/// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows
/// aligned on their own keep their alignment, and a row Undo brings back gets its own back.
pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) {
follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]);
pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str, place: crate::panes::Place) {
follow_typing(ctx, input_id(place), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]);
}
/// The text changed from `before`: rows aligned on their own keep their alignment.
@@ -858,6 +859,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
let away = |p: egui::Pos2| !view.viewport_rect().contains(p);
let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || focus_pos(ctx).is_some_and(away);
let page = view.line_editor.as_ref()?.page;
// Keyed by view: split view can show an editor in each pane.
let key = view.view_key();
let place = view.place();
let xf = view.page_xform(page)?;
let hold = view.objects.hold_editor;
// An OCR word is invisible text over a scan: the textless background still shows the scanned
@@ -909,7 +913,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
// Over the page, so only where the document shows: a paragraph scrolled under the mode bar or
// a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as
// the page is, rather than pushed back into the window.
egui::Area::new(egui::Id::unique("edit-text-line"))
egui::Area::new(egui::Id::unique(("edit-text-line", key)))
.order(egui::Order::Foreground)
.fade_in(false)
.fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top()))
@@ -971,8 +975,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
};
let before = ed.text.clone();
let input = input_id();
ed.claim_typing(ui.ctx(), view_id);
let input = input_id(place);
ed.claim_typing(ui.ctx(), view_id, place);
let output = egui::TextEdit::multiline(&mut ed.text)
.id(input)
.font(font.clone())
@@ -1010,7 +1014,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
}
}
if r.changed() {
ed.follow_typed_rows(ctx, &before);
ed.follow_typed_rows(ctx, &before, place);
// The next pass measures the updated draft with the Apply/Save rewrite.
ctx.request_repaint();
}
+1 -1
View File
@@ -614,7 +614,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
// Over the page, so only where the document shows: a box scrolled under the mode bar or a
// side panel must leave them their pointer (#2755). It stays where it was placed, cut off as
// the page is, rather than pushed back into the window.
egui::Area::new(egui::Id::unique(("fill-text", view.id.0)))
egui::Area::new(egui::Id::unique(("fill-text", view.view_key())))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(pos)
+10 -9
View File
@@ -340,7 +340,8 @@ struct ComboEditor<'a> {
fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>) {
let ComboEditor { form, field: f, widget: w, rect, zoom, index: widget } = editor;
let mut enter = false;
egui::Area::new(egui::Id::unique(("form-editor", view.id.0)))
let view_key = view.view_key();
egui::Area::new(egui::Id::unique(("form-editor", view_key)))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(rect.min)
@@ -358,7 +359,7 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>
.background_color(FIELD_FILL)
.text_color(Color32::BLACK)
.margin(egui::Margin::symmetric(3, 1))
.id(egui::Id::unique(("form-field", view.id.0, &f.name, widget)));
.id(egui::Id::unique(("form-field", view_key, &f.name, widget)));
// The form field's own logical name; not a visual/preordered label.
let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone());
if std::mem::take(&mut fx.request_focus) {
@@ -400,7 +401,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
fx.calendar_text = Some(fx.text.clone());
}
let mut picked = None;
let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0)))
let area = egui::Area::new(egui::Id::unique(("date-picker", view.view_key())))
.order(egui::Order::Foreground)
.fixed_pos(field.left_bottom() + vec2(0.0, 2.0))
.show(ctx, |ui| {
@@ -436,7 +437,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
(Some(a), Some(b)) => (b - a).num_days() as usize,
_ => 31,
};
let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| {
let grid = egui::Grid::new(("date-grid", view.view_key())).spacing([4.0, 2.0]).show(ui, |ui| {
for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small());
}
@@ -814,7 +815,7 @@ pub(crate) fn overlay(
// Over the page, so only where the document shows: a field scrolled under the mode bar
// or a side panel must leave them their pointer (#2755). It stays on its field, cut off
// as the page is, rather than pushed back into the window.
egui::Area::new(egui::Id::unique(("form-editor", view.id.0)))
egui::Area::new(egui::Id::unique(("form-editor", view.view_key())))
.fade_in(false)
.order(egui::Order::Foreground)
.fixed_pos(rect.min)
@@ -839,7 +840,7 @@ pub(crate) fn overlay(
.text_color(Color32::BLACK)
.margin(egui::Margin::symmetric(3, 1))
.password(f.has(field_flags::PASSWORD))
.id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)));
.id(egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget)));
if let Some(max) = f.max_len {
te = te.char_limit(max);
}
@@ -894,7 +895,7 @@ pub(crate) fn overlay(
if editable_combo(f) {
combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget });
}
let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0)))
let resp = egui::Area::new(egui::Id::unique(("form-choices", view.view_key())))
.order(egui::Order::Foreground)
.fixed_pos(rect.left_bottom() + vec2(0.0, 2.0))
.show(ctx, |ui| {
@@ -953,10 +954,10 @@ pub(crate) fn overlay(
FormFieldKind::CheckBox | FormFieldKind::Radio => {
// Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so
// Space, Tab and the arrows come here instead of turning or scrolling the page.
let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget));
let id = egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget));
let mut has_focus = false;
// Not interactable, so clicks still reach the page under it.
let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false);
let area = egui::Area::new(egui::Id::unique(("form-toggle", view.view_key()))).order(egui::Order::Foreground).interactable(false);
area.fixed_pos(rect.min).show(ctx, |ui| {
let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive());
if let Some(fx) = view.forms.focus.as_mut()
+2 -1
View File
@@ -313,7 +313,8 @@ fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) {
let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k);
mouse_click(h, p);
assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing");
assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("edit-text-line-input"))), "with the keyboard");
let input = egui::Id::unique(("edit-text-line-input", h.state().views[0].place()));
assert!(h.ctx.memory(|m| m.has_focus(input)), "with the keyboard");
}
/// The open paragraph's line spacing, if a paragraph is open.
+1 -1
View File
@@ -404,7 +404,7 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() {
let doc = s.session.get(s.views[0].id).unwrap();
let f = doc.form.iter().find(|f| f.name == "name").unwrap();
let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen");
(rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize)))
(rect, egui::Id::unique(("form-field", s.views[0].view_key(), "name", 0usize)))
};
// From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text.
let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y));
+4
View File
@@ -79,6 +79,10 @@ mod script_noop;
mod scroll_prefetch;
mod side_panels;
mod signing;
mod split_view;
mod split_view_edits;
mod split_view_find;
mod split_view_render;
mod stamps;
mod status_layers;
mod text_select;
+317
View File
@@ -0,0 +1,317 @@
//! Split view (`view.split-window`): a tab's menu splits right or down, ⌘\ splits the current
//! document and ends split view again, each pane keeps its own zoom, the same document in both
//! panes is one document (one edit, one Undo), the pane last clicked is the one menus and keys act
//! on, and a tab dropped on the right half of the page splits right with it.
use egui::accesskit::Role;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::panes::Place;
/// `pages` portrait pages (300 × 400) with a line of text each.
fn fixture(pages: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..pages).map(|k| format!("{} 0 R", 4 + 2 * k)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 400] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
for k in 0..pages {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * k));
let content = format!("BT /F1 18 Tf 40 300 Td (Page {}) Tj ET", k + 1);
objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn harness(names: &'static [&'static str]) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.run_inline = true;
app.set_option("language", "en").unwrap();
for name in names {
app.open_bytes(name, None, fixture(3)).unwrap();
}
app
});
h.run_steps(4);
h
}
fn index(h: &Harness<'static, PdfCraftApp>, name: &str) -> usize {
let s = h.state();
s.views.iter().position(|v| s.session.get(v.id).is_some_and(|d| d.name == name)).expect("tab open")
}
fn places(h: &Harness<'static, PdfCraftApp>) -> Vec<Place> {
h.state().views.iter().map(|v| v.place()).collect()
}
/// Run frames until `ready`, rendering in between (renders arrive between frames).
fn wait(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
for _ in 0..600 {
h.run_steps(1);
if ready(h.state()) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("timed out waiting for {what}");
}
/// A press and release at `at` (a click on the page area).
fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
fn drag_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str, to: egui::Pos2) {
let from = h.get_by_role_and_label(Role::Tab, name).rect().center();
h.event(egui::Event::PointerMoved(from));
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for k in 1..=8 {
h.event(egui::Event::PointerMoved(from.lerp(to, k as f32 / 8.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
#[test]
fn split_right_from_the_tab_menu_shows_two_panes_each_with_its_own_zoom() {
let mut h = harness(&["quote.pdf", "order.pdf"]);
// The menu of quote.pdf, not the tab the main pane shows (order.pdf, opened last): it moves
// into the second pane.
h.get_by_role_and_label(Role::Tab, "quote.pdf").click_secondary();
h.run_steps(2);
h.get_by_label("Split right").click();
h.run_steps(4);
let (quote, order) = (index(&h, "quote.pdf"), index(&h, "order.pdf"));
let split = h.state().panes.split.expect("split view is on");
assert!(!split.down);
assert_eq!(h.state().views[order].place(), Place::Main);
assert_eq!(h.state().views[quote].place(), Place::Second);
// Side by side: two page areas, the second to the right of the first.
let (left, right) = (h.state().views[order].viewport_rect(), h.state().views[quote].viewport_rect());
assert!(left.width() > 200.0 && right.width() > 200.0, "{left:?} {right:?}");
assert!(right.left() >= left.right(), "{left:?} {right:?}");
// The second pane has its own strip with its tab; the main strip no longer shows it.
assert_eq!(h.query_all_by_role_and_label(Role::Tab, "quote.pdf").count(), 1);
assert_eq!(h.state().active, Some(quote), "the tab split into the pane is active");
// ⌘+ zooms the active pane only.
let before = (h.state().views[order].zoom, h.state().views[quote].zoom);
h.key_press_modifiers(Modifiers::COMMAND, Key::Plus);
h.run_steps(3);
assert_eq!(h.state().views[order].zoom, before.0, "the other pane keeps its zoom");
assert!(h.state().views[quote].zoom > before.1, "the active pane zoomed in");
// Split down turns the panes.
h.get_by_role_and_label(Role::Tab, "quote.pdf").click_secondary();
h.run_steps(2);
h.get_by_label("Split down").click();
h.run_steps(4);
assert!(h.state().panes.split.is_some_and(|s| s.down));
let (top, bottom) = (h.state().views[order].viewport_rect(), h.state().views[quote].viewport_rect());
assert!(bottom.top() >= top.bottom(), "{top:?} {bottom:?}");
}
#[test]
fn the_same_document_in_both_panes_is_one_document_with_one_undo() {
let mut h = harness(&["contract.pdf"]);
// ⌘\ splits the current document: a second view of it in the second pane.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
assert_eq!(places(&h), [Place::Main, Place::Second]);
let (first, second) = (0, 1);
assert_eq!(h.state().views[first].id, h.state().views[second].id, "one document");
assert_eq!(h.state().session.docs().len(), 1);
// Both panes draw the portrait page.
let portrait = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| hgt > w);
let landscape = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| w > hgt);
wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second));
// Each pane scrolls on its own: the second goes to page 3.
h.state_mut().views[second].go_to_page(2);
h.run_steps(4);
assert_eq!(h.state().views[first].current, 0);
assert_eq!(h.state().views[second].current, 2);
// An edit made in the first pane shows in the second pane too. (The second view of the
// document is the one the app's own edit code doesn't find by the document's id.)
h.state_mut().views[second].go_to_page(0);
h.run_steps(3);
let at = h.state().views[first].viewport_rect().center();
click_at(&mut h, at);
assert_eq!(h.state().active, Some(first), "a click makes the pane active");
assert!(h.state_mut().execute("page.rotate"));
wait(&mut h, "the rotated page in both panes", |s| landscape(s, first) && landscape(s, second));
let id = h.state().views[first].id;
assert!(h.state().session.get(id).unwrap().dirty);
// One Undo, from the other pane, puts it back in both.
let at = h.state().views[second].viewport_rect().center();
click_at(&mut h, at);
assert_eq!(h.state().active, Some(second));
assert!(h.state_mut().execute("edit.undo"));
wait(&mut h, "the page upright again in both panes", |s| portrait(s, first) && portrait(s, second));
assert!(h.state().session.get(id).unwrap().can_undo().is_none(), "it was one step");
// The other pane redraws after a change that leaves its page's size alone too (a comment
// added in the first pane): its page goes out of date at once, then comes back.
wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second));
let at = h.state().views[first].viewport_rect().center();
click_at(&mut h, at);
assert_eq!(h.state().active, Some(first));
wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second));
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let shape = Shape::Rectangle { rect: [40.0, 40.0, 200.0, 200.0] };
let comment = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: String::new() };
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(comment)));
h.run_steps(1);
assert!(h.state().views[second].drawn_page_size(0).is_none(), "the second pane's page is being drawn again");
wait(&mut h, "both panes drawn again", |s| portrait(s, first) && portrait(s, second));
}
#[test]
fn split_again_or_closing_a_panes_last_tab_ends_split_view() {
let mut h = harness(&["a.pdf", "b.pdf"]);
// ⌘\ twice: split, then back to one pane; the second view of the document closes, the
// document stays open.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(3);
assert!(h.state().panes.split.is_some());
assert_eq!(h.state().views.len(), 3);
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(3);
assert!(h.state().panes.split.is_none());
assert_eq!(h.state().views.len(), 2);
assert_eq!(places(&h), [Place::Main, Place::Main]);
assert_eq!(h.state().session.docs().len(), 2);
// Split right with a.pdf (not the tab shown): it moves to the second pane. Closing it there,
// the pane's last tab, ends split view and closes only that tab.
let a = index(&h, "a.pdf");
h.state_mut().split_tab(a, false);
h.run_steps(3);
assert_eq!(h.state().views[index(&h, "a.pdf")].place(), Place::Second);
let a = index(&h, "a.pdf");
h.state_mut().request_close_tab(a);
h.run_steps(3);
assert!(h.state().panes.split.is_none());
assert_eq!(h.state().views.len(), 1);
assert_eq!(places(&h), [Place::Main]);
// The same document in both panes: closing one view keeps the document and its edits.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(3);
assert!(h.state_mut().execute("page.rotate"));
h.run_steps(2);
let second = h.state().views.iter().position(|v| v.place() == Place::Second).unwrap();
h.state_mut().request_close_tab(second);
h.run_steps(3);
assert!(h.state().close_request.is_none(), "no save prompt: the document is still open");
assert!(h.state().panes.split.is_none());
assert_eq!(h.state().views.len(), 1);
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "its edit is still there");
// Typing a view holds of its own (not in the document yet) is asked about, even with the
// document open in the other pane; Don't save closes that view, not the other.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(3);
let second = h.state().views.iter().position(|v| v.place() == Place::Second).unwrap();
let (first_key, id) = (h.state().views[0].view_key(), h.state().views[0].id);
let typed = pdfcraft_ui_egui::fill_sign::TypeBox {
page: 0,
at: [40.0, 40.0],
text: "typed".into(),
size: 10.0,
color: [0.0; 3],
border: Default::default(),
focus: false,
};
h.state_mut().views[second].fill_text = Some(typed);
h.state_mut().request_close_tab(second);
assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::Tab(id)), "the typing is asked about");
let ctx = h.ctx.clone();
h.state_mut().resolve_close(&ctx, Some(false));
h.run_steps(3);
assert_eq!(h.state().views.len(), 1);
assert_eq!(h.state().views[0].view_key(), first_key, "the view asked about closed, the other stayed");
assert!(h.state().session.get(id).unwrap().dirty, "the document and its edit are still open");
}
#[test]
fn the_pane_last_clicked_is_the_one_menus_and_keys_act_on() {
// left.pdf, opened last, is the tab shown; right.pdf goes to the second pane.
let mut h = harness(&["right.pdf", "left.pdf"]);
let right = index(&h, "right.pdf");
h.state_mut().split_tab(right, false);
h.run_steps(4);
let (left, right) = (index(&h, "left.pdf"), index(&h, "right.pdf"));
let (lid, rid) = (h.state().views[left].id, h.state().views[right].id);
// A click in the left pane makes it active; a command from the menu acts on its document.
let at = h.state().views[left].viewport_rect().center();
click_at(&mut h, at);
assert_eq!(h.state().active, Some(left));
h.state_mut().run_command("page.rotate");
h.run_steps(2);
assert!(h.state().session.get(lid).unwrap().dirty);
assert!(!h.state().session.get(rid).unwrap().dirty);
// Then the right pane: the active tab follows the click, and so does the next command.
let at = h.state().views[right].viewport_rect().center();
click_at(&mut h, at);
assert_eq!(h.state().active, Some(right));
h.state_mut().run_command("page.rotate");
h.run_steps(2);
assert!(h.state().session.get(rid).unwrap().dirty);
// The main strip still marks the tab its pane shows, and the window title names it.
assert_eq!(h.get_by_role_and_label(Role::Tab, "left.pdf (edited)").accesskit_node().is_selected(), Some(true));
assert!(h.state().window_title.starts_with("left.pdf"), "{}", h.state().window_title);
}
#[test]
fn a_tab_dropped_on_the_right_half_of_the_page_splits_right_with_it() {
let mut h = harness(&["one.pdf", "two.pdf"]);
let page = h.state().views[index(&h, "two.pdf")].viewport_rect();
drag_tab(&mut h, "one.pdf", egui::pos2(page.right() - 120.0, page.center().y));
assert!(h.state().panes.split.is_some(), "dropped on the right half: split view");
assert_eq!(h.state().views[index(&h, "one.pdf")].place(), Place::Second);
assert_eq!(h.state().views[index(&h, "two.pdf")].place(), Place::Main);
// Dragged back onto the main strip, it rejoins it and split view ends.
let strip = h.get_by_role_and_label(Role::Tab, "two.pdf").rect();
drag_tab(&mut h, "one.pdf", strip.right_center() + egui::vec2(40.0, 0.0));
assert!(h.state().panes.split.is_none());
assert_eq!(places(&h), [Place::Main, Place::Main]);
// Let go outside the window, a tab moves along the strip as before split view (here to the
// front), and nothing splits.
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
drag_tab(&mut h, "two.pdf", egui::pos2(-200.0, 300.0));
assert!(h.state().panes.split.is_none());
assert_eq!(h.state().views.iter().map(|v| v.id).collect::<Vec<_>>(), [ids[1], ids[0]]);
}
/// Regression: closing the second pane's tab from its own strip, with the same document in both
/// panes, drew that pane with the closed tab's index ("index out of bounds").
#[test]
fn closing_the_second_panes_tab_from_its_strip_keeps_the_program_running() {
let mut h = harness(&["a.pdf"]);
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(3);
assert_eq!(places(&h), [Place::Main, Place::Second]);
let main_right = h.state().views[0].viewport_rect().right();
let tab = h.get_all_by_role_and_label(Role::Tab, "a.pdf").find(|t| t.rect().left() >= main_right).expect("the second pane's tab");
tab.click_secondary();
h.run_steps(2);
h.get_by_label("Close Tab").click();
h.run_steps(3);
assert!(h.state().panes.split.is_none());
assert_eq!(h.state().views.len(), 1);
}
+117
View File
@@ -0,0 +1,117 @@
//! Split view: typing in a field of one pane goes into the document when the next click goes to
//! the other pane, even when that click changes the document too.
use egui::{Key, Modifiers, Pos2};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::panes::Place;
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.run_inline = true;
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
app.set_option("left", "closed").unwrap();
// The whole 300×400 pt page in either pane.
app.set_option("zoom", "100").unwrap();
app
});
h.run_steps(4);
h
}
/// The centre of field `name`'s first widget in tab `view`, once that tab has drawn its page.
fn field_center(h: &Harness<'static, PdfCraftApp>, view: usize, name: &str) -> Option<Pos2> {
let s = h.state();
let doc = s.session.get(s.views[view].id)?;
let f = doc.form.iter().find(|f| f.name == name)?;
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[view], &doc.info, f, 0).map(|r| r.center())
}
fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
/// A press and release at `p`.
fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) {
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.drop_at(p);
h.run_steps(3);
}
/// Typing in the second pane's field, then clicking a check box in the first pane: the click
/// makes the first pane active before the second is drawn, and the field commits as it loses the
/// focus, so its edit is queued on the tab that is no longer active. It used to wait there until
/// the check box's edit made that tab start afresh next frame, and the typing was gone.
#[test]
fn typing_in_one_pane_survives_an_edit_in_the_other() {
let mut h = harness();
// ⌘\ splits the document: a second view of it in the second pane, the active one.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
let (first, second) = (0, 1);
assert_eq!(h.state().views.iter().map(|v| v.place()).collect::<Vec<_>>(), [Place::Main, Place::Second]);
assert_eq!(h.state().views[first].id, h.state().views[second].id, "one document");
for _ in 0..100 {
if field_center(&h, first, "agree").is_some() && field_center(&h, second, "name").is_some() {
break;
}
h.run_steps(1);
}
let name = field_center(&h, second, "name").expect("the second pane shows the field");
let agree = field_center(&h, first, "agree").expect("the first pane shows the check box");
assert!(h.state().views[second].viewport_rect().contains(name), "{name:?}");
assert!(h.state().views[first].viewport_rect().contains(agree), "{agree:?}");
click(&mut h, name);
assert_eq!(h.state().active, Some(second));
assert!(h.state().views[second].forms.focus.is_some(), "the editor opened");
h.event(egui::Event::Text("Typed in the second pane".into()));
h.run_steps(2);
assert!(value(&h, "name").is_empty(), "not committed yet");
click(&mut h, agree);
h.run_steps(3);
assert_eq!(h.state().active, Some(first), "the click made the first pane active");
assert_eq!(value(&h, "agree"), ["Yes"], "the click's own edit");
assert_eq!(value(&h, "name"), ["Typed in the second pane"], "the typing in the other pane went into the document");
assert!(h.state().views[second].pending_edit.is_none(), "nothing left queued");
}
/// Save from the second pane, with the same document in both: what is typed there goes into the
/// file. It used to look for the typing in the document's first tab, the first pane's, find none,
/// and write the file without it.
#[test]
fn save_in_the_second_pane_keeps_its_typing() {
let dir = std::env::temp_dir().join(format!("pdfcraft-split-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("out.pdf");
let mut h = harness();
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
let second = 1;
for _ in 0..100 {
if field_center(&h, second, "name").is_some() {
break;
}
h.run_steps(1);
}
let name = field_center(&h, second, "name").expect("the second pane shows the field");
click(&mut h, name);
h.event(egui::Event::Text("Typed in the second pane".into()));
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::S);
h.run_steps(4);
assert_eq!(value(&h, "name"), ["Typed in the second pane"], "the save took the typing");
assert_eq!(h.state().active, Some(second), "the pane worked in stays the active one");
let s = h.state();
assert!(!s.session.get(s.views[second].id).unwrap().dirty, "and wrote it");
assert!(std::fs::metadata(&out).is_ok_and(|m| m.len() > 0));
}
@@ -0,0 +1,81 @@
//! Split view: each pane's find field has its own id, so two find bars open at once do not clash
//! and do not share the focus; Find and Close act on the pane in use; and AltGr, which Windows
//! reports as Ctrl+Alt, does not run ⌘\ while it types a backslash.
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.run_inline = true;
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
app.set_option("left", "closed").unwrap();
app
});
h.run_steps(4);
h
}
#[test]
fn each_panes_find_input_has_its_own_id() {
let mut h = harness();
// ⌘\ splits the document: a second view of it in the second pane.
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
assert_eq!(h.state().views.len(), 2);
let (first, second) = (h.state().views[0].find_input_id(), h.state().views[1].find_input_id());
assert_ne!(first, second, "one find field id per pane");
// Find open in both panes, the second one asking for the focus last.
h.state_mut().views[0].open_find();
h.state_mut().views[1].open_find();
h.run_steps(4);
let focused = h.ctx.memory(|m| m.focused());
assert_eq!(focused, Some(second), "the focus is in the pane that asked last");
assert!(!h.ctx.memory(|m| m.has_focus(first)), "the other pane's field does not share it");
// Typing goes to one query only.
h.event(egui::Event::Text("abc".into()));
h.run_steps(2);
assert_eq!(h.state().views[1].find.as_ref().unwrap().query, "abc");
assert!(h.state().views[0].find.as_ref().unwrap().query.is_empty());
}
/// With the same document in both panes, Find and Close act on the second pane, the one in use.
#[test]
fn find_and_close_act_on_the_second_pane() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
assert_eq!(h.state().active, Some(1), "the new pane is the one in use");
h.key_press_modifiers(Modifiers::COMMAND, Key::F);
h.run_steps(4);
assert!(h.state().views[1].find.is_some(), "Find opens in the pane in use");
assert!(h.state().views[0].find.is_none(), "and not in the other");
let (kept, id) = (h.state().views[0].view_key(), h.state().views[0].id);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1, "Close closes that pane's tab");
assert_eq!(h.state().views[0].view_key(), kept, "and not the first pane's");
assert!(h.state().session.get(id).is_some(), "the document stays open");
}
/// German and Spanish keyboards type `\` with AltGr, which reaches us as Ctrl+Alt: that is typing,
/// not ⌘\. The real Ctrl+\ still splits.
#[test]
fn altgr_backslash_does_not_split() {
let mut h = harness();
let altgr = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE };
h.key_press_modifiers(altgr, Key::Backslash);
h.run_steps(4);
assert_eq!(h.state().views.len(), 1, "AltGr+key types a backslash");
assert!(h.state().panes.split.is_none());
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
assert!(h.state().panes.split.is_some(), "Ctrl+backslash splits");
}
@@ -0,0 +1,64 @@
//! Split view: the two views of one document render through its one pool, and an edit made after
//! they collected the frame's demand (#3149) leaves nothing of the old pages in that pool.
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
fn harness() -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.run_inline = true;
app.set_option("language", "en").unwrap();
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
app
});
h.run_steps(4);
h
}
/// Run frames until `ready`, rendering in between (renders arrive between frames).
fn wait(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
for _ in 0..600 {
h.run_steps(1);
if ready(h.state()) {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("timed out waiting for {what}");
}
/// The palette runs its command after the page area was drawn, as a dialog's OK does: the page
/// turns after both views asked for this frame's rasters at the old size. Neither view may keep
/// that demand (the single-view path drops it; the shared one handed it to the pool).
#[test]
fn a_late_edit_drops_both_views_stale_demand() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash);
h.run_steps(4);
assert_eq!(h.state().views.len(), 2);
assert_eq!(h.state().views[0].id, h.state().views[1].id, "one document in both panes");
let portrait = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| hgt > w);
let landscape = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| w > hgt);
wait(&mut h, "both panes drawn", |s| portrait(s, 0) && portrait(s, 1));
let id = h.state().views[0].id;
let rotation = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages[0].rotation;
h.state_mut().set_option("palette", "rotate pages clockwise").unwrap();
h.run_steps(2);
assert!(h.state().palette_open);
h.key_press(Key::Enter);
// Frame by frame, to look at the one that turned the page.
for _ in 0..10 {
h.run_steps(1);
if rotation(&h) == 90 {
break;
}
}
assert_eq!(rotation(&h), 90, "the palette turned the page");
for (k, v) in h.state().views.iter().enumerate() {
assert_eq!(v.raster_memory().pending_requests, 0, "view {k} kept demand made for the page before it turned");
}
// And both panes draw the turned page.
wait(&mut h, "both panes drawn again", |s| landscape(s, 0) && landscape(s, 1));
}
+2 -1
View File
@@ -440,7 +440,8 @@ fn selected_text_copy_keeps_focused_input_clipboard_ownership() {
h.state_mut().views[0].open_find();
h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into();
h.run_steps(3);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input")));
let find_input = h.state().views[0].find_input_id();
h.ctx.memory_mut(|m| m.request_focus(find_input));
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
h.run_steps(2);
+2 -1
View File
@@ -181,7 +181,8 @@ fn find_shortcuts_advance_and_retreat_matches() {
assert_eq!(n, 15);
// Surrender text edit focus so canvas shortcut handler receives key events.
h.state_mut().views[0].find.as_mut().unwrap().focus = false;
h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("find-input")));
let find_input = h.state().views[0].find_input_id();
h.ctx.memory_mut(|m| m.surrender_focus(find_input));
h.run_steps(2);
let start = h.state().views[0].find.as_ref().unwrap().current.unwrap();
// ⌘G advances to next match.
+2 -1
View File
@@ -348,7 +348,8 @@ update the numbers.
### 16. UI precision gaps
- **Missing:** moving several selected objects across pages (same-page moves shipped, #844), single-key tool accelerators,
rulers, grids and guides, loupe and dynamic zoom, multiple windows and split view, comment
rulers, grids and guides, loupe and dynamic zoom, multiple windows and spreadsheet split (split
view shipped), comment
pop-ups that can be moved, connector lines, customisable quick tools; shortcut display and tool-mode bugs (#746, #721), macOS window controls
(#789).
- **Estimate:** 30–60 h. **Doc:** [ui-parity.md](ui-parity.md).
+1 -1
View File
@@ -32,7 +32,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
| Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped |
| Document tabs | yes | yes, with unsaved marker | shipped |
| Multiple windows, drag a tab out | yes | no | planned |
| Split window, spreadsheet split | yes | no | planned |
| Split window, spreadsheet split | yes | split right or down (⌘\\, tab menu, a tab dropped on the page), one document or two; no four-pane spreadsheet split | split shipped; spreadsheet split planned |
| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned |
| Light, dark, system theme | yes | yes | shipped |
| Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped |
+4 -4
View File
@@ -1247,11 +1247,11 @@ title = "Split window (two panes on one document)"
area = "B"
tier = "P2"
milestone = "M14"
status = "planned"
commands = []
status = "shipped"
commands = ["view.split"]
tools = []
evidence = []
notes = ""
evidence = ["crates/ui-egui/tests/it/split_view.rs::split_right_from_the_tab_menu_shows_two_panes_each_with_its_own_zoom", "crates/ui-egui/tests/it/split_view.rs::the_same_document_in_both_panes_is_one_document_with_one_undo", "crates/ui-egui/tests/it/split_view.rs::split_again_or_closing_a_panes_last_tab_ends_split_view", "crates/ui-egui/tests/it/split_view.rs::the_pane_last_clicked_is_the_one_menus_and_keys_act_on", "crates/ui-egui/tests/it/split_view.rs::a_tab_dropped_on_the_right_half_of_the_page_splits_right_with_it", "crates/ui-egui/src/panes.rs::the_panes_share_the_area_and_the_divider_stays_inside"]
notes = "View ▸ Split (⌘\\ / Ctrl+\\) splits the current document into two panes (page 2 beside page 40) and ends split view again; a tab's right-click Split right / Split down puts that tab beside (or below) the one shown (two documents side by side), or the shown tab's document in both panes; a tab dropped on the right half of the page area splits right with it. Each pane scrolls and zooms on its own, the second has its own small tab strip, and the divider is dragged. The same document in both panes is one document: an edit in one shows in the other, and Undo is the document's. The pane last clicked is the active one (menus, keys and the side panels act on it). Closing a pane's last tab, or Split again, ends split view. Headless: the UI control channel runs view.split like any command. Beyond Acrobat's Window ▸ Split, which splits one document only. Not yet: Acrobat's spreadsheet split (four panes) is view.spreadsheet-split; split view isn't kept across restarts."
[[feature]]
id = "view.spreadsheet-split"