From 260a629e9e5cc39faee0cfc25bec7b2ec6d03c4e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 01:06:48 +0000 Subject: [PATCH] Refactor ui canvas for strict Clippy checks --- crates/ui-egui/src/canvas.rs | 1323 ++++++++++++--------- crates/ui-egui/src/canvas/balloons.rs | 115 +- crates/ui-egui/src/canvas/context_menu.rs | 2 +- crates/ui-egui/src/canvas/cursor.rs | 2 +- crates/ui-egui/src/canvas/geometry.rs | 2 +- crates/ui-egui/src/canvas/input.rs | 540 +++++---- crates/ui-egui/src/canvas/links.rs | 2 +- crates/ui-egui/src/canvas/margins.rs | 2 +- crates/ui-egui/src/canvas/navigation.rs | 2 +- crates/ui-egui/src/canvas/pages.rs | 2 +- crates/ui-egui/src/canvas/reading.rs | 2 +- crates/ui-egui/src/canvas/rulers.rs | 2 +- crates/ui-egui/src/canvas/story.rs | 2 +- 13 files changed, 1119 insertions(+), 879 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c94a7609f..6129bf483 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -428,16 +428,9 @@ impl Default for CanvasState { } } -/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw -/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret -/// input. -pub fn show(app: &mut WordApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let full = ui.available_rect_before_wrap(); - // During a resize keep the already laid-out pages ([`Settle`]); ordinary reading controls - // and zoom update now. +/// Update the deferred viewport sizes and return the text anchor to restore after reflow. +fn update_viewport(app: &mut WordApp, ui: &Ui, full: Rect, now: f64) -> Option { let mut reflow_anchor: Option = None; - let now = ui.input(|i| i.time); if app.session.view.read_mode { app.canvas.web_viewport.leave(); let entering = app.canvas.read_viewport.entering(); @@ -482,35 +475,31 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.read_first_visible = None; app.canvas.read_target_page = None; } - // Keys first, so this frame lays out and paints what was typed (handled after painting, a - // keystroke showed one frame later). Focus is the canvas's from the last frame. - // While "Save changes?" is up, keys answer it rather than edit the document behind it. - if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { - crate::keys::canvas_events(app, ui.ctx()); - } else { - // Alt+numpad entry doesn't survive the canvas losing the keyboard. - app.canvas.alt_code.clear(); - } - crate::ink::replay_tick(app, ui.ctx()); - let layout = shown_layout(app); - if let Some(anchor) = reflow_anchor - && let Some(page) = reading_anchor_page(&layout, &anchor) - { - let step = app.session.view.read_width * app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT + GAP; - app.canvas.read_target_page = None; - app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0)); - } - // View › Switch Modes: dark paper, inverted document colours. - // In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia - // draws sepia paper and None draws white paper. + reflow_anchor +} + +struct CanvasView { + area: Rect, + hruler: Option, + vruler: Option, + dark_page: bool, + sepia_page: bool, + animate: bool, + opts: egui::InputOptions, + geo: Geometry, + dim_body: bool, + grid_story: Option, + caret: Option, +} + +fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> CanvasView { use wordcraft_engine::cmd::view::ReadPageColor; let read_mode = app.session.view.read_mode; let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode }; let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia; - let dark_paper = wordcraft_render::DARK_PAPER; let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print - && !app.session.view.read_mode + && !read_mode && crate::chrome::Screen::of(&app.session.view) == crate::chrome::Screen::Normal; let (hruler, vruler, area) = if show_ruler { let h = Rect::from_min_max(pos2(full.min.x + RULER, full.min.y), pos2(full.max.x, full.min.y + RULER)); @@ -519,49 +508,37 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { (None, None, full) }; - let area = if app.session.view.read_mode { + let area = if read_mode { let column = app.session.view.read_width * app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT; reading_viewport(area, column, app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT) } else { area }; app.canvas.canvas_rect = Some(area); - // Ctrl/⌘ + wheel (and trackpad pinch) zooms the document, like Word. egui reports both as a - // zoom factor and doesn't scroll for them. Start from the shown scale so fit modes zoom too. - // With animation effects off the wheel zooms in whole steps, each at once. let animate = app.animations_on(); let opts = ui.ctx().options(|o| o.input_options); let zoom_delta = ui.input(|i| app.canvas.wheel.zoom(i, &opts, animate)); - // The page point under the pointer before the zoom: it is kept under the pointer (#1223). let mut zoom_anchor = None; if (zoom_delta - 1.0).abs() > 1e-3 && ui.rect_contains_pointer(area) { - let reading = app.session.view.read_mode; - if !reading && let Some(p) = ui.input(|i| i.pointer.latest_pos()) { - zoom_anchor = page_at(&app.canvas.page_rects, &layout, app.canvas.scale, p).map(|at| (p, at)); + if !read_mode && let Some(p) = ui.input(|i| i.pointer.latest_pos()) { + zoom_anchor = page_at(&app.canvas.page_rects, layout, app.canvas.scale, p).map(|at| (p, at)); } - let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(if reading { 75.0 } else { 10.0 }, if reading { 150.0 } else { 500.0 }); - let id = if reading { "view.readTextSize" } else { "view.zoom" }; + let pct = + (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(if read_mode { 75.0 } else { 10.0 }, if read_mode { 150.0 } else { 500.0 }); + let id = if read_mode { "view.readTextSize" } else { "view.zoom" }; app.canvas.wheel.cancel(); let _ = app.run(id, json!({"value": pct})); } - let avail = if app.session.view.read_mode { area.size() } else { area.size() - vec2(SCROLLBAR, 0.0) }; - let geo = geometry(app, &layout, avail); - // A pane or toolbar resize changes the reachable last column immediately. Navigation must - // clamp against this frame's geometry, not the previous frame's scroll maximum. - if app.session.view.read_mode { + let avail = if read_mode { area.size() } else { area.size() - vec2(SCROLLBAR, 0.0) }; + let geo = geometry(app, layout, avail); + if read_mode { app.canvas.scroll_max = (geo.size - area.size()).max(egui::Vec2::ZERO); } - // The status bar's zoom readout and slider were laid out before this, from the last frame's - // scale: after a zoom change draw again at once, so they follow in the next frame rather than - // whenever something else next repaints (the caret's blink). if (geo.scale - app.canvas.scale).abs() > 1e-6 { ui.ctx().request_repaint(); } app.canvas.scale = geo.scale; sync_fit_zoom(app, geo.scale); - // A pointer zoom scrolls so the same page point is under the pointer at the new scale, also - // when the pages reflow into rows. Without this the scroll offset stayed in pixels and the page - // slid away under the pointer. let anchored = if let Some((p, (page, x, y))) = zoom_anchor && let (Some(pr), Some(lp), Some(vp)) = (geo.rects.get(page), layout.pages.get(page), app.canvas.viewport) { @@ -572,69 +549,70 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } else { false }; - // Zooming or resizing reflowed the pages into a different number of columns: the caret's page - // moved, so bring it back into view rather than leave the reader somewhere else. A zoom at the - // pointer keeps the reader where the pointer is instead. if geo.cols != app.canvas.cols { app.canvas.cols = geo.cols; - if !app.session.view.read_mode && !anchored { + if !read_mode && !anchored { app.canvas.scroll_to_caret = true; } } - // Where the selection really ends, also when it took whole table cells. let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint); - // Editing a header/footer (or a note) dims the body; once per frame, for every page. - let dim_body = dims_body(app, &layout); - let grid_story = text_box_host(app, &layout).unwrap_or(app.session.sel.focus.story); + let dim_body = dims_body(app, layout); + let grid_story = text_box_host(app, layout).unwrap_or(app.session.sel.focus.story); let grid_story = matches!(part_kind(app, grid_story), Some(PartKind::Header | PartKind::Footer)).then_some(grid_story); if let Some(c) = caret && !app.session.selects_whole_story() { app.session.page_hint = c.page; } - let mut scroll_target: Option = None; + CanvasView { area, hruler, vruler, dark_page, sepia_page, animate, opts, geo, dim_body, grid_story, caret } +} + +fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option { + let mut target = None; if app.canvas.nav_to_caret { app.canvas.read_target_page = None; - if let Some(c) = caret - && let Some(pr) = geo.rects.get(c.page) + if let Some(c) = view.caret + && let Some(pr) = view.geo.rects.get(c.page) { let x = (pr.left() - GAP).max(0.0); - let y = if app.session.view.read_mode { 0.0 } else { (pr.top() + c.top * geo.scale - 40.0).max(0.0) }; + let y = if app.session.view.read_mode { 0.0 } else { (pr.top() + c.top * view.geo.scale - 40.0).max(0.0) }; app.canvas.scroll_request = Some(vec2(x, y)); } app.canvas.nav_to_caret = false; } else if app.canvas.scroll_to_caret && app.session.view.read_mode && app.canvas.scroll_request.is_none() { - if let Some(c) = caret - && let Some(pr) = geo.rects.get(c.page) + if let Some(c) = view.caret + && let Some(pr) = view.geo.rects.get(c.page) { - // Ordinary caret-follow only reveals an offscreen position, as in Print Layout. - // A click in a visible column must not align that whole column to the left. - let x = pr.left() + c.x * geo.scale; + let x = pr.left() + c.x * view.geo.scale; let left = app.canvas.scroll_offset.x; - if x < left || x > left + area.width() { + if x < left || x > left + view.area.width() { app.canvas.read_target_page = None; app.canvas.scroll_request = Some(vec2((pr.left() - GAP).max(0.0), 0.0)); } } } else if app.canvas.scroll_to_caret - && let Some(c) = caret - && let Some(pr) = geo.rects.get(c.page) + && let Some(c) = view.caret + && let Some(pr) = view.geo.rects.get(c.page) { let r = Rect::from_min_size( - pos2(pr.min.x + c.x * geo.scale, pr.min.y + c.top * geo.scale), - vec2((c.width * geo.scale).max(2.0), c.height * geo.scale), + pos2(pr.min.x + c.x * view.geo.scale, pr.min.y + c.top * view.geo.scale), + vec2((c.width * view.geo.scale).max(2.0), c.height * view.geo.scale), ); - scroll_target = Some(r.expand2(vec2(40.0, 60.0))); + target = Some(r.expand2(vec2(40.0, 60.0))); } app.canvas.scroll_to_caret = false; - let mut origin = area.min; - let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); - // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with - // momentum, wheel notches eased in (or all at once with animation effects off). Applied - // before drawing, so it shows this frame. In reading mode, wheel and touchpad events turn - // whole columns instead. + target +} + +struct ScrollMotion { + wheel: egui::Vec2, + auto: egui::Vec2, + middle: egui::Vec2, +} + +fn scroll_motion(app: &mut WordApp, ui: &Ui, full: Rect, view: &CanvasView) -> ScrollMotion { // Read Mode's centred columns leave margins at the sides; the wheel turns pages over them too. - let wheel_area = if app.session.view.read_mode { full } else { area }; + let wheel_area = if app.session.view.read_mode { full } else { view.area }; let hovered = wheel_hovered(ui.rect_contains_pointer(wheel_area), ui.ctx().dragged_id().is_some(), app.canvas.drag_selecting()); let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT); let wheel_delta = if app.session.view.read_mode { @@ -643,10 +621,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { turn_read_page(app, turns.signum() as f32); } if let Some(page) = app.canvas.read_target_page { - let step = app.session.view.read_width * geo.scale + GAP; + let step = app.session.view.read_width * view.geo.scale + GAP; let x = (page as f32 * step).min(app.canvas.scroll_max.x); let dt = ui.input(|i| i.stable_dt); - let next = if animate { reading_animation_step(app.canvas.scroll_offset.x, x, dt) } else { x }; + let next = if view.animate { reading_animation_step(app.canvas.scroll_offset.x, x, dt) } else { x }; app.canvas.scroll_request = Some(vec2(next, 0.0)); if next == x { app.canvas.read_target_page = None; @@ -659,7 +637,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { egui::Vec2::ZERO } else { app.canvas.read_wheel_accum = 0.0; - ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height(), animate)) + ui.input(|i| app.canvas.wheel.frame(i, &view.opts, hovered, notch, view.area.height(), view.animate)) }; // Dragging a selection near the viewport's edge scrolls on (#688): from a crawl at the margin // to full speed at the edge, through the same offset the wheel writes. @@ -685,32 +663,40 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.auto_scroll.cancel(); egui::Vec2::ZERO } else { - app.canvas.auto_scroll.update(ui, area) + app.canvas.auto_scroll.update(ui, view.area) }; - let delta = wheel_delta + auto_delta + middle_delta; + ScrollMotion { wheel: wheel_delta, auto: auto_delta, middle: middle_delta } +} + +fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> egui::ScrollArea { + let delta = motion.wheel + motion.auto + motion.middle; + // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with + // momentum, wheel notches eased in (or all at once with animation effects off). Applied + // before drawing, so it shows this frame. In reading mode, wheel and touchpad events turn + // whole columns instead. let mut scroll_area = egui::ScrollArea::both() // One scroll position per open document, so switching back finds the page as it was left. .id_salt(("canvas_scroll", app.session.document_id())) .auto_shrink([false, false]) // Bringing the caret into view glides there, or jumps with animation effects off. - .animated(animate) + .animated(view.animate) .scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() }); if app.session.view.read_mode { scroll_area = scroll_area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); } if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode { let before = app.canvas.scroll_offset; - let step = app.session.view.read_width * geo.scale + GAP; + let step = app.session.view.read_width * view.geo.scale + GAP; let snapped = if step.is_finite() && step > 0.0 { vec2((before.x / step).round() * step, 0.0) } else { egui::Vec2::ZERO }; let after = match app.canvas.scroll_request.take() { Some(request) => request.clamp(egui::Vec2::ZERO, app.canvas.scroll_max), None if app.session.view.read_mode => snapped.clamp(egui::Vec2::ZERO, app.canvas.scroll_max), None => clamped_scroll(before, delta, app.canvas.scroll_max), }; - if after == before && wheel_delta != egui::Vec2::ZERO { + if after == before && motion.wheel != egui::Vec2::ZERO { app.canvas.wheel.hit_edge(); } - if auto_delta != egui::Vec2::ZERO && after != before { + if motion.auto != egui::Vec2::ZERO && after != before { // The scroll keeps going without new input. ui.ctx().request_repaint(); } @@ -719,227 +705,655 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if app.canvas.wheel.is_animating() { ui.ctx().request_repaint(); } + scroll_area +} + +fn prepare_layout(app: &mut WordApp, ui: &Ui, full: Rect) -> std::sync::Arc { + let now = ui.input(|i| i.time); + let reflow_anchor = update_viewport(app, ui, full, now); + if app.canvas.focused && !matches!(app.dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })) { + crate::keys::canvas_events(app, ui.ctx()); + } else { + app.canvas.alt_code.clear(); + } + crate::ink::replay_tick(app, ui.ctx()); + let layout = shown_layout(app); + if let Some(anchor) = reflow_anchor + && let Some(page) = reading_anchor_page(&layout, &anchor) + { + let step = app.session.view.read_width * app.session.view.read_zoom.clamp(0.75, 1.5) * PX_PER_PT + GAP; + app.canvas.read_target_page = None; + app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0)); + } + layout +} + +struct CanvasFinish<'a> { + app: &'a mut WordApp, + ui: &'a mut Ui, + layout: &'a std::sync::Arc, + geo: &'a Geometry, + caret: Option, + ime_geometry: Option<(Rect, Rect)>, + ime_interrupt: bool, + area: Rect, + dim_body: bool, + hruler: Option, + vruler: Option, +} + +fn update_canvas_scroll_state( + app: &mut WordApp, + ui: &mut Ui, + layout: &std::sync::Arc, + geo: &Geometry, + caret: Option, + out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>, +) { + app.canvas.scroll_offset = out.state.clamped_offset(); + scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len()); + // The page the status bar names (#3880): the caret's, or a whole-story selection's page, while + // it is on screen; else the page at the top of the window. + app.canvas.viewed_page = (!app.session.view.read_mode && !geo.rects.is_empty()).then(|| { + let extent = if app.session.selects_whole_story() { + let page = app.session.page_hint.min(geo.rects.len() - 1); + geo.rects.get(page).map(|r| (page, r.top(), r.bottom())) + } else { + caret.and_then(|c| geo.rects.get(c.page).map(|r| (c.page, r.top() + c.top * geo.scale, r.top() + (c.top + c.height) * geo.scale))) + }; + viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1)) + }); + crate::ink::replay_bar(app, ui, out.inner_rect); + app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); + if app.session.view.read_mode { + let step = app.session.view.read_width * geo.scale + GAP; + if step.is_finite() && step > 0.0 { + let page = (app.canvas.scroll_offset.x / step).round().max(0.0) as usize; + app.canvas.read_first_visible = reading_page_start(layout, page.min(layout.pages.len().saturating_sub(1))); + } + } + // The viewport barely moves between frames; the auto-scroll decides this frame's scroll before + // the scroll area is built, so it reads last frame's viewport. + app.canvas.viewport = Some(out.inner_rect); +} + +fn update_canvas_focus(app: &mut WordApp, ui: &mut Ui, resp: &egui::Response, ime_geometry: Option<(Rect, Rect)>, ime_interrupt: bool) { + // Space and Enter also set `Response::clicked` on a focused click-sensing widget + // (egui's fake primary click). Re-requesting an already focused widget no longer + // interrupts IME in 0.37. A pointer + // click, an accessibility click, or a drag still takes focus, including a click on + // a canvas that already has it, so a composition commits. `want_focus` focuses only + // while the canvas does not, and does not interrupt an editor that already has it. + let keyboard_click = ui.memory(|m| m.has_focus(resp.id)) && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); + let pointer_click = resp.clicked_by(egui::PointerButton::Primary); + let accesskit_click = resp.clicked() && !pointer_click && !keyboard_click; + let focus_click = pointer_click || accesskit_click || resp.drag_started(); + if focus_click { + ui.memory_mut(|memory| memory.interrupt_ime()); + } + if focus_click || (app.canvas.want_focus && !resp.has_focus()) { + resp.request_focus(); + } + if focus_click || app.canvas.want_focus { + app.canvas.want_focus = false; + } + app.canvas.widget_id = Some((resp.id, ui.ctx().cumulative_pass_nr())); + if resp.has_focus() { + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) + }); + } + app.canvas.focused = resp.has_focus(); + // One observation per viewport pass, after every event and command for the pass has been + // applied: the owner, generation, bounded surrounding text and change cause all come from + // this same settled state. + crate::keys::observe_ime(app, ui.ctx(), ime_geometry, ime_interrupt); +} + +fn dismiss_balloon_on_canvas_click(app: &mut WordApp, ui: &Ui, resp: &egui::Response) { + if app.canvas.balloon.is_some() + && ui.input(|i| i.pointer.any_pressed()) + && resp.contains_pointer() + && !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| over_balloon(app, p)) + { + deselect_balloon(app); + } +} + +struct PagePaint<'a> { + app: &'a mut WordApp, + ui: &'a Ui, + layout: &'a std::sync::Arc, + geo: &'a Geometry, + t: &'a Tokens, + painter: &'a egui::Painter, + dim_body: bool, + dark_page: bool, + sepia_page: bool, + hidden: Option<(usize, Pos)>, + view: u64, + content_origin: Pos2, + ppp: f32, + max_tex: f32, + grid_story: Option, +} + +struct PagePaintOutput { + rects: Vec, + visible_tile_keys: HashSet<(usize, usize)>, + rendered: usize, +} + +fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: &Page, visual_sr: Rect, page_scale: f32) { + // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). + if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { + let settings = &app.session.doc.settings; + // A section with a document grid (`w:docGrid`) shows that grid: lines at the line + // pitch and, for a character grid, columns at the character pitch. + let sections = app.session.doc.sections(); + let sect = sections.get(page.section).map(|(_, s)| *s); + let step = match sect.and_then(|s| s.grid_line_pitch().map(|lp| (s, lp))) { + Some((s, lp)) => { + let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size; + let cp = s.grid_char_pitch(size).map_or(f32::INFINITY, |cp| drawing_grid_step(cp, page_scale)); + (cp, drawing_grid_step(lp, page_scale)) + } + None => (drawing_grid_step(settings.grid_h, page_scale), drawing_grid_step(settings.grid_v, page_scale)), + }; + let (xs, ys) = drawing_grid(page.body, step); + let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22)); + let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale); + let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale); + for x in xs { + let sx = visual_sr.min.x + x * page_scale; + painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke); + } + for y in ys { + let sy = visual_sr.min.y + y * page_scale; + painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); + } + } +} + +fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { + let PagePaint { app, ui, layout, geo, t, painter, dim_body, dark_page, sepia_page, hidden, view, content_origin, ppp, max_tex, grid_story } = + pass; + let mut visible_tile_keys = HashSet::new(); + let mut rendered = 0; + let mut rects = Vec::with_capacity(geo.rects.len()); + for (i, pr) in geo.rects.iter().enumerate() { + let sr = pr.translate(content_origin.to_vec2()); + let Some(page) = layout.pages.get(i) else { continue }; + let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height())); + // A Web/Draft page can be arbitrarily tall. Shrinking the whole page into one GPU + // texture makes the text blurry; split those pages into native-resolution tiles. + let width_scale = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).max(0.05); + let tiled = page.h.clamp(1.0, 1e6) * width_scale > max_tex; + let scale_px = if tiled { width_scale } else { width_scale.min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05) }; + let tile_height_px = TILE_HEIGHT_PX.min(max_tex as usize).max(1); + // Hide White Space shows only a band of the page; everything below paints into it. + let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot)); + let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0)); + // Only pages on screen or with a texture need their key (#811). + let cached = !tiled && app.canvas.textures.contains_key(&i); + let key = + if visible || cached { page_key(app, layout, i, view, scale_px) ^ if tiled { (tile_height_px as u64) << 32 } else { 0 } } else { 0 }; + let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); + // The page a drag took an object off: render it this frame whatever the budget, or the + // raster with the object still on it shows for a frame. + let forced = hidden.as_ref().is_some_and(|(page, _)| *page == i); + // Render before placing the page: a page re-rendered in this frame (a zoom step, an + // edit) then sits on the pixel grid in this frame too, rather than a frame later. + if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { + let mut opts = page_render_options(app, dim_body); + // Only on the page it is on: the `Pos` can't match an object of another page. + opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); + let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts); + let ci = page_image(&img); + match app.canvas.textures.get_mut(&i) { + Some((k, tex)) => { + tex.set(ci, egui::TextureOptions::LINEAR); + *k = key; + } + None => { + let tex = ui.ctx().load_texture(format!("page{i}"), ci, egui::TextureOptions::LINEAR); + app.canvas.textures.insert(i, (key, tex)); + } + } + rendered += 1; + fresh = true; + } else if visible && !tiled && !fresh { + ui.ctx().request_repaint(); + } + let current_scale = geo.scale * ppp; + let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) { + app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp)) + } else { + sr + }; + rects.push(visual_sr); + if !visible { + continue; + } + let band = band_of(visual_sr); + let painter = + if band_top <= 0.0 && band_bot >= sr.height() { painter.clone() } else { painter.with_clip_rect(painter.clip_rect().intersect(band)) }; + // Shadow and paper. + painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); + let paper = if dark_page { + Color32::from_gray(wordcraft_render::DARK_PAPER) + } else if sepia_page { + let s = wordcraft_render::SEPIA_PAPER; + Color32::from_rgb(s.0, s.1, s.2) + } else { + Color32::WHITE + }; + painter.rect_filled(visual_sr, 0.0, paper); + if tiled { + // Render before painting to keep the frame sharp immediately after an edit or + // zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling. + app.canvas.textures.remove(&i); + let total_px = (page.h.clamp(1.0, 1e6) * scale_px).ceil() as usize; + let screen_per_raster_px = geo.scale / scale_px; + let base = pos2((sr.min.x * ppp).round() / ppp, (sr.min.y * ppp).round() / ppp); + for tile in visible_tiles(sr, ui.clip_rect().expand(200.0).intersect(band), geo.scale, scale_px, tile_height_px) { + let start_px = tile.saturating_mul(tile_height_px); + let height_px = total_px.saturating_sub(start_px).min(tile_height_px); + if height_px == 0 { + continue; + } + let id = (i, tile); + visible_tile_keys.insert(id); + let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key); + if !fresh && rendered < 4 { + let mut opts = page_render_options(app, dim_body); + // Only on the page it is on: the `Pos` can't match an object of another page. + opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); + // The tiny subtraction avoids a 1px overshoot from round-tripping f32 + // pixels through page points and render_area's ceil() conversion. + let img = wordcraft_render::render_area( + page_doc(app), + page, + wordcraft_geom::Rect::new(0.0, start_px as f32 / scale_px, page.w, (height_px as f32 - 0.01).max(0.01) / scale_px), + scale_px, + &opts, + ); + let ci = page_image(&img); + match app.canvas.tiles.get_mut(&id) { + Some((k, tex)) => { + tex.set(ci, egui::TextureOptions::LINEAR); + *k = key; + } + None => { + let tex = ui.ctx().load_texture(format!("page{i}-tile{tile}"), ci, egui::TextureOptions::LINEAR); + app.canvas.tiles.insert(id, (key, tex)); + } + } + rendered += 1; + } else if !fresh { + ui.ctx().request_repaint(); + } + if let Some((_, tex)) = app.canvas.tiles.get(&id) { + let rect = Rect::from_min_size( + pos2(base.x, base.y + start_px as f32 * screen_per_raster_px), + vec2(tex.size_vec2().x * screen_per_raster_px, height_px as f32 * screen_per_raster_px), + ); + painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + } + } else if let Some((_, tex)) = app.canvas.textures.get(&i) { + painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let page_scale = page_screen_scale(visual_sr, page, geo.scale); + // Header/footer editing chrome. + if let StoryRef::Part(id) = app.session.sel.focus.story { + let sect_body = page.body; + if page.header_story == Some(id) || page.footer_story == Some(id) { + let hy = visual_sr.min.y + sect_body.y * page_scale - 4.0; + let fy = visual_sr.min.y + sect_body.bottom() * page_scale + 4.0; + for (y, label) in [(hy, "Header"), (fy, "Footer")] { + dashed(&painter, pos2(visual_sr.min.x, y), pos2(visual_sr.max.x, y), Stroke::new(1.0, t.accent)); + let tr = Rect::from_min_size(pos2(visual_sr.min.x + 2.0, if label == "Header" { y } else { y - 18.0 }), vec2(52.0, 18.0)); + painter.rect_filled(tr, 2.0, t.checked); + painter.text(tr.center(), egui::Align2::CENTER_CENTER, tl!(label), regular(11.0), t.accent_text); + } + } + } + draw_page_gridlines(app, &painter, t, page, visual_sr, page_scale); + // The current header/footer's cells have editing guides; explicit View Gridlines + // still outlines every table. Neither kind of guide is part of the document. + if app.session.view.table_gridlines || grid_story.is_some() { + for rect in gridline_cells(page, app.session.view.table_gridlines, grid_story) { + let r = Rect::from_min_size( + pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), + vec2(rect.w * page_scale, rect.h * page_scale), + ); + painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle); + } + } + } + PagePaintOutput { rects, visible_tile_keys, rendered } +} + +fn paint_read_aloud_highlight( + app: &mut WordApp, + painter: &egui::Painter, + layout: &std::sync::Arc, + rects: &[Rect], + geo: &Geometry, + t: &Tokens, +) { + if let Some((a, b)) = crate::read_aloud::highlight(app) { + for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) { + if let Some(pr) = rects.get(pi) { + let sr = Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale)); + painter.rect_filled(sr, 0.0, t.accent.gamma_multiply(0.16)); + painter.hline(sr.x_range(), sr.max.y - 0.5, egui::Stroke::new(1.5, t.accent)); + } + } + } +} + +fn canvas_response(ui: &mut Ui, content: Rect, scroll_target: Option) -> egui::Response { + // A fixed id: an automatic one follows how many widgets came before, which Read Mode's + // chrome changes, and a new id dropped the keyboard focus, so the arrows, Page Up/Down + // and Escape did nothing in Read Mode until the reader clicked a page (#1772). + // It also keeps the focus when Focus hides the rulers (#1678). + ui.advance_cursor_after_rect(content); + let response = ui.interact(content, egui::Id::unique("wordcraft_canvas"), Sense::click_and_drag()); + if let Some(target) = scroll_target { + ui.scroll_to_rect(Rect::from_min_size(ui.min_rect().min + target.min.to_vec2(), target.size()), None); + } + response +} + +fn handle_primary_pointer( + app: &mut WordApp, + ui: &Ui, + resp: &egui::Response, + rects: &[Rect], + layout: &DocLayout, + geo: &Geometry, + middle_gesture: bool, +) { + // Double-click the space between two pages (or the line between them) to hide or show it. + if resp.double_clicked() + && app.session.view.mode == wordcraft_layout::ViewMode::Print + && !app.session.view.read_mode + && let Some(p) = resp.interact_pointer_pos() + && on_page_gap(rects, &geo.bands, p) + { + let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space})); + } else + // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. + if !middle_gesture && !crate::table_pen::pointer(app, ui, resp, rects, layout, geo.scale) { + mouse(app, ui, resp, rects, layout, geo.scale); + } +} + +fn paint_white_space_seams(painter: &egui::Painter, rects: &[Rect], geo: &Geometry, t: &Tokens) { + for (i, pair) in rects.windows(2).enumerate() { + let Some(&(_, bot)) = geo.bands.get(i) else { continue }; + if white_space_hidden(&geo.rects, &geo.bands) { + let y = pair[0].min.y + bot; + painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong); + } + } +} + +fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, geo: &Geometry, secondary_clicked: bool) { + // Selected table cells: a right-click off them drops them, even between the selection's ends + // (beside a selected column), so the menu acts on the cell clicked. + if secondary_clicked + && let Some(range) = app.session.cell_range() + && let Some(p) = resp.interact_pointer_pos() + && let Some((page, x, y)) = page_at(rects, layout, geo.scale, p) + { + let at = wordcraft_geom::Point::new(x, y); + let on_cells = + wordcraft_engine::cmd::table_mouse::cell_rects(layout, &app.session.doc, range).iter().any(|(pi, r)| *pi == page && r.contains(at)); + if !on_cells && let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + } + // Right-click: move the caret there (unless inside the selection), then the context menu. + // Right-click in an equation puts the caret there and opens the equation menu. + if secondary_clicked + && let Some(p) = resp.interact_pointer_pos() + && let Some((page, x, y)) = page_at(rects, layout, geo.scale, p) + { + match layout.equation_hit(page, x, y, app.session.sel.focus.story) { + Some((at, inner)) => { + app.session.sel = wordcraft_engine::Selection::caret(at); + let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); + } + None if app.session.math.is_some() => { + let _ = app.run("equation.exit", json!({})); + } + None => {} + } + } + // Right-click on a picture, shape or text box selects it (its menu offers Wrap Text). + if secondary_clicked + && app.session.math.is_none() + && let Some(p) = resp.interact_pointer_pos() + && let Some((page, x, y)) = page_at(rects, layout, geo.scale, p) + { + if crate::objects::select_at(app, layout, rects, geo.scale, p) { + app.canvas.context_issue = None; + app.canvas.context_synonyms = None; + } else if let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) { + let (a, b) = app.session.sel.ordered(); + if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + context_suggestions(app); + } + } +} + +fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>) { + let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler } = finish; + update_canvas_scroll_state(app, ui, layout, geo, caret, &out); + let (resp, rects) = out.inner; + // A click on the page leaves the selected comment balloon. + dismiss_balloon_on_canvas_click(app, ui, &resp); + update_canvas_focus(app, ui, &resp, ime_geometry, ime_interrupt); + // Screen readers: the document under the canvas, with the caret (#488). + crate::a11y::expose(app, ui.ctx(), resp.id, layout, &rects, area); + // The buttons that start or stop middle-click scrolling don't also click the page. + let middle_gesture = app.canvas.auto_scroll.blocks_input(); + handle_primary_pointer(app, ui, &resp, &rects, layout, geo, middle_gesture); + // A click on a check box ticks it; on a list's button opens the list. + if !middle_gesture { + crate::content_controls::clicks(app, &resp); + } + let secondary_clicked = resp.secondary_clicked() && !middle_gesture; + handle_secondary_selection(app, &resp, &rects, layout, geo, secondary_clicked); + // Shift+F10 opens the same menu below the caret, for the selection or the word there (#821). + let from_keys = std::mem::take(&mut app.canvas.context_menu_from_keys); + // A click on the equation frame's options strip opens the equation's menu below the strip. + let eq_strip = app.canvas.eq_options.map(|f| f.strip).filter(|r| resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| r.contains(p))); + if from_keys { + context_suggestions(app); + app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h)); + } else if let Some(r) = eq_strip { + app.canvas.context_menu_at = Some(r.left_bottom()); + } else if secondary_clicked { + app.canvas.context_menu_at = resp.interact_pointer_pos(); + } + // Keep the parent independently of egui's one memory popup: the toolbar's font, size and + // colour controls use that slot for their own lists (as in a collapsed ribbon group). + let mut open = app.canvas.context_menu_open; + if secondary_clicked || from_keys || eq_strip.is_some() { + egui::Popup::close_all(ui.ctx()); + open = true; + } else if open && ui.input(|i| i.pointer.any_click()) { + let layer = ui.input(|i| i.pointer.interact_pos()).and_then(|p| ui.ctx().layer_id_at(p)); + if !layer.is_some_and(|l| matches!(l.order, egui::Order::Foreground | egui::Order::Tooltip)) { + open = false; + } + } + let child_open = egui::Popup::is_any_open(ui.ctx()); + let mut shown = open; + if open { + app.escape_free = false; + } + let menu = egui::Popup::menu(&resp).open_bool(&mut shown); + let menu = menu.at_position(app.canvas.context_menu_at.unwrap_or(out.inner_rect.left_top())); + let with_toolbar = crate::mini_toolbar::context_menu_visible(app); + let menu = if with_toolbar { menu.frame(egui::Frame::NONE).close_behavior(egui::PopupCloseBehavior::IgnoreClicks) } else { menu }; + menu.show(|ui| { + if with_toolbar { + crate::mini_toolbar::show_in_context_menu(app, ui); + ui.add_space(4.0); + // The command panel keeps its own width, not the toolbar's: the popup's justified + // layout would stretch it, so it takes the width egui measures in its sizing pass. + let width_id = ui.scope_id().with("context_menu_width"); + let width = ui.data(|d| d.get_temp::(width_id)).filter(|_| !ui.is_sizing_pass()); + let size = egui::vec2(width.unwrap_or(ui.available_width()), ui.available_height()); + let rect = egui::Rect::from_min_size(ui.cursor().min, size); + let panel = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| context_menu(app, ui)).response.rect.width() + }); + if ui.is_sizing_pass() { + ui.data_mut(|d| d.insert_temp(width_id, panel.inner)); + } + } else { + context_menu(app, ui); + } + }); + // Escape closes the child list first; a second Escape closes the context menu. + if open && !shown && child_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + shown = true; + } + // The standalone selection toolbar stands down while the context popup owns the pointer. + app.canvas.context_menu_open = shown; + if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) { + ui.ctx().set_cursor_icon(c); + } else if resp.hovered() || app.canvas.obj_drag.is_some() { + let latest = ui.input(|i| i.pointer.latest_pos()); + let over_object = latest.and_then(|p| crate::objects::cursor(app, layout, &rects, geo.scale, p)); + // The selection bar: Word shows a right-pointing arrow there; egui has none, so the + // plain arrow stands in (#856). + let in_bar = app.session.sel.focus.story == StoryRef::Body + && (app.canvas.unit_drag.as_ref().is_some_and(|d| d.unit == "line") + || latest.and_then(|p| page_at(&rects, layout, geo.scale, p)).is_some_and(|(pg, x, y)| layout.selection_bar_at(pg, x, y).is_some())); + // An armed Format Painter shows over the text, as Word's brush does; egui has no brush, + // so the copy pointer stands in (#3453). The selection bar keeps its arrow. + let fallback = if in_bar { + egui::CursorIcon::Default + } else if app.session.painter.is_some() { + egui::CursorIcon::Copy + } else { + egui::CursorIcon::Text + }; + ui.ctx().set_cursor_icon(over_object.unwrap_or(fallback)); + } + crate::table_resize::cursor(app, ui, &resp, layout, &rects, geo.scale); + crate::table_mouse::cursor(app, ui, &resp, layout, &rects, geo.scale); + crate::table_pen::cursor(app, ui, &resp); + // Windows' I-beam is lost on white paper there: draw one that shows (#1746). + if app.canvas.drawn_ibeam + && (resp.hovered() || resp.dragged()) + && let Some(p) = ui.input(|i| i.pointer.latest_pos()) + { + let opts = page_render_options(app, dim_body); + let bg = if on_visible_page(&rects, &geo.bands, p) { + opts.paper_of(&app.session.doc) + } else { + let c = Tokens::get(ui.ctx()).canvas; + wordcraft_doc::props::Rgb(c.r(), c.g(), c.b()) + }; + crate::ibeam::replace(ui.ctx(), p, bg); + } + app.canvas.auto_scroll.paint(ui, area); + if let (Some(h), Some(v)) = (hruler, vruler) { + rulers(app, ui, h, v, &rects, layout, geo.scale); + } + crate::mini_toolbar::track_dismissal(app, ui.ctx(), area); + if let Some(sel) = app.canvas.mini_anchor + && app.ui.mini_toolbar + { + crate::mini_toolbar::show(app, ui.ctx(), sel, out.inner_rect); + } + if app.session.view.read_mode { + // Fixed navigation chrome, independent of the horizontally moving columns. + let step = app.session.view.read_width * app.canvas.scale + GAP; + let current = if step > 0.0 { (app.canvas.scroll_offset.x / step).round() as usize + 1 } else { 1 }; + let total = layout.pages.len(); + let footer = Rect::from_min_size(pos2(area.max.x - 260.0, area.max.y - 38.0), vec2(250.0, 32.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(footer), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.add_enabled(app.canvas.scroll_offset.x > 0.5, egui::Button::new("‹")).on_hover_text(tl!("Previous Page")).clicked() { + turn_read_page(app, -1.0); + } + ui.label(format!("{} / {}", current.min(total.max(1)), total)); + if ui + .add_enabled(app.canvas.scroll_offset.x + 0.5 < app.canvas.scroll_max.x, egui::Button::new("›")) + .on_hover_text(tl!("Next Page")) + .clicked() + { + turn_read_page(app, 1.0); + } + if ui.button(tl!("Edit Document")).clicked() { + let _ = app.run("view.printLayout", json!({})); + } + }); + }); + }); + } +} + +/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw +/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret +/// input. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let full = ui.available_rect_before_wrap(); + let layout = prepare_layout(app, ui, full); + let view = prepare_view(app, ui, full, &layout); + let scroll_target = update_caret_scroll(app, &view); + let motion = scroll_motion(app, ui, full, &view); + let scroll_area = configure_scroll_area(app, ui, &view, motion); + let CanvasView { area, hruler, vruler, dark_page, sepia_page, geo, dim_body, grid_story, caret, .. } = view; + let mut origin = area.min; + let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); let mut ime_geometry: Option<(Rect, Rect)> = None; let mut ime_interrupt = false; let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| { origin = ui.min_rect().min - viewport.min.to_vec2(); let content = Rect::from_min_size(ui.min_rect().min, geo.size); - // A fixed id: an automatic one follows how many widgets came before, which Read Mode's - // chrome changes, and a new id dropped the keyboard focus, so the arrows, Page Up/Down - // and Escape did nothing in Read Mode until the reader clicked a page (#1772). - // It also keeps the focus when Focus hides the rulers (#1678). - ui.advance_cursor_after_rect(content); - let resp = ui.interact(content, egui::Id::unique("wordcraft_canvas"), Sense::click_and_drag()); - if let Some(tr) = scroll_target { - ui.scroll_to_rect(Rect::from_min_size(ui.min_rect().min + tr.min.to_vec2(), tr.size()), None); - } + let resp = canvas_response(ui, content, scroll_target); // Read Mode's neighbouring columns end right at the viewport's edges: keep their // shadows from showing there as hairlines. let clip = if app.session.view.read_mode { ui.clip_rect().shrink2(vec2(3.0, 0.0)) } else { ui.clip_rect() }; let painter = ui.painter_at(clip); let ppp = ui.ctx().pixels_per_point(); let max_tex = ui.ctx().input(|i| i.max_texture_side).min(wordcraft_render::MAX_SIDE as usize).max(1) as f32; - let mut visible_tile_keys = HashSet::new(); - let mut rendered = 0; - let t0 = crate::now_ms(); - let mut rects = Vec::with_capacity(geo.rects.len()); let content_origin = ui.min_rect().min; - // The object a move has taken off its page: the page is drawn again without it (or it - // would be on the page where it was and under the pointer at once). let hidden = app.canvas.dragged_off_page(); - let view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p)); - for (i, pr) in geo.rects.iter().enumerate() { - let sr = pr.translate(content_origin.to_vec2()); - let Some(page) = layout.pages.get(i) else { continue }; - let (band_top, band_bot) = geo.bands.get(i).copied().unwrap_or((0.0, sr.height())); - // A Web/Draft page can be arbitrarily tall. Shrinking the whole page into one GPU - // texture makes the text blurry; split those pages into native-resolution tiles. - let width_scale = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).max(0.05); - let tiled = page.h.clamp(1.0, 1e6) * width_scale > max_tex; - let scale_px = if tiled { width_scale } else { width_scale.min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05) }; - let tile_height_px = TILE_HEIGHT_PX.min(max_tex as usize).max(1); - // Hide White Space shows only a band of the page; everything below paints into it. - let band_of = |r: Rect| Rect::from_min_max(pos2(r.min.x - 4.0, r.min.y + band_top), pos2(r.max.x + 4.0, r.min.y + band_bot)); - let visible = band_of(sr).intersects(ui.clip_rect().expand(200.0)); - // Only pages on screen or with a texture need their key (#811). - let cached = !tiled && app.canvas.textures.contains_key(&i); - let key = - if visible || cached { page_key(app, &layout, i, view, scale_px) ^ if tiled { (tile_height_px as u64) << 32 } else { 0 } } else { 0 }; - let mut fresh = cached && app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key); - // The page a drag took an object off: render it this frame whatever the budget, or the - // raster with the object still on it shows for a frame. - let forced = hidden.as_ref().is_some_and(|(page, _)| *page == i); - // Render before placing the page: a page re-rendered in this frame (a zoom step, an - // edit) then sits on the pixel grid in this frame too, rather than a frame later. - if visible && !tiled && !fresh && (forced || rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) { - let mut opts = page_render_options(app, dim_body); - // Only on the page it is on: the `Pos` can't match an object of another page. - opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); - let img = wordcraft_render::render_page(page_doc(app), page, scale_px, &opts); - let ci = page_image(&img); - match app.canvas.textures.get_mut(&i) { - Some((k, tex)) => { - tex.set(ci, egui::TextureOptions::LINEAR); - *k = key; - } - None => { - let tex = ui.ctx().load_texture(format!("page{i}"), ci, egui::TextureOptions::LINEAR); - app.canvas.textures.insert(i, (key, tex)); - } - } - rendered += 1; - fresh = true; - } else if visible && !tiled && !fresh { - ui.ctx().request_repaint(); - } - let current_scale = geo.scale * ppp; - let visual_sr = if fresh && (scale_px - current_scale).abs() <= 0.0001_f32.max(current_scale.abs() * 0.0001) { - app.canvas.textures.get(&i).map_or(sr, |(_, tex)| texel_aligned_rect(sr, tex.size_vec2(), ppp)) - } else { - sr - }; - rects.push(visual_sr); - if !visible { - continue; - } - let band = band_of(visual_sr); - let painter = if band_top <= 0.0 && band_bot >= sr.height() { - painter.clone() - } else { - painter.with_clip_rect(painter.clip_rect().intersect(band)) - }; - // Shadow and paper. - painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); - let paper = if dark_page { - Color32::from_gray(dark_paper) - } else if sepia_page { - let s = wordcraft_render::SEPIA_PAPER; - Color32::from_rgb(s.0, s.1, s.2) - } else { - Color32::WHITE - }; - painter.rect_filled(visual_sr, 0.0, paper); - if tiled { - // Render before painting to keep the frame sharp immediately after an edit or - // zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling. - app.canvas.textures.remove(&i); - let total_px = (page.h.clamp(1.0, 1e6) * scale_px).ceil() as usize; - let screen_per_raster_px = geo.scale / scale_px; - let base = pos2((sr.min.x * ppp).round() / ppp, (sr.min.y * ppp).round() / ppp); - for tile in visible_tiles(sr, ui.clip_rect().expand(200.0).intersect(band), geo.scale, scale_px, tile_height_px) { - let start_px = tile.saturating_mul(tile_height_px); - let height_px = total_px.saturating_sub(start_px).min(tile_height_px); - if height_px == 0 { - continue; - } - let id = (i, tile); - visible_tile_keys.insert(id); - let fresh = app.canvas.tiles.get(&id).is_some_and(|(k, _)| *k == key); - if !fresh && rendered < 4 { - let mut opts = page_render_options(app, dim_body); - // Only on the page it is on: the `Pos` can't match an object of another page. - opts.display.hide_object = hidden.as_ref().filter(|(page, _)| *page == i).map(|(_, p)| p.clone()); - // The tiny subtraction avoids a 1px overshoot from round-tripping f32 - // pixels through page points and render_area's ceil() conversion. - let img = wordcraft_render::render_area( - page_doc(app), - page, - 0.0, - start_px as f32 / scale_px, - page.w, - (height_px as f32 - 0.01).max(0.01) / scale_px, - scale_px, - &opts, - ); - let ci = page_image(&img); - match app.canvas.tiles.get_mut(&id) { - Some((k, tex)) => { - tex.set(ci, egui::TextureOptions::LINEAR); - *k = key; - } - None => { - let tex = ui.ctx().load_texture(format!("page{i}-tile{tile}"), ci, egui::TextureOptions::LINEAR); - app.canvas.tiles.insert(id, (key, tex)); - } - } - rendered += 1; - } else if !fresh { - ui.ctx().request_repaint(); - } - if let Some((_, tex)) = app.canvas.tiles.get(&id) { - let rect = Rect::from_min_size( - pos2(base.x, base.y + start_px as f32 * screen_per_raster_px), - vec2(tex.size_vec2().x * screen_per_raster_px, height_px as f32 * screen_per_raster_px), - ); - painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - } - } else if let Some((_, tex)) = app.canvas.textures.get(&i) { - painter.image(tex.id(), visual_sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - let page_scale = page_screen_scale(visual_sr, page, geo.scale); - // Header/footer editing chrome. - if let StoryRef::Part(id) = app.session.sel.focus.story { - let sect_body = page.body; - if page.header_story == Some(id) || page.footer_story == Some(id) { - let hy = visual_sr.min.y + sect_body.y * page_scale - 4.0; - let fy = visual_sr.min.y + sect_body.bottom() * page_scale + 4.0; - for (y, label) in [(hy, "Header"), (fy, "Footer")] { - dashed(&painter, pos2(visual_sr.min.x, y), pos2(visual_sr.max.x, y), Stroke::new(1.0, t.accent)); - let tr = Rect::from_min_size(pos2(visual_sr.min.x + 2.0, if label == "Header" { y } else { y - 18.0 }), vec2(52.0, 18.0)); - painter.rect_filled(tr, 2.0, t.checked); - painter.text(tr.center(), egui::Align2::CENTER_CENTER, tl!(label), regular(11.0), t.accent_text); - } - } - } - // View › Gridlines: the drawing grid over the text area (Print Layout, on screen only). - if app.session.view.gridlines && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { - let settings = &app.session.doc.settings; - // A section with a document grid (`w:docGrid`) shows that grid: lines at the line - // pitch and, for a character grid, columns at the character pitch. - let sections = app.session.doc.sections(); - let sect = sections.get(page.section).map(|(_, s)| *s); - let step = match sect.and_then(|s| s.grid_line_pitch().map(|lp| (s, lp))) { - Some((s, lp)) => { - let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size; - let cp = s.grid_char_pitch(size).map_or(f32::INFINITY, |cp| drawing_grid_step(cp, page_scale)); - (cp, drawing_grid_step(lp, page_scale)) - } - None => (drawing_grid_step(settings.grid_h, page_scale), drawing_grid_step(settings.grid_v, page_scale)), - }; - let (xs, ys) = drawing_grid(page.body, step); - let stroke = Stroke::new(1.0, t.blue.linear_multiply(0.22)); - let (x0, x1) = (visual_sr.min.x + page.body.x * page_scale, visual_sr.min.x + page.body.right() * page_scale); - let (y0, y1) = (visual_sr.min.y + page.body.y * page_scale, visual_sr.min.y + page.body.bottom() * page_scale); - for x in xs { - let sx = visual_sr.min.x + x * page_scale; - painter.line_segment([pos2(sx, y0), pos2(sx, y1)], stroke); - } - for y in ys { - let sy = visual_sr.min.y + y * page_scale; - painter.line_segment([pos2(x0, sy), pos2(x1, sy)], stroke); - } - } - // The current header/footer's cells have editing guides; explicit View Gridlines - // still outlines every table. Neither kind of guide is part of the document. - if app.session.view.table_gridlines || grid_story.is_some() { - for rect in gridline_cells(page, app.session.view.table_gridlines, grid_story) { - let r = Rect::from_min_size( - pos2(visual_sr.min.x + rect.x * page_scale, visual_sr.min.y + rect.y * page_scale), - vec2(rect.w * page_scale, rect.h * page_scale), - ); - painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle); - } - } - } - // Hide White Space: a thin line where one page ends and the next begins. - for (i, pair) in rects.windows(2).enumerate() { - let Some(&(_, bot)) = geo.bands.get(i) else { continue }; - if white_space_hidden(&geo.rects, &geo.bands) { - let y = pair[0].min.y + bot; - painter.rect_filled(Rect::from_min_max(pos2(pair[0].min.x, y), pos2(pair[0].max.x, y + SEAM)), 0.0, t.border_strong); - } - } + let page_view = view_key(app, dim_body, hidden.as_ref().map(|(_, p)| p)); + let t0 = crate::now_ms(); + let PagePaintOutput { rects, visible_tile_keys, rendered } = paint_pages(PagePaint { + app, + ui, + layout: &layout, + geo: &geo, + t: &t, + painter: &painter, + dim_body, + dark_page, + sepia_page, + hidden, + view: page_view, + content_origin, + ppp, + max_tex, + grid_story, + }); + paint_white_space_seams(&painter, &rects, &geo, &t); if rendered > 0 { app.canvas.render_ms = crate::now_ms() - t0; } @@ -1019,17 +1433,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip); // Content controls: the frame of the one the caret is in (all of them in Design Mode). crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale); - // Read Aloud: the sentence being spoken. - if let Some((a, b)) = crate::read_aloud::highlight(app) { - for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) { - if let Some(pr) = rects.get(pi) { - let sr = - Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale)); - painter.rect_filled(sr, 0.0, t.accent.gamma_multiply(0.16)); - painter.hline(sr.x_range(), sr.max.y - 0.5, egui::Stroke::new(1.5, t.accent)); - } - } - } + paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t); // Caret. let focused = resp.has_focus() || app.canvas.focused; // After an automatic change the web's text agent starts afresh @@ -1086,9 +1490,20 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let since = crate::now_ms() - app.canvas.caret_visible_since; // The caret glides and fades like Word's (`caret_glide`). let on = ((since / 530.0) as u64).is_multiple_of(2); - let look = crate::caret_glide::look(app, ui, content_origin, &geo, [pos2(x, y0), pos2(x, y1)], focused, since, on); + let look = crate::caret_glide::look( + app, + ui, + crate::caret_glide::LookRequest { + content_origin, + geometry: &geo, + target: [pos2(x, y0), pos2(x, y1)], + shown: focused, + since_ms: since, + on, + }, + ); if look.opacity > 0.0 && focused { - let caret_color = caret_color_at(app, &layout, pi, ex + slot.x, base - slot.y + (slot.d - slot.a) / 2.0, view, dim_body); + let caret_color = caret_color_at(app, &layout, pi, ex + slot.x, base - slot.y + (slot.d - slot.a) / 2.0, page_view, dim_body); painter.line_segment(look.bar, Stroke::new(1.5, caret_color.gamma_multiply(look.opacity))); } if focused { @@ -1110,7 +1525,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let on = ((since / 530.0) as u64).is_multiple_of(2); // The caret contrasts with what lies under it, like Word's inverted caret (#299): // worked out only when something is drawn in it. - let caret_color = |app: &mut WordApp| caret_color_at(app, &layout, c.page, c.x + c.width / 2.0, c.top + c.height / 2.0, view, dim_body); + let caret_color = + |app: &mut WordApp| caret_color_at(app, &layout, c.page, c.x + c.width / 2.0, c.top + c.height / 2.0, page_view, dim_body); // An input method's composition is drawn from the caret, and the caret and the candidate // window move to the input method's cursor inside it. let mut preedit_color = None; @@ -1153,7 +1569,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; // The caret glides and fades like Word's (`caret_glide`). let shown = preview_sel(app).unwrap_or(&app.session.sel).is_collapsed() && focused; - let look = crate::caret_glide::look(app, ui, content_origin, &geo, bar, shown, since, on); + let look = crate::caret_glide::look( + app, + ui, + crate::caret_glide::LookRequest { content_origin, geometry: &geo, target: bar, shown, since_ms: since, on }, + ); if shown && look.opacity > 0.0 { let color = preedit_color.unwrap_or_else(|| caret_color(app)); painter.line_segment(look.bar, Stroke::new(1.5, color.gamma_multiply(look.opacity))); @@ -1175,275 +1595,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); - app.canvas.scroll_offset = out.state.clamped_offset(); - scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len()); - // The page the status bar names (#3880): the caret's, or a whole-story selection's page, while - // it is on screen; else the page at the top of the window. - app.canvas.viewed_page = (!app.session.view.read_mode && !geo.rects.is_empty()).then(|| { - let extent = if app.session.selects_whole_story() { - let page = app.session.page_hint.min(geo.rects.len() - 1); - geo.rects.get(page).map(|r| (page, r.top(), r.bottom())) - } else { - caret.and_then(|c| geo.rects.get(c.page).map(|r| (c.page, r.top() + c.top * geo.scale, r.top() + (c.top + c.height) * geo.scale))) - }; - viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1)) - }); - crate::ink::replay_bar(app, ui, out.inner_rect); - app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); - if app.session.view.read_mode { - let step = app.session.view.read_width * geo.scale + GAP; - if step.is_finite() && step > 0.0 { - let page = (app.canvas.scroll_offset.x / step).round().max(0.0) as usize; - app.canvas.read_first_visible = reading_page_start(&layout, page.min(layout.pages.len().saturating_sub(1))); - } - } - // The viewport barely moves between frames; the auto-scroll decides this frame's scroll before - // the scroll area is built, so it reads last frame's viewport. - app.canvas.viewport = Some(out.inner_rect); - let (resp, rects) = out.inner; - // A click on the page leaves the selected comment balloon. - if app.canvas.balloon.is_some() - && ui.input(|i| i.pointer.any_pressed()) - && resp.contains_pointer() - && !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| over_balloon(app, p)) - { - deselect_balloon(app); - } - // Space and Enter also set `Response::clicked` on a focused click-sensing widget - // (egui's fake primary click). Re-requesting an already focused widget no longer - // interrupts IME in 0.37. A pointer - // click, an accessibility click, or a drag still takes focus, including a click on - // a canvas that already has it, so a composition commits. `want_focus` focuses only - // while the canvas does not, and does not interrupt an editor that already has it. - let keyboard_click = ui.memory(|m| m.has_focus(resp.id)) && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter)); - let pointer_click = resp.clicked_by(egui::PointerButton::Primary); - let accesskit_click = resp.clicked() && !pointer_click && !keyboard_click; - let focus_click = pointer_click || accesskit_click || resp.drag_started(); - if focus_click { - ui.memory_mut(|memory| memory.interrupt_ime()); - } - if focus_click || (app.canvas.want_focus && !resp.has_focus()) { - resp.request_focus(); - } - if focus_click || app.canvas.want_focus { - app.canvas.want_focus = false; - } - app.canvas.widget_id = Some((resp.id, ui.ctx().cumulative_pass_nr())); - if resp.has_focus() { - ui.memory_mut(|m| { - m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); - } - app.canvas.focused = resp.has_focus(); - // One observation per viewport pass, after every event and command for the pass has been - // applied: the owner, generation, bounded surrounding text and change cause all come from - // this same settled state. - crate::keys::observe_ime(app, ui.ctx(), ime_geometry, ime_interrupt); - // Screen readers: the document under the canvas, with the caret (#488). - crate::a11y::expose(app, ui.ctx(), resp.id, &layout, &rects, area); - // The buttons that start or stop middle-click scrolling don't also click the page. - let middle_gesture = app.canvas.auto_scroll.blocks_input(); - // Double-click the space between two pages (or the line between them) to hide or show it. - if resp.double_clicked() - && app.session.view.mode == wordcraft_layout::ViewMode::Print - && !app.session.view.read_mode - && let Some(p) = resp.interact_pointer_pos() - && on_page_gap(&rects, &geo.bands, p) - { - let _ = app.run("view.hideWhiteSpace", json!({"value": !app.session.view.hide_white_space})); - } else - // Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing. - if !middle_gesture && !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) { - mouse(app, ui, &resp, &rects, &layout, geo.scale); - } - // A click on a check box ticks it; on a list's button opens the list. - if !middle_gesture { - crate::content_controls::clicks(app, &resp); - } - let secondary_clicked = resp.secondary_clicked() && !middle_gesture; - // Selected table cells: a right-click off them drops them, even between the selection's ends - // (beside a selected column), so the menu acts on the cell clicked. - if secondary_clicked - && let Some(range) = app.session.cell_range() - && let Some(p) = resp.interact_pointer_pos() - && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) - { - let at = wordcraft_geom::Point::new(x, y); - let on_cells = - wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range).iter().any(|(pi, r)| *pi == page && r.contains(at)); - if !on_cells && let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) { - app.session.sel = wordcraft_engine::Selection::caret(pos); - } - } - // Right-click: move the caret there (unless inside the selection), then the context menu. - // Right-click in an equation puts the caret there and opens the equation menu. - if secondary_clicked - && let Some(p) = resp.interact_pointer_pos() - && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) - { - match layout.equation_hit(page, x, y, app.session.sel.focus.story) { - Some((at, inner)) => { - app.session.sel = wordcraft_engine::Selection::caret(at); - let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); - } - None if app.session.math.is_some() => { - let _ = app.run("equation.exit", json!({})); - } - None => {} - } - } - // Right-click on a picture, shape or text box selects it (its menu offers Wrap Text). - if secondary_clicked - && app.session.math.is_none() - && let Some(p) = resp.interact_pointer_pos() - && let Some((page, x, y)) = page_at(&rects, &layout, geo.scale, p) - { - if crate::objects::select_at(app, &layout, &rects, geo.scale, p) { - app.canvas.context_issue = None; - app.canvas.context_synonyms = None; - } else if let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) { - let (a, b) = app.session.sel.ordered(); - if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() { - app.session.sel = wordcraft_engine::Selection::caret(pos); - } - context_suggestions(app); - } - } - // Shift+F10 opens the same menu below the caret, for the selection or the word there (#821). - let from_keys = std::mem::take(&mut app.canvas.context_menu_from_keys); - // A click on the equation frame's options strip opens the equation's menu below the strip. - let eq_strip = app.canvas.eq_options.map(|f| f.strip).filter(|r| resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| r.contains(p))); - if from_keys { - context_suggestions(app); - app.canvas.context_menu_at = caret_screen(app).map(|(p, h)| p + egui::vec2(0.0, h)); - } else if let Some(r) = eq_strip { - app.canvas.context_menu_at = Some(r.left_bottom()); - } else if secondary_clicked { - app.canvas.context_menu_at = resp.interact_pointer_pos(); - } - // Keep the parent independently of egui's one memory popup: the toolbar's font, size and - // colour controls use that slot for their own lists (as in a collapsed ribbon group). - let mut open = app.canvas.context_menu_open; - if secondary_clicked || from_keys || eq_strip.is_some() { - egui::Popup::close_all(ui.ctx()); - open = true; - } else if open && ui.input(|i| i.pointer.any_click()) { - let layer = ui.input(|i| i.pointer.interact_pos()).and_then(|p| ui.ctx().layer_id_at(p)); - if !layer.is_some_and(|l| matches!(l.order, egui::Order::Foreground | egui::Order::Tooltip)) { - open = false; - } - } - let child_open = egui::Popup::is_any_open(ui.ctx()); - let mut shown = open; - if open { - app.escape_free = false; - } - let menu = egui::Popup::menu(&resp).open_bool(&mut shown); - let menu = menu.at_position(app.canvas.context_menu_at.unwrap_or(out.inner_rect.left_top())); - let with_toolbar = crate::mini_toolbar::context_menu_visible(app); - let menu = if with_toolbar { menu.frame(egui::Frame::NONE).close_behavior(egui::PopupCloseBehavior::IgnoreClicks) } else { menu }; - menu.show(|ui| { - if with_toolbar { - crate::mini_toolbar::show_in_context_menu(app, ui); - ui.add_space(4.0); - // The command panel keeps its own width, not the toolbar's: the popup's justified - // layout would stretch it, so it takes the width egui measures in its sizing pass. - let width_id = ui.scope_id().with("context_menu_width"); - let width = ui.data(|d| d.get_temp::(width_id)).filter(|_| !ui.is_sizing_pass()); - let size = egui::vec2(width.unwrap_or(ui.available_width()), ui.available_height()); - let rect = egui::Rect::from_min_size(ui.cursor().min, size); - let panel = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| context_menu(app, ui)).response.rect.width() - }); - if ui.is_sizing_pass() { - ui.data_mut(|d| d.insert_temp(width_id, panel.inner)); - } - } else { - context_menu(app, ui); - } - }); - // Escape closes the child list first; a second Escape closes the context menu. - if open && !shown && child_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - shown = true; - } - // The standalone selection toolbar stands down while the context popup owns the pointer. - app.canvas.context_menu_open = shown; - if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) { - ui.ctx().set_cursor_icon(c); - } else if resp.hovered() || app.canvas.obj_drag.is_some() { - let latest = ui.input(|i| i.pointer.latest_pos()); - let over_object = latest.and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p)); - // The selection bar: Word shows a right-pointing arrow there; egui has none, so the - // plain arrow stands in (#856). - let in_bar = app.session.sel.focus.story == StoryRef::Body - && (app.canvas.unit_drag.as_ref().is_some_and(|d| d.unit == "line") - || latest.and_then(|p| page_at(&rects, &layout, geo.scale, p)).is_some_and(|(pg, x, y)| layout.selection_bar_at(pg, x, y).is_some())); - // An armed Format Painter shows over the text, as Word's brush does; egui has no brush, - // so the copy pointer stands in (#3453). The selection bar keeps its arrow. - let fallback = if in_bar { - egui::CursorIcon::Default - } else if app.session.painter.is_some() { - egui::CursorIcon::Copy - } else { - egui::CursorIcon::Text - }; - ui.ctx().set_cursor_icon(over_object.unwrap_or(fallback)); - } - crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale); - crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale); - crate::table_pen::cursor(app, ui, &resp); - // Windows' I-beam is lost on white paper there: draw one that shows (#1746). - if app.canvas.drawn_ibeam - && (resp.hovered() || resp.dragged()) - && let Some(p) = ui.input(|i| i.pointer.latest_pos()) - { - let opts = page_render_options(app, dim_body); - let bg = if on_visible_page(&rects, &geo.bands, p) { - opts.paper_of(&app.session.doc) - } else { - let c = Tokens::get(ui.ctx()).canvas; - wordcraft_doc::props::Rgb(c.r(), c.g(), c.b()) - }; - crate::ibeam::replace(ui.ctx(), p, bg); - } - app.canvas.auto_scroll.paint(ui, area); - let _ = origin; - if let (Some(h), Some(v)) = (hruler, vruler) { - rulers(app, ui, h, v, &rects, &layout, geo.scale); - } - crate::mini_toolbar::track_dismissal(app, ui.ctx(), area); - if let Some(sel) = app.canvas.mini_anchor - && app.ui.mini_toolbar - { - crate::mini_toolbar::show(app, ui.ctx(), sel, out.inner_rect); - } - if app.session.view.read_mode { - // Fixed navigation chrome, independent of the horizontally moving columns. - let step = app.session.view.read_width * app.canvas.scale + GAP; - let current = if step > 0.0 { (app.canvas.scroll_offset.x / step).round() as usize + 1 } else { 1 }; - let total = layout.pages.len(); - let footer = Rect::from_min_size(pos2(area.max.x - 260.0, area.max.y - 38.0), vec2(250.0, 32.0)); - ui.scope_builder(egui::UiBuilder::new().max_rect(footer), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { - if ui.add_enabled(app.canvas.scroll_offset.x > 0.5, egui::Button::new("‹")).on_hover_text(tl!("Previous Page")).clicked() { - turn_read_page(app, -1.0); - } - ui.label(format!("{} / {}", current.min(total.max(1)), total)); - if ui - .add_enabled(app.canvas.scroll_offset.x + 0.5 < app.canvas.scroll_max.x, egui::Button::new("›")) - .on_hover_text(tl!("Next Page")) - .clicked() - { - turn_read_page(app, 1.0); - } - if ui.button(tl!("Edit Document")).clicked() { - let _ = app.run("view.printLayout", json!({})); - } - }); - }); - }); - } + finish_canvas_frame( + CanvasFinish { app, ui, layout: &layout, geo: &geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler }, + out, + ); } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/balloons.rs b/crates/ui-egui/src/canvas/balloons.rs index 1275c37e2..445bacd15 100644 --- a/crates/ui-egui/src/canvas/balloons.rs +++ b/crates/ui-egui/src/canvas/balloons.rs @@ -1,6 +1,9 @@ use super::geometry::{FLOAT_WIDTH, floating_comments, markup_width, shown_comment}; use super::pages::page_screen_scale; -use super::*; +use super::{ + Color32, CommentDisplay, DocLayout, HashMap, PX_PER_PT, Pos, Pos2, Rect, Sense, StoryRef, Stroke, Tokens, Ui, WordApp, json, pos2, regular, + semibold, vec2, +}; /// Comment balloons in the markup area right of each page, joined to their anchors, with each /// comment's replies under it. Clicking a balloon selects it, like Word: its text becomes editable @@ -19,49 +22,7 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, let clip = view; let only_shown = app.session.prefs.markup.comment_display == CommentDisplay::Selected; let shown = shown_comment(app); - // Replies hang under the comment they answer rather than getting balloons of their own. - let is_reply = |c: &wordcraft_doc::Comment| c.parent.is_some_and(|p| app.session.doc.comments.contains_key(&p)); - let mut replies: HashMap> = HashMap::new(); - for (id, c) in &app.session.doc.comments { - if let Some(p) = c.parent.filter(|_| is_reply(c)) { - replies.entry(p).or_default().push(*id); - } - } - // (page, anchor x, anchor y, item) for each anchored, unresolved-or-not comment, each - // tracked formatting change (Word's default shows those in balloons too) and, when revisions - // go in balloons, each tracked deletion. - let mut by_page: HashMap> = HashMap::new(); - let m = &app.session.prefs.markup; - let comments = if m.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() }; - for (id, pos) in comments { - let Some(pos) = pos else { continue }; - if app.session.doc.comments.get(&id).is_some_and(is_reply) || (only_shown && shown != Some(id)) { - continue; - } - let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; - by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Comment(id), pos)); - } - let formats = if m.formatting { wordcraft_engine::cmd::review::format_changes(&app.session) } else { Vec::new() }; - for (pos, author, props) in formats.into_iter().take(500) { - let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; - let what = crate::panes::describe_props(&props).join(", "); - let text = if what.is_empty() { tl!("Formatted").to_string() } else { format!("{}: {what}", tl!("Formatted")) }; - by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos)); - } - // Each end of a tracked move. - let moves = if m.insertions_deletions { wordcraft_engine::cmd::review::move_changes(&app.session) } else { Vec::new() }; - for (pos, author, from) in moves.into_iter().take(500) { - let Some(c) = layout.caret_on(&pos, app.session.page_hint) else { continue }; - let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string(); - by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(author, text), pos)); - } - // Show revisions in balloons: each tracked deletion, taken out of the text, at its place. - let deletions = if app.session.revisions_in_balloons() { wordcraft_engine::cmd::review::deletion_balloons(&app.session) } else { Vec::new() }; - for d in deletions { - let Some(c) = layout.caret_on(&d.at, app.session.page_hint) else { continue }; - let text = format!("{}: {}", tl!("Deleted"), d.text); - by_page.entry(c.page).or_default().push((c.x, c.top + c.height, Balloon::Format(d.author, text), d.at)); - } + let BalloonItems { replies, mut by_page } = collect_items(app, layout, only_shown, shown); let palette = t.author_colors; let leaders = t.leader_colors; let mut authors: Vec = Vec::new(); @@ -179,7 +140,7 @@ pub(super) fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead); } let card = if active { - active_balloon(app, ui, painter, *id, &thread, card, fs, color) + active_balloon(app, ui, painter, ActiveBalloon { id: *id, thread: &thread, card, font_size: fs, color }) } else { card_shapes(painter, &t, card, color, selected, None); let text_painter = painter.with_clip_rect(card.intersect(clip)); @@ -221,6 +182,66 @@ enum Balloon { Format(String, String), } +struct BalloonItems { + replies: HashMap>, + by_page: HashMap>, +} + +fn collect_items(app: &WordApp, layout: &DocLayout, only_shown: bool, shown: Option) -> BalloonItems { + let comments_by_id = &app.session.doc.comments; + // Replies hang under the comment they answer rather than getting balloons of their own. + let is_reply = |comment: &wordcraft_doc::Comment| comment.parent.is_some_and(|parent| comments_by_id.contains_key(&parent)); + let mut replies: HashMap> = HashMap::new(); + for (id, comment) in comments_by_id { + if let Some(parent) = comment.parent.filter(|_| is_reply(comment)) { + replies.entry(parent).or_default().push(*id); + } + } + let mut by_page: HashMap> = HashMap::new(); + let markup = &app.session.prefs.markup; + let comments = if markup.comments { wordcraft_engine::cmd::review::comment_list(&app.session) } else { Vec::new() }; + for (id, pos) in comments { + let Some(pos) = pos else { continue }; + if comments_by_id.get(&id).is_some_and(is_reply) || (only_shown && shown != Some(id)) { + continue; + } + add_balloon_anchor(&mut by_page, layout, app.session.page_hint, pos, Balloon::Comment(id)); + } + if markup.formatting { + for (pos, author, props) in wordcraft_engine::cmd::review::format_changes(&app.session).into_iter().take(500) { + let what = crate::panes::describe_props(&props).join(", "); + let text = if what.is_empty() { tl!("Formatted").to_string() } else { format!("{}: {what}", tl!("Formatted")) }; + add_balloon_anchor(&mut by_page, layout, app.session.page_hint, pos, Balloon::Format(author, text)); + } + } + if markup.insertions_deletions { + for (pos, author, from) in wordcraft_engine::cmd::review::move_changes(&app.session).into_iter().take(500) { + let text = if from { tl!("Moved from") } else { tl!("Moved to") }.to_string(); + add_balloon_anchor(&mut by_page, layout, app.session.page_hint, pos, Balloon::Format(author, text)); + } + } + if app.session.revisions_in_balloons() { + for deletion in wordcraft_engine::cmd::review::deletion_balloons(&app.session) { + let text = format!("{}: {}", tl!("Deleted"), deletion.text); + add_balloon_anchor(&mut by_page, layout, app.session.page_hint, deletion.at, Balloon::Format(deletion.author, text)); + } + } + BalloonItems { replies, by_page } +} + +fn add_balloon_anchor(by_page: &mut HashMap>, layout: &DocLayout, page_hint: usize, pos: Pos, item: Balloon) { + let Some(caret) = layout.caret_on(&pos, page_hint) else { return }; + by_page.entry(caret.page).or_default().push((caret.x, caret.top + caret.height, item, pos)); +} + +struct ActiveBalloon<'a> { + id: u32, + thread: &'a [u32], + card: Rect, + font_size: f32, + color: Color32, +} + fn author_name(a: &str) -> String { if a.is_empty() { tl!("Author").to_string() } else { a.to_string() } } @@ -259,8 +280,8 @@ pub(super) fn balloon_text(painter: &egui::Painter, text: String, font: egui::Fo /// editable fields (each edit is one undo step via `review.editComment`), an optional reply /// field, and Reply / Resolve / Delete. /// `card` carries last frame's size; returns this frame's. -#[allow(clippy::too_many_arguments)] -fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, id: u32, thread: &[u32], card: Rect, fs: f32, color: Color32) -> Rect { +fn active_balloon(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, balloon: ActiveBalloon<'_>) -> Rect { + let ActiveBalloon { id, thread, card, font_size: fs, color } = balloon; let t = Tokens::get(ui.ctx()); let slot = painter.add(egui::Shape::Noop); // The card's own click area sits under its fields, so a click on its padding stays here. diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs index 4341dd473..04235290f 100644 --- a/crates/ui-egui/src/canvas/context_menu.rs +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Ui, WordApp, json}; /// The spelling suggestions and synonyms the context menu offers for the selection or the word /// at the caret. diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index 7069025ed..0f1f43786 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -1,7 +1,7 @@ use super::navigation::{page_at, page_to_screen}; use super::pages::{page_render_options, page_screen_scale}; use super::story::dims_body; -use super::*; +use super::{Color32, DocLayout, Hash, Hasher, Pos, Pos2, WordApp}; /// Caret position on screen. pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> { diff --git a/crates/ui-egui/src/canvas/geometry.rs b/crates/ui-egui/src/canvas/geometry.rs index 95d22c8e7..589f0d3bd 100644 --- a/crates/ui-egui/src/canvas/geometry.rs +++ b/crates/ui-egui/src/canvas/geometry.rs @@ -1,5 +1,5 @@ use super::reading::place_read_pages; -use super::*; +use super::{CommentDisplay, DocLayout, GAP, PX_PER_PT, Pos2, Rect, StoryRef, WordApp, pos2, vec2}; /// Page positions in content space (points), for the current zoom. pub struct Geometry { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index a21b6cfe9..56ab7cf98 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -2,7 +2,7 @@ use super::balloons::over_balloon; use super::links::{field_showing_code, is_followable_link, is_generated_bookmark_link, link_under}; use super::navigation::page_at; use super::story::{enters_header_footer, in_text_box, part_kind, text_box_host}; -use super::*; +use super::{CanvasState, DocLayout, PartKind, Pos, Pos2, Rect, StoryRef, Ui, WordApp, json}; /// Drag-select auto-scroll (#688): the pointer within this many points of the viewport's edge /// starts scrolling, like Word. @@ -86,9 +86,29 @@ pub(super) fn clamped_scroll(before: egui::Vec2, delta: egui::Vec2, max: egui::V /// handles, then page hit testing, outlines, headers and footers, equations, selection and caret /// interaction. pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) { + let Some(pointer) = route_pointer_targets(app, ui, resp, rects, layout, scale) else { + return; + }; + // While drag-selecting, the pointer may sit past the viewport's edge (or the window's): + // hit-test where it would be inside the view, so as the edge auto-scroll moves the content the + // selection keeps growing under a stationary pointer (#688). + let p = if app.canvas.dragging + && ui.input(|i| i.pointer.primary_down()) + && let Some(vp) = app.canvas.viewport + { + vp.clamp(pointer) + } else { + pointer + }; + let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return }; + let story = app.session.sel.focus.story; + handle_page_pointer(app, PagePointer { ui, resp, layout, p, page, x, y, story }); +} + +fn route_pointer_targets(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> Option { // A pen or the eraser (Draw tab) owns the pointer. if crate::ink::pointer(app, ui, resp, rects, layout, scale) { - return; + return None; } let pointer = resp.interact_pointer_pos().or_else(|| resp.hover_pos()); // An object (or crop handle) drag follows the pointer anywhere until it's released. @@ -97,109 +117,47 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ if let Some(at) = object_pointer && crate::objects::pointer(app, ui, resp, rects, layout, scale, at) { - return; + return None; } // A press on a table border (or the table's resize handle) drags it instead of the caret. if crate::table_resize::pointer(app, ui, resp, rects, layout, scale) { - return; + return None; } // A press on a table's selection area, insert button or move handle. if crate::table_mouse::pointer(app, ui, resp, rects, layout, scale) { - return; + return None; } - let Some(p) = pointer else { return }; - // While drag-selecting, the pointer may sit past the viewport's edge (or the window's): - // hit-test where it would be inside the view, so as the edge auto-scroll moves the content the - // selection keeps growing under a stationary pointer (#688). - let p = if app.canvas.dragging - && ui.input(|i| i.pointer.primary_down()) - && let Some(vp) = app.canvas.viewport - { - vp.clamp(p) - } else { - p - }; - let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return }; + pointer +} + +#[derive(Clone, Copy)] +struct PagePointer<'a> { + ui: &'a Ui, + resp: &'a egui::Response, + layout: &'a DocLayout, + p: Pos2, + page: usize, + x: f32, + y: f32, + story: StoryRef, +} + +fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) { + let PagePointer { ui, resp, layout, p, page, x, y, story, .. } = pointer; let mods = ui.input(|i| i.modifiers); - let story = app.session.sel.focus.story; - // Inside an equation, clicks place the caret in it (and double-clicks don't select words); - // so do clicks on the grip and options strip of its frame. + if skip_equation_or_outline(app, &pointer) { + return; + } let on_frame = app.canvas.eq_options.is_some_and(|f| f.strip.contains(p) || f.grip.contains(p)); - if (resp.double_clicked() || resp.triple_clicked()) && (on_frame || layout.equation_hit(page, x, y, story).is_some()) { + if handle_completed_click(app, &pointer) { return; } - // Outline view: a paragraph's symbol selects it with what is under it; a double-click - // expands or collapses the heading. - if app.session.view.mode == wordcraft_layout::ViewMode::Outline - && let Some(block) = layout.outline_symbol_at(page, x, y) - { - if resp.double_clicked() { - let _ = app.run("outline.toggle", json!({ "block": block })); - } else if ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() { - let _ = app.run("outline.selectSubtree", json!({ "block": block })); - app.session.page_hint = page; - } - return; - } - // The press already selected the word or paragraph (and a drag may have grown it). - let released_clicks = if resp.triple_clicked() { - 3 - } else if resp.double_clicked() { - 2 - } else { - 0 - }; - if released_clicks > 0 && app.canvas.unit_drag.as_ref().is_some_and(|d| d.clicks == released_clicks) { - app.canvas.unit_drag = None; - app.canvas.dragging = false; - return; - } - // Double-click in the header/footer area edits it (creating it when the page has none, #960); - // double-click in the body leaves it. Print Layout only: the other views show no margins. let hf_zone = if app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { layout.header_footer_zone(page, y) } else { None }; let hf_enter = enters_header_footer(app, story, hf_zone); - if resp.double_clicked() { - // Its first press already moved into the header/footer and placed the caret. - if app.canvas.hf_press_switched { - return; - } - if let Some((_, header)) = hf_zone - && hf_enter - { - let id = if header { "insert.editHeader" } else { "insert.editFooter" }; - if app.run(id, json!({ "page": page })).is_ok() - && let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) - { - app.session.sel = wordcraft_engine::Selection::caret(pos); - } - return; - } - if matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none() { - let _ = app.run("insert.closeHeader", json!({})); - if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) { - app.session.sel = wordcraft_engine::Selection::caret(pos); - } - return; - } - // Double-click a field showing its code: edit the code. - if let Some(pos) = layout.hit(page, x, y, story) - && let Some(field) = field_showing_code(app, &pos) - { - app.session.sel = wordcraft_engine::Selection::caret(field); - let _ = app.run("fields.setCode", json!({})); - return; - } - let _ = app.run("select.word", json!({})); - return; - } - if resp.triple_clicked() { - let _ = app.run("select.paragraph", json!({"cell": true})); - return; - } let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() && !over_balloon(app, p); // The equation frame's grip and options strip stay in the equation (the strip's click opens // its menu, in `show`). @@ -207,154 +165,7 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ return; } if pressed { - let now = ui.input(|i| i.time); - let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let clicks = press_count(app.canvas.last_press, now, p, delay, dist); - app.canvas.last_press = Some((now, p, clicks)); - app.canvas.unit_drag = None; - if clicks <= 1 { - app.canvas.hf_press_switched = false; - } - // A double-click that enters or leaves a header/footer is handled on release. - let hf_switch = hf_enter || app.canvas.hf_press_switched || (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none()); - // Clicking into a footnote/endnote or a text box edits it; clicking outside a text box goes - // back to where it is. While editing a header/footer, only its own text boxes are in reach. - let story = { - let is_note = |s: StoryRef| matches!(part_kind(app, s), Some(PartKind::Footnote | PartKind::Endnote)); - let is_box = |s: StoryRef| part_kind(app, s) == Some(PartKind::TextBox); - let host = if is_box(story) { text_box_host(app, layout).unwrap_or(StoryRef::Body) } else { story }; - let in_hf = matches!(part_kind(app, host), Some(PartKind::Header | PartKind::Footer)); - let hf_box = if in_hf { layout.header_footer_text_box_at(page, x, y).map(StoryRef::Part) } else { None }; - match (hf_box, layout.story_at(page, x, y)) { - (Some(b), _) => b, - (None, Some(s)) if is_note(s) => s, - (None, Some(s)) if is_box(s) && !in_hf => s, - (None, Some(StoryRef::Body)) if is_note(story) => StoryRef::Body, - _ if is_box(story) => host, - _ => story, - } - }; - // While editing a header/footer, a press in another one (the footer while editing the - // header, or another page's own) edits that one, with the caret at the press. Hit-tested - // against the story being edited, the press put the caret back in it, scrolling the view - // away from the band that was clicked. - if !mods.shift - && matches!(part_kind(app, story), Some(PartKind::Header | PartKind::Footer)) - && let Some((_, header)) = hf_zone - && enters_header_footer(app, story, hf_zone) - { - app.canvas.column_drag = None; - app.canvas.hf_press_switched = true; - let id = if header { "insert.editHeader" } else { "insert.editFooter" }; - if app.run(id, json!({ "page": page })).is_ok() { - let to = app.session.sel.focus.story; - if let Some(pos) = app.session.layout().hit(page, x, y, to) { - app.session.sel = wordcraft_engine::Selection::caret(pos); - } - app.session.page_hint = page; - } - return; - } - // A press in the header/footer area from the body leaves the caret where it is, as in - // Word (#2103): moving it to the nearest body text scrolled the view there, away from the - // footer the double-click was aiming at. - if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body { - app.canvas.column_drag = None; - return; - } - if !mods.shift - && let Some((at, inner)) = layout.equation_hit(page, x, y, story) - { - app.session.sel = wordcraft_engine::Selection::caret(at); - let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); - app.session.page_hint = page; - return; - } - if app.session.math.is_some() { - let _ = app.run("equation.exit", json!({})); - } - // The selection bar (the left margin beside body text, #856): a click selects the line, - // a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; Shift+click - // extends by lines; dragging grows the selection by the clicked unit. - if story == StoryRef::Body - && !mods.alt - && let Some(line) = layout.selection_bar_at(page, x, y) - { - app.canvas.column_drag = None; - app.session.page_hint = page; - let lj = serde_json::to_value(&line).unwrap_or_default(); - let (unit, start, end) = if mods.shift { - let anchor = app.session.sel.anchor.clone(); - let aj = serde_json::to_value(&anchor).unwrap_or_default(); - let _ = app.run("select.dragUnit", json!({"unit": "line", "start": aj, "end": aj, "pos": lj})); - ("line", anchor.clone(), anchor) - } else { - let unit = match clicks { - _ if mods.command => "document", - 3.. => "document", - 2 => "paragraph", - _ => "line", - }; - let _ = app.run("caret.set", json!({"pos": lj})); - let id = match unit { - "document" => "select.all", - "paragraph" => "select.paragraph", - _ => "select.line", - }; - let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; - let _ = app.run(id, params); - let (start, end) = app.session.sel.ordered(); - (unit, start, end) - }; - app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line }); - app.canvas.dragging = true; - return; - } - let Some(pos) = layout.hit(page, x, y, story) else { return }; - // Ctrl/⌘+click follows a hyperlink. - if mods.command - && let Some(link) = link_under(app, layout, page, x, y, &pos) - { - if let Some(name) = link.strip_prefix('#') { - // The caret goes to the bookmark's start, as in Word (Go To selects its text). - if app.run("edit.goto", json!({"bookmark": name})).is_ok() { - let _ = app.run("select.collapse", json!({})); - } - } else if is_followable_link(&link) { - app.canvas.open_url = Some(link); - } else { - app.status(tl!("Only web (http, https) and email (mailto) links can be followed")); - } - return; - } - let pj = serde_json::to_value(&pos).unwrap_or_default(); - // Alt+drag selects a column (block) of text. - if mods.alt && !mods.shift && !mods.command { - let _ = app.run("caret.set", json!({"pos": pj})); - app.session.page_hint = page; - app.canvas.column_drag = Some(((page, x, y), (page, x, y))); - app.canvas.dragging = true; - return; - } - app.canvas.column_drag = None; - let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift})); - // Double-click selects a word, triple-click a paragraph, Ctrl/⌘+click a sentence; holding - // the button and dragging then extends by whole units (Word, #549). - let unit = match clicks { - _ if mods.shift => None, - 3 if !hf_switch => Some("paragraph"), - 2 if !hf_switch => Some("word"), - 1 if mods.command => Some("sentence"), - _ => None, - }; - if let Some(unit) = unit { - let _ = app.run(&format!("select.{unit}"), json!({})); - let (start, end) = app.session.sel.ordered(); - app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: pos }); - } - app.session.page_hint = page; - app.canvas.dragging = true; - // Format painter applies on mouse up. + handle_primary_press(app, &pointer, mods, hf_zone, hf_enter); } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) && let Some((from, last)) = app.canvas.column_drag @@ -407,6 +218,259 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ let _ = app.run("edit.pasteFormat", json!({})); } } + show_hover_link(app, &pointer); +} + +fn handle_primary_press( + app: &mut WordApp, + pointer: &PagePointer<'_>, + mods: egui::Modifiers, + hf_zone: Option<(Option, bool)>, + hf_enter: bool, +) { + let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer; + let now = ui.input(|i| i.time); + let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); + let clicks = press_count(app.canvas.last_press, now, p, delay, dist); + app.canvas.last_press = Some((now, p, clicks)); + app.canvas.unit_drag = None; + if clicks <= 1 { + app.canvas.hf_press_switched = false; + } + // A double-click that enters or leaves a header/footer is handled on release. + let hf_switch = hf_enter || app.canvas.hf_press_switched || (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none()); + // Clicking into a footnote/endnote or a text box edits it; clicking outside a text box goes + // back to where it is. While editing a header/footer, only its own text boxes are in reach. + let story = pointer_story(app, layout, page, x, y, story); + // While editing a header/footer, a press in another one (the footer while editing the + // header, or another page's own) edits that one, with the caret at the press. Hit-tested + // against the story being edited, the press put the caret back in it, scrolling the view + // away from the band that was clicked. + if !mods.shift + && matches!(part_kind(app, story), Some(PartKind::Header | PartKind::Footer)) + && let Some((_, header)) = hf_zone + && enters_header_footer(app, story, hf_zone) + { + app.canvas.column_drag = None; + app.canvas.hf_press_switched = true; + let id = if header { "insert.editHeader" } else { "insert.editFooter" }; + if app.run(id, json!({ "page": page })).is_ok() { + let to = app.session.sel.focus.story; + if let Some(pos) = app.session.layout().hit(page, x, y, to) { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + app.session.page_hint = page; + } + return; + } + // A press in the header/footer area from the body leaves the caret where it is, as in + // Word (#2103): moving it to the nearest body text scrolled the view there, away from the + // footer the double-click was aiming at. + if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body { + app.canvas.column_drag = None; + return; + } + if !mods.shift + && let Some((at, inner)) = layout.equation_hit(page, x, y, story) + { + app.session.sel = wordcraft_engine::Selection::caret(at); + let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()})); + app.session.page_hint = page; + return; + } + if app.session.math.is_some() { + let _ = app.run("equation.exit", json!({})); + } + // The selection bar (the left margin beside body text, #856): a click selects the line, + // a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; Shift+click + // extends by lines; dragging grows the selection by the clicked unit. + if story == StoryRef::Body + && !mods.alt + && let Some(line) = layout.selection_bar_at(page, x, y) + { + app.canvas.column_drag = None; + app.session.page_hint = page; + let lj = serde_json::to_value(&line).unwrap_or_default(); + let (unit, start, end) = if mods.shift { + let anchor = app.session.sel.anchor.clone(); + let aj = serde_json::to_value(&anchor).unwrap_or_default(); + let _ = app.run("select.dragUnit", json!({"unit": "line", "start": aj, "end": aj, "pos": lj})); + ("line", anchor.clone(), anchor) + } else { + let unit = match clicks { + _ if mods.command => "document", + 3.. => "document", + 2 => "paragraph", + _ => "line", + }; + let _ = app.run("caret.set", json!({"pos": lj})); + let id = match unit { + "document" => "select.all", + "paragraph" => "select.paragraph", + _ => "select.line", + }; + let params = if id == "select.all" { json!({"whole": true}) } else { json!({}) }; + let _ = app.run(id, params); + let (start, end) = app.session.sel.ordered(); + (unit, start, end) + }; + app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line }); + app.canvas.dragging = true; + return; + } + let Some(pos) = layout.hit(page, x, y, story) else { return }; + // Ctrl/⌘+click follows a hyperlink. + if mods.command + && let Some(link) = link_under(app, layout, page, x, y, &pos) + { + if let Some(name) = link.strip_prefix('#') { + // The caret goes to the bookmark's start, as in Word (Go To selects its text). + if app.run("edit.goto", json!({"bookmark": name})).is_ok() { + let _ = app.run("select.collapse", json!({})); + } + } else if is_followable_link(&link) { + app.canvas.open_url = Some(link); + } else { + app.status(tl!("Only web (http, https) and email (mailto) links can be followed")); + } + return; + } + let pj = serde_json::to_value(&pos).unwrap_or_default(); + // Alt+drag selects a column (block) of text. + if mods.alt && !mods.shift && !mods.command { + let _ = app.run("caret.set", json!({"pos": pj})); + app.session.page_hint = page; + app.canvas.column_drag = Some(((page, x, y), (page, x, y))); + app.canvas.dragging = true; + return; + } + app.canvas.column_drag = None; + let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift})); + // Double-click selects a word, triple-click a paragraph, Ctrl/⌘+click a sentence; holding + // the button and dragging then extends by whole units (Word, #549). + let unit = match clicks { + _ if mods.shift => None, + 3 if !hf_switch => Some("paragraph"), + 2 if !hf_switch => Some("word"), + 1 if mods.command => Some("sentence"), + _ => None, + }; + if let Some(unit) = unit { + let _ = app.run(&format!("select.{unit}"), json!({})); + let (start, end) = app.session.sel.ordered(); + app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: pos }); + } + app.session.page_hint = page; + app.canvas.dragging = true; + // Format painter applies on mouse up +} + +fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef { + let is_note = |s| matches!(part_kind(app, s), Some(PartKind::Footnote | PartKind::Endnote)); + let is_box = |s| part_kind(app, s) == Some(PartKind::TextBox); + let host = if is_box(story) { text_box_host(app, layout).unwrap_or(StoryRef::Body) } else { story }; + let in_hf = matches!(part_kind(app, host), Some(PartKind::Header | PartKind::Footer)); + let hf_box = if in_hf { layout.header_footer_text_box_at(page, x, y).map(StoryRef::Part) } else { None }; + match (hf_box, layout.story_at(page, x, y)) { + (Some(b), _) => b, + (None, Some(s)) if is_note(s) => s, + (None, Some(s)) if is_box(s) && !in_hf => s, + (None, Some(StoryRef::Body)) if is_note(story) => StoryRef::Body, + _ if is_box(story) => host, + _ => story, + } +} + +fn handle_completed_click(app: &mut WordApp, pointer: &PagePointer<'_>) -> bool { + let PagePointer { resp, layout, page, x, y, story, .. } = *pointer; + // The press already selected the word or paragraph (and a drag may have grown it). + let released_clicks = if resp.triple_clicked() { + 3 + } else if resp.double_clicked() { + 2 + } else { + 0 + }; + if released_clicks > 0 && app.canvas.unit_drag.as_ref().is_some_and(|d| d.clicks == released_clicks) { + app.canvas.unit_drag = None; + app.canvas.dragging = false; + return true; + } + // Double-click in the header/footer area edits it (creating it when the page has none, #960); + // double-click in the body leaves it. Print Layout only: the other views show no margins. + let hf_zone = if app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode { + layout.header_footer_zone(page, y) + } else { + None + }; + let hf_enter = enters_header_footer(app, story, hf_zone); + if resp.double_clicked() { + // Its first press already moved into the header/footer and placed the caret. + if app.canvas.hf_press_switched { + return true; + } + if let Some((_, header)) = hf_zone + && hf_enter + { + let id = if header { "insert.editHeader" } else { "insert.editFooter" }; + if app.run(id, json!({ "page": page })).is_ok() + && let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) + { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + return true; + } + if matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none() { + let _ = app.run("insert.closeHeader", json!({})); + if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) { + app.session.sel = wordcraft_engine::Selection::caret(pos); + } + return true; + } + // Double-click a field showing its code: edit the code. + if let Some(pos) = layout.hit(page, x, y, story) + && let Some(field) = field_showing_code(app, &pos) + { + app.session.sel = wordcraft_engine::Selection::caret(field); + let _ = app.run("fields.setCode", json!({})); + return true; + } + let _ = app.run("select.word", json!({})); + return true; + } + if resp.triple_clicked() { + let _ = app.run("select.paragraph", json!({"cell": true})); + return true; + } + false +} + +fn skip_equation_or_outline(app: &mut WordApp, pointer: &PagePointer<'_>) -> bool { + let PagePointer { ui, resp, layout, p, page, x, y, story, .. } = pointer; + // Inside an equation, clicks place the caret in it (and double-clicks don't select words); + // so do clicks on the grip and options strip of its frame. + let on_frame = app.canvas.eq_options.is_some_and(|f| f.strip.contains(*p) || f.grip.contains(*p)); + if (resp.double_clicked() || resp.triple_clicked()) && (on_frame || layout.equation_hit(*page, *x, *y, *story).is_some()) { + return true; + } + // Outline view: a paragraph's symbol selects it with what is under it; a double-click + // expands or collapses the heading. + if app.session.view.mode == wordcraft_layout::ViewMode::Outline + && let Some(block) = layout.outline_symbol_at(*page, *x, *y) + { + if resp.double_clicked() { + let _ = app.run("outline.toggle", json!({ "block": block })); + } else if ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() { + let _ = app.run("outline.selectSubtree", json!({ "block": block })); + app.session.page_hint = *page; + } + return true; + } + false +} + +fn show_hover_link(app: &WordApp, pointer: &PagePointer<'_>) { + let PagePointer { ui, resp, layout, page, x, y, story, .. } = pointer; // Hover a link: show its target, unless the context menu is opening or open: a tooltip draws // above the menu and would cover its first items (#3368). if resp.hovered() @@ -414,8 +478,8 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ && !app.canvas.context_menu_open && !resp.secondary_clicked() && !ui.input(|i| i.pointer.secondary_down()) - && let Some(pos) = layout.hit(page, x, y, story) - && let Some(link) = link_under(app, layout, page, x, y, &pos) + && let Some(pos) = layout.hit(*page, *x, *y, *story) + && let Some(link) = link_under(app, layout, *page, *x, *y, &pos) { let key = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" }; let target = if is_generated_bookmark_link(&link) { tl!("Current Document").to_string() } else { link }; diff --git a/crates/ui-egui/src/canvas/links.rs b/crates/ui-egui/src/canvas/links.rs index 9d738d797..0c0f13810 100644 --- a/crates/ui-egui/src/canvas/links.rs +++ b/crates/ui-egui/src/canvas/links.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocLayout, Pos, WordApp}; /// The simple field at or just before `pos` when it shows its code (View › Field Codes, or its /// own toggle): where it is. diff --git a/crates/ui-egui/src/canvas/margins.rs b/crates/ui-egui/src/canvas/margins.rs index c8061e9fe..2886bb81e 100644 --- a/crates/ui-egui/src/canvas/margins.rs +++ b/crates/ui-egui/src/canvas/margins.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Hash, Pos2, Rect, Sense, Ui, WordApp, json}; /// One edge of a page's text area on a ruler. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 01d089667..e94b4e65b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -1,5 +1,5 @@ use super::pages::{page_screen_scale, to_screen}; -use super::*; +use super::{DocLayout, Pos2, Rect, Ui, WordApp, pos2, vec2}; /// Page index and document coordinates under a screen point. pub fn page_at(rects: &[Rect], layout: &DocLayout, scale: f32, p: Pos2) -> Option<(usize, f32, f32)> { diff --git a/crates/ui-egui/src/canvas/pages.rs b/crates/ui-egui/src/canvas/pages.rs index ce400d898..d4a3483d8 100644 --- a/crates/ui-egui/src/canvas/pages.rs +++ b/crates/ui-egui/src/canvas/pages.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocLayout, Hash, Hasher, Page, Placed, Pos, Pos2, Rect, StoryRef, WordApp, json, pos2}; /// Fingerprint of everything outside the layout that changes how pages render: the view's /// marks and markup, dark mode, the page colour and watermark, header/body dimming, the media, diff --git a/crates/ui-egui/src/canvas/reading.rs b/crates/ui-egui/src/canvas/reading.rs index 02d0d94ba..490e4988e 100644 --- a/crates/ui-egui/src/canvas/reading.rs +++ b/crates/ui-egui/src/canvas/reading.rs @@ -1,5 +1,5 @@ use super::geometry::sane_dim; -use super::*; +use super::{DocLayout, GAP, PX_PER_PT, Placed, Pos, Rect, StoryRef, WordApp, pos2, vec2}; /// Full pagination is expensive. During a resize keep the already laid-out pages and reflow /// once the viewport has been stable this long (seconds). diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index 0806eb332..0362bbee8 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -1,6 +1,6 @@ use super::margins::{MarginEdge, margin_handle}; use super::pages::page_screen_scale; -use super::*; +use super::{DocLayout, Pos2, Rect, Sense, Stroke, Tokens, Ui, WordApp, json, pos2, regular, vec2}; /// Horizontal and vertical rulers for the caret's page and paragraph. Each spans that page's /// rect on screen at the page's own scale and is clipped to its strip, whatever the layout diff --git a/crates/ui-egui/src/canvas/story.rs b/crates/ui-egui/src/canvas/story.rs index 2c6697bc4..366faeaf1 100644 --- a/crates/ui-egui/src/canvas/story.rs +++ b/crates/ui-egui/src/canvas/story.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocLayout, PartKind, Rect, StoryRef, WordApp, pos2, vec2}; /// Whether the caret is in a text box's story. pub fn in_text_box(app: &WordApp) -> bool {