1953 lines
96 KiB
Rust
1953 lines
96 KiB
Rust
//! The document canvas: pages on a grey background, rendered by the engine into textures (one
|
||
//! per page, re-rendered only when the page's content changes), with the caret, selection,
|
||
//! rulers and mouse editing drawn by egui on top.
|
||
|
||
use std::collections::HashMap;
|
||
use std::hash::{Hash, Hasher};
|
||
|
||
use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
|
||
use serde_json::json;
|
||
use wordcraft_doc::{PartKind, Pos, StoryRef};
|
||
use wordcraft_layout::{DocLayout, Page, Placed};
|
||
|
||
use crate::WordApp;
|
||
use crate::theme::{Tokens, regular, semibold};
|
||
|
||
/// Points on screen per document point at 100% (96 px per inch, like a word processor).
|
||
pub const PX_PER_PT: f32 = 96.0 / 72.0;
|
||
const GAP: f32 = 18.0;
|
||
const RULER: f32 = 22.0;
|
||
|
||
pub struct CanvasState {
|
||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||
pub scroll_to_caret: bool,
|
||
pub caret_visible_since: f64,
|
||
dragging: bool,
|
||
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
|
||
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
|
||
pub last_highlight: String,
|
||
pub last_font_color: String,
|
||
pub last_shading: String,
|
||
pub open_url: Option<String>,
|
||
/// Screen rects of pages last frame (for the control channel: page ↔ screen coordinates).
|
||
pub page_rects: Vec<Rect>,
|
||
/// Effective scale (screen points per document point).
|
||
pub scale: f32,
|
||
pub canvas_rect: Option<Rect>,
|
||
pub focused: bool,
|
||
pub render_ms: f64,
|
||
pub ime_preedit: String,
|
||
pub want_focus: bool,
|
||
pub context_issue: Option<serde_json::Value>,
|
||
pub context_synonyms: Option<serde_json::Value>,
|
||
/// The right-click context menu is open (the mini toolbar stands down while it is).
|
||
pub context_menu_open: bool,
|
||
/// Screen rect of the selection's first line, for the floating mini toolbar.
|
||
pub mini_anchor: Option<Rect>,
|
||
/// A picture, shape or text box being dragged by its frame.
|
||
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
|
||
/// An ink stroke being drawn with a pen (Draw tab).
|
||
pub(crate) ink: Option<crate::ink::InkDrag>,
|
||
/// The eraser is down: whether it has erased anything in this drag yet.
|
||
pub(crate) erasing: Option<bool>,
|
||
/// The lasso being drawn or dragged, and Ink Replay (Draw tab).
|
||
pub(crate) ink_ui: crate::ink::InkUi,
|
||
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
|
||
pub(crate) wheel: crate::scroll::CanvasScroll,
|
||
/// The scroll offset and its maximum at the end of last frame.
|
||
pub(crate) scroll_offset: egui::Vec2,
|
||
scroll_max: egui::Vec2,
|
||
/// Pages per row last frame; when it changes the caret's page is scrolled back into view.
|
||
pub cols: usize,
|
||
/// The comment whose balloon is selected (its text is editable in place, like Word's).
|
||
pub balloon: Option<u32>,
|
||
/// Give the selected balloon's text field keyboard focus on its next frame.
|
||
pub(crate) balloon_focus: bool,
|
||
/// The selected balloon shows a reply field.
|
||
pub(crate) balloon_reply: bool,
|
||
/// The selected balloon's height last frame (screen points), for its click area.
|
||
balloon_h: f32,
|
||
/// Screen rects of the comment balloons last frame (for the control channel and tests).
|
||
pub balloon_rects: Vec<(u32, Rect)>,
|
||
/// A paste event arrived since the last Mod+V release (see `keys::canvas_events`).
|
||
pub(crate) pasted: bool,
|
||
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
|
||
pub table_tool: Option<crate::table_pen::TableTool>,
|
||
/// The Draw Table stroke (or eraser press) in progress.
|
||
pub(crate) table_stroke: Option<crate::table_pen::PenStroke>,
|
||
/// The last key ran an Alt shortcut: the text its key sends (it may arrive a frame later)
|
||
/// isn't typed. The next key press clears it.
|
||
pub(crate) swallow_text: bool,
|
||
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
|
||
pub(crate) link_from: Option<wordcraft_doc::Pos>,
|
||
}
|
||
|
||
impl CanvasState {
|
||
/// The cached rendering of a page.
|
||
pub(crate) fn page_texture(&self, page: usize) -> Option<&TextureHandle> {
|
||
self.textures.get(&page).map(|(_, t)| t)
|
||
}
|
||
}
|
||
|
||
impl Default for CanvasState {
|
||
fn default() -> Self {
|
||
CanvasState {
|
||
textures: HashMap::new(),
|
||
scroll_to_caret: true,
|
||
caret_visible_since: 0.0,
|
||
dragging: false,
|
||
column_drag: None,
|
||
last_highlight: "yellow".into(),
|
||
last_font_color: "C00000".into(),
|
||
last_shading: "FFF2CC".into(),
|
||
open_url: None,
|
||
page_rects: Vec::new(),
|
||
scale: PX_PER_PT,
|
||
canvas_rect: None,
|
||
focused: false,
|
||
render_ms: 0.0,
|
||
ime_preedit: String::new(),
|
||
want_focus: true,
|
||
context_issue: None,
|
||
context_synonyms: None,
|
||
obj_drag: None,
|
||
ink: None,
|
||
erasing: None,
|
||
ink_ui: Default::default(),
|
||
context_menu_open: false,
|
||
mini_anchor: None,
|
||
pasted: false,
|
||
swallow_text: false,
|
||
wheel: Default::default(),
|
||
scroll_offset: egui::Vec2::ZERO,
|
||
scroll_max: egui::Vec2::ZERO,
|
||
cols: 1,
|
||
balloon: None,
|
||
balloon_focus: false,
|
||
balloon_reply: false,
|
||
balloon_h: 0.0,
|
||
balloon_rects: Vec::new(),
|
||
table_tool: None,
|
||
table_stroke: None,
|
||
link_from: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl CanvasState {
|
||
/// True while a mouse drag-select is in progress (the mini toolbar waits for mouse-up).
|
||
pub fn drag_selecting(&self) -> bool {
|
||
self.dragging
|
||
}
|
||
}
|
||
|
||
/// Page positions in content space (points), for the current zoom.
|
||
pub struct Geometry {
|
||
pub rects: Vec<Rect>,
|
||
pub size: egui::Vec2,
|
||
pub scale: f32,
|
||
/// Pages per row.
|
||
pub cols: usize,
|
||
}
|
||
|
||
/// Width of the markup area beside each page for comment balloons (points), or 0.
|
||
pub fn markup_width(app: &WordApp) -> f32 {
|
||
let v = &app.session.view;
|
||
// Word shows comments either in balloons (contextual) or in the Comments pane (list).
|
||
let on = v.show_markup && !v.comments_pane && !v.read_mode && !v.multi_page && v.mode == wordcraft_layout::ViewMode::Print;
|
||
// Track Changes Options: comments and formatting hidden, or every revision inline, leave
|
||
// no markup area.
|
||
let m = &app.session.prefs.markup;
|
||
let on = on && m.balloons != wordcraft_layout::display::BalloonMode::Inline;
|
||
let comments = m.comments && !app.session.doc.comments.is_empty();
|
||
let formats = m.formatting && wordcraft_engine::cmd::review::has_format_changes(&app.session.doc);
|
||
let moves = m.insertions_deletions && wordcraft_engine::cmd::moves::has_moves(&app.session.doc);
|
||
if on && (comments || formats || moves) { 216.0 } else { 0.0 }
|
||
}
|
||
|
||
/// A page dimension (points) safe to lay out: finite, at least 1 pt, at most `cap`.
|
||
fn sane_dim(v: f32, cap: f32) -> f32 {
|
||
if v.is_finite() { v.clamp(1.0, cap) } else { 72.0 }
|
||
}
|
||
|
||
/// How many pages fit side by side: as many slots of `slot_px` (a page plus its markup area, on
|
||
/// screen) as the width holds with a gap around each, like Word's Print Layout when zoomed out.
|
||
/// At least `min_cols`, never more than `pages` (or 1 when `auto` is off and `min_cols` is 1).
|
||
fn columns_for(avail_w: f32, slot_px: f32, pages: usize, min_cols: usize, auto: bool) -> usize {
|
||
let fit = if auto && avail_w.is_finite() && slot_px.is_finite() && slot_px > 0.0 {
|
||
// Saturating float → int cast; bounded by the page count below.
|
||
((avail_w - GAP) / (slot_px + GAP)).floor().max(1.0) as usize
|
||
} else {
|
||
1
|
||
};
|
||
fit.max(min_cols).min(pages.max(1)).max(1)
|
||
}
|
||
|
||
/// Place pages (sizes in points) in rows of `cols`, left to right then top to bottom, each in a slot
|
||
/// as wide as the widest page plus `markup` and centred in `avail_w`. Returns the screen-space rects
|
||
/// (relative to the content's top-left) and the content size.
|
||
fn place_pages(sizes: &[(f32, f32)], markup: f32, scale: f32, cols: usize, avail: egui::Vec2) -> (Vec<Rect>, egui::Vec2) {
|
||
let cols = cols.max(1);
|
||
let scale = if scale.is_finite() { scale.clamp(0.01, 10.0) } else { PX_PER_PT };
|
||
let markup = if markup.is_finite() { markup.clamp(0.0, 10_000.0) } else { 0.0 };
|
||
let avail = vec2(if avail.x.is_finite() { avail.x.max(0.0) } else { 0.0 }, if avail.y.is_finite() { avail.y.max(0.0) } else { 0.0 });
|
||
let maxw = sizes.iter().map(|&(w, _)| sane_dim(w, 1e5)).fold(0.0f32, f32::max).max(72.0) + markup;
|
||
let slot = maxw * scale;
|
||
let row_w = cols as f32 * slot + (cols as f32 - 1.0) * GAP;
|
||
let content_w = (row_w + 2.0 * GAP).max(avail.x);
|
||
let mut rects = Vec::with_capacity(sizes.len());
|
||
let mut y = GAP;
|
||
for row in sizes.chunks(cols) {
|
||
let mut x = (content_w - row_w) / 2.0;
|
||
let mut h = 0.0f32;
|
||
for &(w, ph) in row {
|
||
let (w, ph) = (sane_dim(w, 1e5), sane_dim(ph, 1e6));
|
||
// Pages narrower than the widest keep their markup area beside them.
|
||
let px = x + (maxw - markup - w).max(0.0) * scale / 2.0;
|
||
rects.push(Rect::from_min_size(pos2(px, y), vec2(w * scale, ph * scale)));
|
||
h = h.max(ph * scale);
|
||
x += slot + GAP;
|
||
}
|
||
y += h + GAP;
|
||
}
|
||
(rects, vec2(content_w, y.max(avail.y)))
|
||
}
|
||
|
||
pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
|
||
let v = &app.session.view;
|
||
let markup = markup_width(app);
|
||
let maxw = l.pages.iter().map(|p| sane_dim(p.w, 1e5)).fold(0.0f32, f32::max).max(72.0) + markup;
|
||
let maxh = l.pages.iter().map(|p| sane_dim(p.h, 20_000.0)).fold(0.0f32, f32::max).max(72.0);
|
||
let web = v.mode != wordcraft_layout::ViewMode::Print;
|
||
let mut scale = v.zoom.clamp(0.1, 5.0) * PX_PER_PT;
|
||
match v.fit.as_str() {
|
||
"pageWidth" => scale = ((avail.x - 2.0 * GAP - 20.0) / maxw).clamp(0.1, 6.0),
|
||
"onePage" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 2.0 * GAP) / maxw).clamp(0.05, 6.0),
|
||
"multiplePages" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0),
|
||
_ => {}
|
||
}
|
||
if v.read_mode {
|
||
scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0);
|
||
}
|
||
if web {
|
||
scale = PX_PER_PT * v.zoom.clamp(0.1, 5.0);
|
||
}
|
||
if !scale.is_finite() {
|
||
scale = PX_PER_PT;
|
||
}
|
||
// Pages flow side by side in Print Layout whenever more than one fits across (Read Mode is a
|
||
// two-page spread; One Page and Page Width show one page per row; Web Layout has no pages).
|
||
let (min_cols, auto) = if v.read_mode {
|
||
(2, false)
|
||
} else if web || v.fit == "onePage" || v.fit == "pageWidth" {
|
||
(1, false)
|
||
} else if v.multi_page {
|
||
(2, true)
|
||
} else {
|
||
(1, true)
|
||
};
|
||
let cols = if v.read_mode { 2 } else { columns_for(avail.x, maxw * scale, l.pages.len(), min_cols, auto) };
|
||
let sizes: Vec<(f32, f32)> = l.pages.iter().map(|p| (p.w, p.h)).collect();
|
||
let (rects, size) = place_pages(&sizes, markup, scale, cols, avail);
|
||
Geometry { rects, size, scale, cols }
|
||
}
|
||
|
||
/// While a fit mode (Page Width, One Page, Multiple Pages) sizes the page, keep the session's zoom
|
||
/// equal to what is shown, as Word does. Zoom In/Out, the Zoom dialog and `view.state` then start
|
||
/// from the visible zoom; before, they stepped from the stale manual zoom, so Zoom In from a 163%
|
||
/// Page Width jumped to 110% (issue #67).
|
||
pub fn sync_fit_zoom(app: &mut WordApp, scale: f32) {
|
||
let v = &mut app.session.view;
|
||
if v.fit.is_empty() || v.read_mode || v.mode != wordcraft_layout::ViewMode::Print || !scale.is_finite() {
|
||
return;
|
||
}
|
||
v.zoom = (scale / PX_PER_PT).clamp(0.1, 5.0);
|
||
}
|
||
|
||
/// Fingerprint of a page's content for the texture cache.
|
||
fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
scale_px.to_bits().hash(&mut h);
|
||
let v = &app.session.view;
|
||
(v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h);
|
||
app.session.prefs.markup.hash(&mut h);
|
||
dim_body.hash(&mut h);
|
||
format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h);
|
||
(page.w.to_bits(), page.h.to_bits()).hash(&mut h);
|
||
for list in [&page.items, &page.header, &page.footer] {
|
||
list.len().hash(&mut h);
|
||
for it in list.iter() {
|
||
match it {
|
||
Placed::Lines { para, l0, l1, x, y, turn, .. } => {
|
||
(std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits(), turn).hash(&mut h);
|
||
}
|
||
Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h),
|
||
Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h),
|
||
Placed::Image { rect, media, spin, .. } => format!("{rect:?}{media}{spin:?}").hash(&mut h),
|
||
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin } => {
|
||
format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h);
|
||
freeform.as_ref().map(|f| std::sync::Arc::as_ptr(f) as usize).hash(&mut h);
|
||
}
|
||
Placed::Graphic { rect, graphic, spin, .. } => {
|
||
(std::sync::Arc::as_ptr(graphic) as usize, rect.x.to_bits(), rect.y.to_bits(), rect.w.to_bits(), rect.h.to_bits()).hash(&mut h);
|
||
format!("{spin:?}").hash(&mut h)
|
||
}
|
||
Placed::Clip { rect, spin, items } => format!("{rect:?}{spin:?}{items:?}").hash(&mut h),
|
||
Placed::Cell { .. } | Placed::Object { .. } => {}
|
||
}
|
||
}
|
||
}
|
||
app.session.doc.media.len().hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
fn to_screen(origin: Pos2, page_rect: Rect, scale: f32, x: f32, y: f32) -> Pos2 {
|
||
pos2(origin.x + page_rect.min.x + x * scale, origin.y + page_rect.min.y + y * scale)
|
||
}
|
||
|
||
/// Place a raster at a physical-pixel-aligned origin and its exact device-pixel size.
|
||
fn texel_aligned_rect(layout: Rect, texture_px: egui::Vec2, ppp: f32) -> Rect {
|
||
let snap = |v: f32| (v * ppp).round() / ppp;
|
||
Rect::from_min_size(pos2(snap(layout.min.x), snap(layout.min.y)), texture_px / ppp)
|
||
}
|
||
|
||
pub(crate) fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
|
||
if page.w > 0.0 { rect.width() / page.w } else { fallback }
|
||
}
|
||
|
||
/// Closest the drawing grid's lines get on screen; zoomed far out, every other line is skipped.
|
||
const DRAWING_GRID_MIN_PX: f32 = 4.0;
|
||
/// Most grid lines drawn each way on one page (page sizes come from files).
|
||
const DRAWING_GRID_MAX_LINES: usize = 2000;
|
||
|
||
/// The View › Gridlines drawing grid over a page's text area, in document points: the x of each
|
||
/// vertical line and the y of each horizontal line, starting at the margins and `step` (across,
|
||
/// down) apart, never outside the margins. Drawn on screen only — never printed or exported.
|
||
fn drawing_grid(body: wordcraft_geom::Rect, step: (f32, f32)) -> (Vec<f32>, Vec<f32>) {
|
||
let axis = |start: f32, len: f32, step: f32| -> Vec<f32> {
|
||
if !(step.is_finite() && step > 0.0 && start.is_finite() && len.is_finite() && len >= 0.0) {
|
||
return Vec::new();
|
||
}
|
||
let end = start + len + 0.01;
|
||
(0..DRAWING_GRID_MAX_LINES).map(|k| start + k as f32 * step).take_while(|p| *p <= end).collect()
|
||
};
|
||
(axis(body.x, body.w, step.0), axis(body.y, body.h, step.1))
|
||
}
|
||
|
||
/// The drawing grid's spacing in points at a screen scale: the document's spacing (`w:drawingGrid*Spacing`;
|
||
/// ECMA-376's 1/8 inch when it doesn't say), doubled until the lines are at least
|
||
/// [`DRAWING_GRID_MIN_PX`] apart on screen.
|
||
fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
|
||
let mut step = if spacing.is_finite() && spacing > 0.0 { spacing } else { wordcraft_doc::DEFAULT_GRID };
|
||
if !(scale.is_finite() && scale > 0.0) {
|
||
return step;
|
||
}
|
||
for _ in 0..16 {
|
||
if step * scale >= DRAWING_GRID_MIN_PX {
|
||
break;
|
||
}
|
||
step *= 2.0;
|
||
}
|
||
step
|
||
}
|
||
|
||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
crate::ink::replay_tick(app, ui.ctx());
|
||
let layout = app.session.layout();
|
||
// View › Switch Modes: dark paper, inverted document colours, a white caret.
|
||
let dark_page = app.session.view.dark_mode;
|
||
let caret_color = if dark_page { Color32::WHITE } else { t.caret };
|
||
let dark_paper = wordcraft_render::DARK_PAPER;
|
||
let full = ui.available_rect_before_wrap();
|
||
let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode;
|
||
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));
|
||
let v = Rect::from_min_max(pos2(full.min.x, full.min.y + RULER), pos2(full.min.x + RULER, full.max.y));
|
||
(Some(h), Some(v), Rect::from_min_max(pos2(full.min.x + RULER, full.min.y + RULER), full.max))
|
||
} else {
|
||
(None, None, full)
|
||
};
|
||
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.
|
||
let zoom_delta = ui.input(|i| i.zoom_delta());
|
||
if (zoom_delta - 1.0).abs() > 1e-3 && ui.rect_contains_pointer(area) {
|
||
let pct = (app.canvas.scale / PX_PER_PT * zoom_delta * 100.0).clamp(10.0, 500.0);
|
||
let _ = app.run("view.zoom", json!({"value": pct}));
|
||
}
|
||
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
||
app.canvas.scale = geo.scale;
|
||
sync_fit_zoom(app, geo.scale);
|
||
// 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.
|
||
if geo.cols != app.canvas.cols {
|
||
app.canvas.cols = geo.cols;
|
||
app.canvas.scroll_to_caret = true;
|
||
}
|
||
let caret = layout.caret_on(&app.session.sel.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);
|
||
if let Some(c) = caret {
|
||
app.session.page_hint = c.page;
|
||
}
|
||
let mut scroll_target: Option<Rect> = None;
|
||
if app.canvas.scroll_to_caret
|
||
&& let Some(c) = caret
|
||
&& let Some(pr) = 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),
|
||
);
|
||
scroll_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. Applied before drawing, so it shows this frame.
|
||
let hovered = ui.rect_contains_pointer(area) && ui.ctx().dragged_id().is_none();
|
||
let notch = crate::scroll::notch_px(app.canvas.scale / PX_PER_PT);
|
||
let opts = ui.ctx().options(|o| o.input_options);
|
||
let delta = ui.input(|i| app.canvas.wheel.frame(i, &opts, hovered, notch, area.height()));
|
||
let mut scroll_area = egui::ScrollArea::both()
|
||
.id_salt("canvas_scroll")
|
||
.auto_shrink([false, false])
|
||
.scroll_source(egui::scroll_area::ScrollSource { mouse_wheel: false, ..Default::default() });
|
||
if delta != egui::Vec2::ZERO {
|
||
let before = app.canvas.scroll_offset;
|
||
let after = (before - delta).clamp(egui::Vec2::ZERO, app.canvas.scroll_max);
|
||
if after == before {
|
||
app.canvas.wheel.hit_edge();
|
||
}
|
||
scroll_area = scroll_area.scroll_offset(after);
|
||
}
|
||
if app.canvas.wheel.is_animating() {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
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);
|
||
let resp = ui.allocate_rect(content, 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 painter = ui.painter_at(ui.clip_rect());
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let max_tex = ui.ctx().input(|i| i.max_texture_side) as f32;
|
||
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;
|
||
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 scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||
let key = page_key(app, page, scale_px, dim_body);
|
||
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||
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 !sr.intersects(ui.clip_rect().expand(200.0)) {
|
||
continue;
|
||
}
|
||
// Shadow and paper.
|
||
painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||
painter.rect_filled(visual_sr, 0.0, if dark_page { Color32::from_gray(dark_paper) } else { Color32::WHITE });
|
||
if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
||
let mut opts = screen_render_options();
|
||
opts.display.marks = app.session.view.marks;
|
||
opts.display.placeholders = true;
|
||
opts.display.markup = app.session.view.show_markup;
|
||
// Ink Replay draws the ink itself, stroke by stroke, over pages without it.
|
||
opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay;
|
||
opts.display.revisions = app.session.prefs.markup.clone();
|
||
opts.dark = dark_page;
|
||
opts.dark_paper = dark_paper;
|
||
opts.display.dim_header = !dim_body;
|
||
opts.display.dim_body = dim_body;
|
||
let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts);
|
||
let px = img.to_straight();
|
||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px);
|
||
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;
|
||
} else if !fresh {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
// Table Layout › View Gridlines: table cell outlines (on screen only).
|
||
if app.session.view.table_gridlines {
|
||
for it in &page.items {
|
||
if let Placed::Cell { rect, .. } = it {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if rendered > 0 {
|
||
app.canvas.render_ms = crate::now_ms() - t0;
|
||
}
|
||
// Drop textures of pages that no longer exist.
|
||
let n = layout.pages.len();
|
||
app.canvas.textures.retain(|k, _| *k < n);
|
||
app.canvas.page_rects = rects.clone();
|
||
balloons(app, ui, &painter, &rects, &layout, geo.scale);
|
||
// Find › Reading Highlight.
|
||
let marks: Vec<(Pos, Pos)> = app.session.find_highlights().iter().take(5000).cloned().collect();
|
||
for (a, b) in &marks {
|
||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||
if let Some(pr) = rects.get(pi) {
|
||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||
let hr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||
painter.rect_filled(hr, 0.0, t.find_highlight);
|
||
}
|
||
}
|
||
}
|
||
// Selection (a selected object shows its frame instead).
|
||
if !app.session.sel.is_collapsed() && crate::objects::selected(app).is_none() {
|
||
// A column selection highlights each row's piece.
|
||
let pieces: Vec<(Pos, Pos)> = match app.session.column_segments() {
|
||
Some(segs) => segs.to_vec(),
|
||
None => vec![app.session.sel.ordered()],
|
||
};
|
||
let mut first: Option<Rect> = None;
|
||
for (a, b) in &pieces {
|
||
for (pi, r) in layout.selection_rects(&app.session.doc, a, b, app.session.page_hint) {
|
||
if let Some(pr) = rects.get(pi) {
|
||
let scale = layout.pages.get(pi).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale));
|
||
painter.rect_filled(sr, 0.0, t.selection);
|
||
// Track the topmost rect: the mini toolbar anchors on the selection's first line.
|
||
if first.is_none_or(|f| sr.min.y < f.min.y) {
|
||
first = Some(sr);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
app.canvas.mini_anchor = first;
|
||
} else {
|
||
app.canvas.mini_anchor = None;
|
||
}
|
||
// 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));
|
||
}
|
||
}
|
||
}
|
||
// Caret.
|
||
let focused = resp.has_focus() || app.canvas.focused;
|
||
// Editing an equation: shade it and draw the caret inside it.
|
||
let mut math_caret = false;
|
||
if let Some(m) = app.session.math.clone()
|
||
&& let Some((pi, ex, base, ml)) = layout.equation_geom(&m.at, app.session.page_hint)
|
||
&& let Some(pr) = rects.get(pi)
|
||
{
|
||
math_caret = true;
|
||
let s = geo.scale;
|
||
let zone = Rect::from_min_max(
|
||
pos2(pr.min.x + ex * s - 2.0, pr.min.y + (base - ml.ascent) * s - 2.0),
|
||
pos2(pr.min.x + (ex + ml.width) * s + 2.0, pr.min.y + (base + ml.descent) * s + 2.0),
|
||
);
|
||
painter.rect_filled(zone, 2.0, Color32::from_black_alpha(16));
|
||
if let Some(slot) = ml.slot(&m.pos.path, m.pos.off) {
|
||
let x = (pr.min.x + (ex + slot.x) * s).round() + 0.5;
|
||
let y0 = pr.min.y + (base - slot.y - slot.a) * s;
|
||
let y1 = pr.min.y + (base - slot.y + slot.d) * s;
|
||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||
if ((since / 530.0) as u64).is_multiple_of(2) && focused {
|
||
painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(1.5, t.caret));
|
||
}
|
||
if focused {
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||
let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1));
|
||
ui.ctx().output_mut(|o| {
|
||
o.ime = Some(egui::output::IMEOutput {
|
||
rect: cr,
|
||
cursor_rect: cr,
|
||
purpose: Default::default(),
|
||
should_interrupt_composition: false,
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
if !math_caret
|
||
&& let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint)
|
||
&& let Some(pr) = rects.get(c.page)
|
||
{
|
||
let scale = layout.pages.get(c.page).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));
|
||
let x = pr.min.x + c.x * scale;
|
||
let y0 = pr.min.y + c.top * scale;
|
||
let y1 = y0 + c.height * scale;
|
||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||
let on = ((since / 530.0) as u64).is_multiple_of(2);
|
||
if app.session.sel.is_collapsed() && on && focused {
|
||
let bar = if c.width > 0.0 {
|
||
// Turned text (a table cell's text direction): the caret lies across the page.
|
||
[pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)]
|
||
} else {
|
||
[pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
|
||
};
|
||
painter.line_segment(bar, Stroke::new(1.5, caret_color));
|
||
}
|
||
if focused {
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||
}
|
||
// IME candidate window placement.
|
||
let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1));
|
||
if focused {
|
||
ui.ctx().output_mut(|o| {
|
||
o.ime =
|
||
Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false });
|
||
});
|
||
}
|
||
if !app.canvas.ime_preedit.is_empty() {
|
||
let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * scale * 0.8), caret_color);
|
||
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, caret_color));
|
||
}
|
||
}
|
||
crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||
crate::ink::paint(app, &painter, &rects, &layout, geo.scale);
|
||
crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale);
|
||
(resp, rects)
|
||
});
|
||
app.canvas.scroll_offset = out.state.offset;
|
||
crate::ink::replay_bar(app, ui, out.inner_rect);
|
||
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
|
||
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() {
|
||
deselect_balloon(app, ui.ctx());
|
||
}
|
||
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
|
||
if resp.clicked() || resp.drag_started() || app.canvas.want_focus {
|
||
resp.request_focus();
|
||
app.canvas.want_focus = false;
|
||
}
|
||
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();
|
||
// Draw Table / Eraser own the mouse while on (no caret moves); else the usual editing.
|
||
if !crate::table_pen::pointer(app, ui, &resp, &rects, &layout, geo.scale) {
|
||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||
}
|
||
// 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 resp.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 => {}
|
||
}
|
||
}
|
||
if resp.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)
|
||
&& 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);
|
||
}
|
||
app.canvas.context_issue = app.session.run("review.suggestions", &json!({})).ok().filter(|v| !v.is_null());
|
||
app.canvas.context_synonyms = app.session.run("review.thesaurus", &json!({})).ok().and_then(|v| v.get("synonyms").cloned());
|
||
}
|
||
resp.context_menu(|ui| context_menu(app, ui));
|
||
// Stand down while the context menu owns the pointer so the two never double up;
|
||
// `context_menu_opened` also covers the click that dismisses the menu.
|
||
app.canvas.context_menu_open = resp.context_menu_opened();
|
||
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 over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
|
||
ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text));
|
||
}
|
||
crate::table_pen::cursor(app, ui, &resp);
|
||
// 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());
|
||
}
|
||
let _ = origin;
|
||
if let (Some(h), Some(v)) = (hruler, vruler) {
|
||
rulers(app, ui, h, v, &rects, &layout, geo.scale);
|
||
}
|
||
if let Some(sel) = app.canvas.mini_anchor {
|
||
crate::mini_toolbar::show(app, ui.ctx(), sel, area);
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// in place, with Reply, Resolve and Delete (the same commands as the Comments pane).
|
||
fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||
app.canvas.balloon_rects.clear();
|
||
let mw = markup_width(app);
|
||
if app.canvas.balloon.is_some_and(|id| mw <= 0.0 || !app.session.doc.comments.contains_key(&id)) {
|
||
deselect_balloon(app, ui.ctx());
|
||
}
|
||
if mw <= 0.0 {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ui.ctx());
|
||
let clip = ui.clip_rect();
|
||
// 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<u32, Vec<u32>> = 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, and each
|
||
// tracked formatting change (Word's default shows those in balloons too).
|
||
let mut by_page: HashMap<usize, Vec<(f32, f32, Balloon, Pos)>> = 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) {
|
||
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));
|
||
}
|
||
let palette = [t.blue, Color32::from_rgb(0xB0, 0x3A, 0x2E), Color32::from_rgb(0x2E, 0x7D, 0x32), Color32::from_rgb(0x8E, 0x44, 0xAD), t.orange];
|
||
let mut authors: Vec<String> = Vec::new();
|
||
let mut clicked: Option<(u32, Pos)> = None;
|
||
// The markup area extends each page.
|
||
for (pi, pr) in rects.iter().enumerate() {
|
||
let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
|
||
let area = Rect::from_min_max(pos2(pr.max.x, pr.min.y), pos2(pr.max.x + mw * page_scale, pr.max.y));
|
||
if area.intersects(clip) {
|
||
painter.rect_filled(area, 0.0, Color32::from_rgb(0xF3, 0xF3, 0xF3));
|
||
painter.line_segment([area.left_top(), area.left_bottom()], Stroke::new(1.0, Color32::from_rgb(0xE0, 0xE0, 0xE0)));
|
||
}
|
||
}
|
||
let mut pages: Vec<usize> = by_page.keys().copied().collect();
|
||
pages.sort_unstable();
|
||
for pi in pages {
|
||
let Some(list) = by_page.get_mut(&pi) else { continue };
|
||
let Some(pr) = rects.get(pi) else { continue };
|
||
if !pr.expand(400.0).intersects(clip) {
|
||
continue;
|
||
}
|
||
list.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
|
||
let page_scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
|
||
let x0 = pr.max.x + 10.0;
|
||
let w = (mw * page_scale - 20.0).max(60.0);
|
||
let mut next_y = pr.min.y;
|
||
for (ax, ay, item, pos) in list.iter() {
|
||
let id = match item {
|
||
Balloon::Comment(id) => id,
|
||
Balloon::Format(author, text) => {
|
||
// A read-only card: who formatted, and how.
|
||
let author = author_name(author);
|
||
let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| {
|
||
authors.push(author.clone());
|
||
authors.len() - 1
|
||
});
|
||
let color = palette.get(ai % palette.len()).copied().unwrap_or(t.blue);
|
||
let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0);
|
||
let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale);
|
||
let top = (anchor.y - 12.0).max(next_y);
|
||
let head = painter.layout(author, semibold(fs), t.text, w - 16.0);
|
||
let body = painter.layout(text.clone(), regular(fs), t.text_dim, w - 16.0);
|
||
let card = Rect::from_min_size(pos2(x0, top), vec2(w, head.size().y + body.size().y + 16.0));
|
||
next_y = card.max.y + 6.0;
|
||
if !card.intersects(clip) {
|
||
continue;
|
||
}
|
||
let lead = Stroke::new(1.0, color.linear_multiply(0.7));
|
||
dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead);
|
||
painter.line_segment([pos2(pr.max.x, anchor.y), pos2(x0, top + 10.0)], lead);
|
||
card_shapes(painter, &t, card, color, false, None);
|
||
let y = card.min.y + 6.0;
|
||
let hh = head.size().y;
|
||
painter.galley(pos2(card.min.x + 10.0, y), head, t.text);
|
||
painter.galley(pos2(card.min.x + 10.0, y + hh + 4.0), body, t.text_dim);
|
||
continue;
|
||
}
|
||
};
|
||
let Some(c) = app.session.doc.comments.get(id) else { continue };
|
||
let resolved = c.resolved;
|
||
let author = author_name(&c.author);
|
||
let ai = authors.iter().position(|a| *a == author).unwrap_or_else(|| {
|
||
authors.push(author.clone());
|
||
authors.len() - 1
|
||
});
|
||
let color = palette.get(ai % palette.len()).copied().unwrap_or(t.blue);
|
||
let fs = (11.0 * page_scale / PX_PER_PT).clamp(8.0, 16.0);
|
||
let thread: Vec<u32> = replies.get(id).cloned().unwrap_or_default();
|
||
let anchor = pos2(pr.min.x + ax * page_scale, pr.min.y + ay * page_scale);
|
||
let top = (anchor.y - 12.0).max(next_y);
|
||
let active = app.canvas.balloon == Some(*id);
|
||
// Lay out a read-only balloon's text: the comment, then each reply under a rule.
|
||
let mut lines: Vec<(std::sync::Arc<egui::Galley>, f32)> = Vec::new();
|
||
let card = if active {
|
||
Rect::from_min_size(pos2(x0, top), vec2(w, app.canvas.balloon_h.max(24.0)))
|
||
} else {
|
||
let dim = |r: bool| if r { t.text_dim } else { t.text };
|
||
lines.push((painter.layout(author, semibold(fs), t.text, w - 16.0), 0.0));
|
||
let body = app.session.doc.plain_text(StoryRef::Part(c.part));
|
||
lines.push((painter.layout(body.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||
for rid in &thread {
|
||
let Some(r) = app.session.doc.comments.get(rid) else { continue };
|
||
lines.push((painter.layout(author_name(&r.author), semibold(fs), t.text, w - 16.0), 9.0));
|
||
let text = app.session.doc.plain_text(StoryRef::Part(r.part));
|
||
lines.push((painter.layout(text.trim().to_string(), regular(fs), dim(resolved), w - 16.0), 4.0));
|
||
}
|
||
let h = lines.iter().map(|(g, gap)| g.size().y + gap).sum::<f32>() + 12.0;
|
||
Rect::from_min_size(pos2(x0, top), vec2(w, h))
|
||
};
|
||
if !card.intersects(clip) {
|
||
next_y = card.max.y + 6.0;
|
||
continue;
|
||
}
|
||
let selected = active || (app.session.sel.focus.path == pos.path && app.session.sel.focus.story == pos.story);
|
||
// Leader: from the anchor along the text to the page edge, then to the card.
|
||
let lead = Stroke::new(if selected { 1.5 } else { 1.0 }, color.linear_multiply(if selected { 1.0 } else { 0.7 }));
|
||
dashed(painter, anchor, pos2(pr.max.x, anchor.y), lead);
|
||
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)
|
||
} else {
|
||
card_shapes(painter, &t, card, color, selected, None);
|
||
let mut y = card.min.y + 6.0;
|
||
for (i, (g, gap)) in lines.into_iter().enumerate() {
|
||
y += gap;
|
||
if i >= 2 && i % 2 == 0 {
|
||
// A reply starts: a hairline between it and what it answers.
|
||
painter.hline(card.min.x + 10.0..=card.max.x - 8.0, y - gap / 2.0, Stroke::new(1.0, t.border));
|
||
}
|
||
let h = g.size().y;
|
||
painter.galley(pos2(card.min.x + 10.0, y), g, t.text);
|
||
y += h;
|
||
}
|
||
let r = ui.interact(card, ui.id().with(("balloon", *id)), Sense::click());
|
||
if r.hovered() {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||
}
|
||
if r.clicked() {
|
||
clicked = Some((*id, pos.clone()));
|
||
}
|
||
card
|
||
};
|
||
next_y = card.max.y + 6.0;
|
||
app.canvas.balloon_rects.push((*id, card));
|
||
}
|
||
}
|
||
if let Some((id, p)) = clicked {
|
||
select_balloon(app, ui.ctx(), id);
|
||
app.session.sel = wordcraft_engine::Selection::caret(p);
|
||
}
|
||
}
|
||
|
||
/// What a balloon in the markup area shows.
|
||
enum Balloon {
|
||
Comment(u32),
|
||
/// A tracked formatting change or move: its author and "Formatted: …" / "Moved from".
|
||
Format(String, String),
|
||
}
|
||
|
||
fn author_name(a: &str) -> String {
|
||
if a.is_empty() { tl!("Author").to_string() } else { a.to_string() }
|
||
}
|
||
|
||
/// A balloon card's background, border and author-colour stripe; `slot` paints them under
|
||
/// widgets added before the card's size was known.
|
||
fn card_shapes(painter: &egui::Painter, t: &Tokens, card: Rect, color: Color32, selected: bool, slot: Option<egui::layers::ShapeIdx>) {
|
||
let shapes = vec![
|
||
egui::Shape::rect_filled(card.translate(vec2(0.0, 1.0)), 4.0, t.page_shadow),
|
||
egui::Shape::rect_filled(card, 4.0, if selected { t.checked } else { Color32::WHITE }),
|
||
egui::Shape::rect_stroke(card, 4.0, Stroke::new(if selected { 1.5 } else { 1.0 }, color), egui::StrokeKind::Inside),
|
||
egui::Shape::rect_filled(Rect::from_min_size(card.min, vec2(4.0, card.height())), egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }, color),
|
||
];
|
||
match slot {
|
||
Some(i) => painter.set(i, egui::Shape::Vec(shapes)),
|
||
None => {
|
||
painter.extend(shapes);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The selected balloon: the comment and its replies as 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 {
|
||
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.
|
||
let _ = ui.interact(card, ui.id().with(("balloon", id)), Sense::click());
|
||
let inner = Rect::from_min_max(pos2(card.min.x + 10.0, card.min.y + 6.0), pos2(card.max.x - 6.0, card.min.y + 20_000.0));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
|
||
child.spacing_mut().item_spacing.y = 3.0;
|
||
let (font, bold, small) = (regular(fs), semibold(fs), regular((fs * 0.9).max(8.0)));
|
||
let focus = std::mem::take(&mut app.canvas.balloon_focus);
|
||
let name = |app: &WordApp, cid: u32| app.session.doc.comments.get(&cid).map(|c| author_name(&c.author)).unwrap_or_default();
|
||
let resolved = app.session.doc.comments.get(&id).is_some_and(|c| c.resolved);
|
||
child.horizontal(|ui| {
|
||
ui.label(egui::RichText::new(name(app, id)).font(bold.clone()).color(t.text));
|
||
if resolved {
|
||
ui.label(egui::RichText::new(tl!("Resolved")).font(small.clone()).color(t.green));
|
||
}
|
||
});
|
||
crate::panes::comment_editor(app, &mut child, id, Some(font.clone()), focus && !app.canvas.balloon_reply);
|
||
for rid in thread {
|
||
child.add_space(4.0);
|
||
child.label(egui::RichText::new(name(app, *rid)).font(bold.clone()).color(t.text));
|
||
crate::panes::comment_editor(app, &mut child, *rid, Some(font.clone()), false);
|
||
}
|
||
if app.canvas.balloon_reply && crate::panes::reply_editor(app, &mut child, id, Some(font.clone()), focus) {
|
||
app.canvas.balloon_reply = false;
|
||
}
|
||
let (mut reply, mut resolve, mut delete) = (false, false, false);
|
||
child.add_space(2.0);
|
||
child.horizontal(|ui| {
|
||
let button = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).font(small.clone())).small()).clicked();
|
||
reply = button(ui, tl!("Reply"));
|
||
resolve = button(ui, if resolved { tl!("Reopen") } else { tl!("Resolve") });
|
||
delete = button(ui, tl!("Delete"));
|
||
});
|
||
let card = Rect::from_min_max(card.min, pos2(card.max.x, child.min_rect().max.y + 6.0));
|
||
app.canvas.balloon_h = card.height();
|
||
card_shapes(painter, &t, card, color, true, Some(slot));
|
||
if reply {
|
||
app.canvas.balloon_reply = true;
|
||
app.canvas.balloon_focus = true;
|
||
}
|
||
if resolve {
|
||
let _ = app.run("review.resolveComment", json!({"id": id}));
|
||
}
|
||
if delete {
|
||
deselect_balloon(app, ui.ctx());
|
||
let _ = app.run("review.deleteComment", json!({"id": id}));
|
||
}
|
||
card
|
||
}
|
||
|
||
/// Select a comment's balloon (its text gets the keyboard), writing back any edit in the one
|
||
/// selected before.
|
||
pub fn select_balloon(app: &mut WordApp, ctx: &egui::Context, id: u32) {
|
||
if app.canvas.balloon == Some(id) {
|
||
return;
|
||
}
|
||
deselect_balloon(app, ctx);
|
||
app.canvas.balloon = Some(id);
|
||
app.canvas.balloon_focus = true;
|
||
app.canvas.balloon_h = 0.0;
|
||
}
|
||
|
||
/// Leave the selected balloon, writing back what was typed in it (its fields may not be drawn
|
||
/// again to notice they lost focus).
|
||
pub fn deselect_balloon(app: &mut WordApp, ctx: &egui::Context) {
|
||
let Some(id) = app.canvas.balloon.take() else { return };
|
||
app.canvas.balloon_reply = false;
|
||
app.canvas.balloon_focus = false;
|
||
let thread: Vec<u32> =
|
||
std::iter::once(id).chain(app.session.doc.comments.iter().filter(|(_, c)| c.parent == Some(id)).map(|(k, _)| *k)).collect();
|
||
for c in thread {
|
||
crate::panes::commit_comment(app, ctx, c);
|
||
}
|
||
crate::panes::commit_reply(app, ctx, id);
|
||
}
|
||
|
||
fn dashed(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) {
|
||
p.extend(egui::Shape::dashed_line(&[a, b], s, 4.0, 3.0));
|
||
}
|
||
|
||
/// 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)> {
|
||
let i = nearest_page(rects, p)?;
|
||
let r = rects.get(i)?;
|
||
let scale = layout.pages.get(i).map_or(scale, |page| page_screen_scale(*r, page, scale));
|
||
Some((i, (p.x - r.min.x) / scale, (p.y - r.min.y) / scale))
|
||
}
|
||
|
||
/// The page whose screen rect is under (or nearest to) a screen point.
|
||
pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
|
||
let mut best: Option<(usize, f32)> = None;
|
||
for (i, r) in rects.iter().enumerate() {
|
||
let d = if r.contains(p) {
|
||
0.0
|
||
} else {
|
||
let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0);
|
||
let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0);
|
||
dx + dy * 2.0
|
||
};
|
||
if best.is_none_or(|(_, bd)| d < bd) {
|
||
best = Some((i, d));
|
||
}
|
||
}
|
||
best.map(|(i, _)| i)
|
||
}
|
||
|
||
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||
// A pen or the eraser (Draw tab) owns the pointer.
|
||
if crate::ink::pointer(app, ui, resp, rects, layout, scale) {
|
||
return;
|
||
}
|
||
let pointer = resp.interact_pointer_pos().or_else(|| resp.hover_pos());
|
||
// An object drag follows the pointer anywhere until it's released.
|
||
let object_pointer = pointer.or_else(|| app.canvas.obj_drag.as_ref().and_then(|_| ui.input(|i| i.pointer.latest_pos())));
|
||
if let Some(at) = object_pointer
|
||
&& crate::objects::pointer(app, ui, resp, rects, layout, scale, at)
|
||
{
|
||
return;
|
||
}
|
||
let Some(p) = pointer else { return };
|
||
let Some((page, x, y)) = page_at(rects, layout, scale, p) else { return };
|
||
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).
|
||
if (resp.double_clicked() || resp.triple_clicked()) && layout.equation_hit(page, x, y, story).is_some() {
|
||
return;
|
||
}
|
||
// Double-click in the header/footer area edits it; double-click in the body leaves it.
|
||
if resp.double_clicked() {
|
||
if let Some((s, header)) = layout.header_footer_at(page, y)
|
||
&& story == StoryRef::Body
|
||
{
|
||
let id = if header { "insert.editHeader" } else { "insert.editFooter" };
|
||
let _ = app.run(id, json!({}));
|
||
let _ = s;
|
||
if 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) && layout.header_footer_at(page, y).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!({}));
|
||
return;
|
||
}
|
||
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
|
||
if pressed {
|
||
// 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,
|
||
}
|
||
};
|
||
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!({}));
|
||
}
|
||
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('#') {
|
||
let _ = app.run("edit.goto", json!({"bookmark": name}));
|
||
} 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}));
|
||
if mods.command && !mods.shift {
|
||
let _ = app.run("select.sentence", json!({}));
|
||
}
|
||
app.session.page_hint = page;
|
||
app.canvas.dragging = true;
|
||
// Format painter applies on mouse up.
|
||
} else if app.canvas.dragging
|
||
&& ui.input(|i| i.pointer.primary_down())
|
||
&& let Some((from, last)) = app.canvas.column_drag
|
||
{
|
||
if last != (page, x, y) {
|
||
app.canvas.column_drag = Some((from, (page, x, y)));
|
||
let story = app.session.sel.focus.story;
|
||
let _ = app.run(
|
||
"select.column",
|
||
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
|
||
);
|
||
}
|
||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
|
||
if let Some(pos) = layout.hit(page, x, y, story)
|
||
&& pos != app.session.sel.focus
|
||
{
|
||
app.session.sel.focus = pos;
|
||
app.session.page_hint = page;
|
||
app.canvas.caret_visible_since = crate::now_ms();
|
||
}
|
||
// Auto-scroll near the edges is handled by egui's scroll area drag.
|
||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
|
||
app.canvas.dragging = false;
|
||
app.canvas.column_drag = None;
|
||
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
|
||
let _ = app.run("edit.pasteFormat", json!({}));
|
||
}
|
||
}
|
||
// Hover a link: show its target.
|
||
if resp.hovered()
|
||
&& !app.canvas.dragging
|
||
&& 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 tip = format!("{link}\n{}", crate::i18n::fmt(tl!("{key}+Click to follow link"), &[("key", key)]));
|
||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||
ui.label(tip);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Horizontal and vertical rulers for the caret's page and paragraph.
|
||
fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let p = ui.painter();
|
||
p.rect_filled(Rect::from_min_max(pos2(v.min.x, h.min.y), pos2(v.max.x, h.max.y)), 0.0, t.canvas);
|
||
p.rect_filled(h, 0.0, t.canvas);
|
||
p.rect_filled(v, 0.0, t.canvas);
|
||
let pi = app.session.page_hint.min(layout.pages.len().saturating_sub(1));
|
||
let (Some(page), Some(pr)) = (layout.pages.get(pi), rects.get(pi)) else { return };
|
||
let scale = page_screen_scale(*pr, page, scale);
|
||
let sect = app.session.doc.sections().get(page.section).map(|(_, s)| (*s).clone()).unwrap_or_default();
|
||
// Horizontal.
|
||
let hp = ui.painter_at(h);
|
||
let x0 = pr.min.x;
|
||
let bar = Rect::from_min_max(pos2(x0, h.min.y + 4.0), pos2(x0 + page.w * scale, h.max.y - 3.0));
|
||
hp.rect_filled(bar, 0.0, t.ruler_margin);
|
||
let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y));
|
||
hp.rect_filled(text, 0.0, t.ruler);
|
||
let unit = 72.0;
|
||
let origin = page.body.x;
|
||
let mut k = -((origin / unit).ceil() as i32) * 8;
|
||
loop {
|
||
let xpt = origin + k as f32 * unit / 8.0;
|
||
if xpt > page.w {
|
||
break;
|
||
}
|
||
if xpt >= 0.0 {
|
||
let sx = x0 + xpt * scale;
|
||
let (len, label) = if k % 8 == 0 {
|
||
(0.0, Some(k / 8))
|
||
} else if k % 4 == 0 {
|
||
(5.0, None)
|
||
} else if k % 2 == 0 {
|
||
(3.0, None)
|
||
} else {
|
||
(1.5, None)
|
||
};
|
||
match label {
|
||
Some(n) if n != 0 => {
|
||
hp.text(pos2(sx, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick);
|
||
}
|
||
Some(_) => {}
|
||
None => {
|
||
if scale * unit / 8.0 > 4.0 || k % 2 == 0 {
|
||
hp.line_segment([pos2(sx, bar.center().y - len / 2.0), pos2(sx, bar.center().y + len / 2.0)], Stroke::new(1.0, t.ruler_tick));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
k += 1;
|
||
if k > 400 {
|
||
break;
|
||
}
|
||
}
|
||
// Margins: the edges of the white text area drag the section's margins. Registered before
|
||
// the indent markers, so a marker sitting on an edge keeps the pointer where they overlap.
|
||
let (le, re) = (x0 + page.body.x * scale, x0 + page.body.right() * scale);
|
||
for (edge, zone) in [
|
||
(MarginEdge::Left, Rect::from_min_max(pos2(le - 10.0, bar.min.y), pos2(le + 3.0, bar.max.y))),
|
||
(MarginEdge::Right, Rect::from_min_max(pos2(re - 3.0, bar.min.y), pos2(re + 10.0, bar.max.y))),
|
||
] {
|
||
margin_handle(app, ui, edge, zone, |p| (p.x - x0) / scale, §);
|
||
}
|
||
// Indent markers for the caret's paragraph (relative to its column).
|
||
let col_x = page.body.x;
|
||
if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) {
|
||
let rp = app.session.doc.styles.resolve_para(¶.props);
|
||
// Indents and tabs are measured from the paragraph's start edge: the right margin of a
|
||
// right-to-left paragraph, where the start-indent markers sit.
|
||
let rtl = rp.bidi;
|
||
let w = page.body.w;
|
||
let sx = |s: f32| if rtl { x0 + (col_x + w - s) * scale } else { x0 + (col_x + s) * scale };
|
||
let s_at = |x: f32| if rtl { col_x + w - (x - x0) / scale } else { (x - x0) / scale - col_x };
|
||
let first = sx(rp.indent_left + rp.indent_first);
|
||
let left = sx(rp.indent_left);
|
||
let right = sx(w - rp.indent_right);
|
||
let c = t.text_dim;
|
||
hp.add(egui::Shape::convex_polygon(
|
||
vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)],
|
||
t.ruler,
|
||
Stroke::new(1.0, c),
|
||
));
|
||
hp.add(egui::Shape::convex_polygon(
|
||
vec![pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y - 3.0), pos2(left, bar.max.y - 8.0)],
|
||
t.ruler,
|
||
Stroke::new(1.0, c),
|
||
));
|
||
hp.rect(
|
||
Rect::from_min_max(pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y + 1.0)),
|
||
0.0,
|
||
t.ruler,
|
||
Stroke::new(1.0, c),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
hp.add(egui::Shape::convex_polygon(
|
||
vec![pos2(right - 4.5, bar.max.y - 1.0), pos2(right + 4.5, bar.max.y - 1.0), pos2(right, bar.max.y - 6.0)],
|
||
t.ruler,
|
||
Stroke::new(1.0, c),
|
||
));
|
||
let mut open_tabs = false;
|
||
for (i, tab) in rp.tabs.iter().enumerate() {
|
||
let tx = sx(tab.pos);
|
||
let foot = if rtl { -4.0 } else { 4.0 };
|
||
hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||
hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + foot, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||
// Double-clicking a tab marker opens the Tabs dialog (#320).
|
||
let zone = Rect::from_center_size(pos2(tx, bar.max.y - 4.0), vec2(8.0, 9.0));
|
||
open_tabs |= ui.interact(zone, ui.id().with(("ruler_tab", i)), Sense::click()).double_clicked();
|
||
}
|
||
if open_tabs {
|
||
let _ = app.run("para.tabs", json!({}));
|
||
}
|
||
// Dragging the left-indent marker.
|
||
let id = ui.id().with("ruler_left");
|
||
let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0));
|
||
let r = ui.interact(mr, id, Sense::drag());
|
||
if r.dragged()
|
||
&& let Some(pp) = r.interact_pointer_pos()
|
||
{
|
||
let pt = s_at(pp.x).clamp(-col_x, page.body.w - 18.0);
|
||
let snapped = (pt / 4.5).round() * 4.5;
|
||
if !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("para.indents", json!({"left": snapped}));
|
||
}
|
||
let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0));
|
||
let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::drag());
|
||
if r.dragged()
|
||
&& let Some(pp) = r.interact_pointer_pos()
|
||
{
|
||
let pt = s_at(pp.x) - rp.indent_left;
|
||
if !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5}));
|
||
}
|
||
let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0));
|
||
let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::drag());
|
||
if r.dragged()
|
||
&& let Some(pp) = r.interact_pointer_pos()
|
||
{
|
||
let pt = page.body.w - s_at(pp.x);
|
||
if !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("para.indents", json!({"right": (pt / 4.5).round() * 4.5}));
|
||
}
|
||
}
|
||
// Vertical.
|
||
let vp = ui.painter_at(v);
|
||
let y0 = pr.min.y;
|
||
let vbar = Rect::from_min_max(pos2(v.min.x + 4.0, y0), pos2(v.max.x - 3.0, y0 + page.h.min(20_000.0) * scale));
|
||
vp.rect_filled(vbar, 0.0, t.ruler_margin);
|
||
let (te, be) = (y0 + page.body.y * scale, y0 + page.body.bottom() * scale);
|
||
vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, te), pos2(vbar.max.x, be)), 0.0, t.ruler);
|
||
for (edge, ey) in [(MarginEdge::Top, te), (MarginEdge::Bottom, be)] {
|
||
let zone = Rect::from_min_max(pos2(vbar.min.x, ey - 4.0), pos2(vbar.max.x, ey + 4.0));
|
||
margin_handle(app, ui, edge, zone, |p| (p.y - y0) / scale, §);
|
||
}
|
||
let origin = page.body.y;
|
||
let mut k = -((origin / unit).ceil() as i32) * 8;
|
||
loop {
|
||
let ypt = origin + k as f32 * unit / 8.0;
|
||
if ypt > page.h.min(20_000.0) {
|
||
break;
|
||
}
|
||
if ypt >= 0.0 && k % 8 == 0 && k != 0 {
|
||
vp.text(pos2(vbar.center().x, y0 + ypt * scale), egui::Align2::CENTER_CENTER, (k / 8).abs().to_string(), regular(9.5), t.ruler_tick);
|
||
} else if ypt >= 0.0 && k % 4 == 0 {
|
||
let sy = y0 + ypt * scale;
|
||
vp.line_segment([pos2(vbar.center().x - 2.5, sy), pos2(vbar.center().x + 2.5, sy)], Stroke::new(1.0, t.ruler_tick));
|
||
}
|
||
k += 1;
|
||
if k > 2000 {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One edge of a page's text area on a ruler.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||
pub(crate) enum MarginEdge {
|
||
Left,
|
||
Right,
|
||
Top,
|
||
Bottom,
|
||
}
|
||
|
||
impl MarginEdge {
|
||
fn param(self) -> &'static str {
|
||
match self {
|
||
MarginEdge::Left => "left",
|
||
MarginEdge::Right => "right",
|
||
MarginEdge::Top => "top",
|
||
MarginEdge::Bottom => "bottom",
|
||
}
|
||
}
|
||
fn label(self) -> &'static str {
|
||
match self {
|
||
MarginEdge::Left => "Left Margin",
|
||
MarginEdge::Right => "Right Margin",
|
||
MarginEdge::Top => "Top Margin",
|
||
MarginEdge::Bottom => "Bottom Margin",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The least text width (or height) a margin drag leaves: one inch, in points.
|
||
const MIN_TEXT: f32 = 72.0;
|
||
|
||
/// The margin (points) after dragging `edge` of the text area on a ruler to `at` points from the
|
||
/// page's left (or top) edge: snapped to 1/16 inch, never negative, and leaving at least an inch
|
||
/// of text between it and the opposite margin.
|
||
pub(crate) fn dragged_margin(edge: MarginEdge, at: f32, s: &wordcraft_doc::section::SectionProps) -> f32 {
|
||
let (raw, room, cur) = match edge {
|
||
MarginEdge::Left => (at - s.gutter, s.page_w - s.margin_right - s.gutter - MIN_TEXT, s.margin_left),
|
||
MarginEdge::Right => (s.page_w - at, s.page_w - s.margin_left - s.gutter - MIN_TEXT, s.margin_right),
|
||
MarginEdge::Top => (at, s.page_h - s.margin_bottom - MIN_TEXT, s.margin_top),
|
||
MarginEdge::Bottom => (s.page_h - at, s.page_h - s.margin_top - MIN_TEXT, s.margin_bottom),
|
||
};
|
||
if !raw.is_finite() || !room.is_finite() {
|
||
return cur;
|
||
}
|
||
((raw / 4.5).round() * 4.5).clamp(0.0, room.max(0.0))
|
||
}
|
||
|
||
/// A draggable margin edge on a ruler: `to_pt` turns the pointer into points from the page's
|
||
/// left (or top) edge. A whole drag is one undo step, through `layout.margins`.
|
||
fn margin_handle(app: &mut WordApp, ui: &Ui, edge: MarginEdge, zone: Rect, to_pt: impl Fn(Pos2) -> f32, sect: &wordcraft_doc::section::SectionProps) {
|
||
let cursor =
|
||
if matches!(edge, MarginEdge::Left | MarginEdge::Right) { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical };
|
||
let r = ui.interact(zone, ui.id().with(("ruler_margin", edge)), Sense::drag()).on_hover_cursor(cursor);
|
||
let r = if r.dragged() { r } else { r.on_hover_text(tl!(edge.label())) };
|
||
if r.dragged()
|
||
&& let Some(pp) = r.interact_pointer_pos()
|
||
{
|
||
let v = dragged_margin(edge, to_pt(pp), sect);
|
||
if !r.drag_started() {
|
||
app.session.join_next_undo();
|
||
}
|
||
let _ = app.run("layout.margins", json!({ edge.param(): v }));
|
||
}
|
||
}
|
||
|
||
/// Convert a page position (points) to screen coordinates (for agents and tests).
|
||
pub fn page_to_screen(app: &mut WordApp, page: usize, x: f32, y: f32) -> Option<Pos2> {
|
||
let r = app.canvas.page_rects.get(page)?;
|
||
let layout = app.session.layout();
|
||
let scale = layout.pages.get(page).map_or(app.canvas.scale, |page_data| page_screen_scale(*r, page_data, app.canvas.scale));
|
||
Some(to_screen(pos2(0.0, 0.0), *r, scale, x, y))
|
||
}
|
||
|
||
/// Whether the caret is in a text box's story.
|
||
pub fn in_text_box(app: &WordApp) -> bool {
|
||
part_kind(app, app.session.sel.focus.story) == Some(PartKind::TextBox)
|
||
}
|
||
|
||
fn part_kind(app: &WordApp, s: StoryRef) -> Option<PartKind> {
|
||
match s {
|
||
StoryRef::Part(id) => app.session.doc.parts.get(&id).map(|p| p.kind),
|
||
StoryRef::Body => None,
|
||
}
|
||
}
|
||
|
||
/// The story a text box's story sits in (where the box is), when the caret is in a text box.
|
||
fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option<StoryRef> {
|
||
match app.session.sel.focus.story {
|
||
StoryRef::Part(id) if in_text_box(app) => layout.text_box(id, app.session.page_hint).map(|o| o.story),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Editing a header or footer, or a text box in one.
|
||
pub fn editing_header_footer(app: &WordApp, layout: &DocLayout) -> bool {
|
||
let story = text_box_host(app, layout).unwrap_or(app.session.sel.focus.story);
|
||
matches!(part_kind(app, story), Some(PartKind::Header | PartKind::Footer))
|
||
}
|
||
|
||
/// The body is dimmed while editing anything but the body (or a text box in it).
|
||
fn dims_body(app: &WordApp, layout: &DocLayout) -> bool {
|
||
match app.session.sel.focus.story {
|
||
StoryRef::Body => false,
|
||
StoryRef::Part(_) if in_text_box(app) => editing_header_footer(app, layout),
|
||
StoryRef::Part(_) => true,
|
||
}
|
||
}
|
||
|
||
/// Caret position on screen.
|
||
pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> {
|
||
let l = app.session.layout();
|
||
let c = l.caret_on(&app.session.sel.focus, app.session.page_hint)?;
|
||
let p = page_to_screen(app, c.page, c.x, c.top)?;
|
||
let scale = app
|
||
.canvas
|
||
.page_rects
|
||
.get(c.page)
|
||
.and_then(|r| l.pages.get(c.page).map(|page| page_screen_scale(*r, page, app.canvas.scale)))
|
||
.unwrap_or(app.canvas.scale);
|
||
Some((p, c.height.max(c.width) * scale))
|
||
}
|
||
|
||
pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option<Pos> {
|
||
let layout = app.session.layout();
|
||
let (page, x, y) = page_at(&app.canvas.page_rects, &layout, app.canvas.scale, p)?;
|
||
let story = app.session.sel.focus.story;
|
||
app.session.layout().hit(page, x, y, story)
|
||
}
|
||
|
||
/// Right-click inside an equation: its structures' actions, display and conversion.
|
||
fn equation_menu(app: &mut WordApp, ui: &mut Ui) {
|
||
ui.set_min_width(260.0);
|
||
let acts = app.session.run("equation.structureActions", &json!({})).ok().and_then(|v| v.get("actions").cloned()).unwrap_or_default();
|
||
let mut last_level = None;
|
||
for a in acts.as_array().into_iter().flatten() {
|
||
let (Some(action), Some(label), Some(level)) =
|
||
(a.get("action").and_then(|v| v.as_str()), a.get("label").and_then(|v| v.as_str()), a.get("level").and_then(|v| v.as_u64()))
|
||
else {
|
||
continue;
|
||
};
|
||
if last_level.is_some_and(|l| l != level) {
|
||
ui.separator();
|
||
}
|
||
last_level = Some(level);
|
||
if ui.button(tl!(label)).clicked() {
|
||
let _ = app.run("equation.structure", json!({"action": action, "level": level}));
|
||
app.canvas.want_focus = true;
|
||
ui.close();
|
||
}
|
||
}
|
||
if last_level.is_some() {
|
||
ui.separator();
|
||
}
|
||
let display = app.session.run("equation.get", &json!({})).ok().and_then(|v| v.get("display").and_then(|d| d.as_bool())).unwrap_or(false);
|
||
let items: [(&str, &str, serde_json::Value); 5] = [
|
||
(if display { "Change to Inline" } else { "Change to Display" }, "equation.display", json!({"value": !display})),
|
||
("Professional", "equation.convert", json!({"to": "professional"})),
|
||
("Linear", "equation.convert", json!({"to": "linear"})),
|
||
("Equation Number", "equation.number", json!({})),
|
||
("Close Equation", "equation.exit", json!({})),
|
||
];
|
||
for (label, id, params) in items {
|
||
if ui.button(tl!(label)).clicked() {
|
||
let _ = app.run(id, params);
|
||
app.canvas.want_focus = true;
|
||
ui.close();
|
||
}
|
||
}
|
||
}
|
||
|
||
fn context_menu(app: &mut WordApp, ui: &mut Ui) {
|
||
if app.session.math.is_some() {
|
||
return equation_menu(app, ui);
|
||
}
|
||
ui.set_min_width(220.0);
|
||
let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| {
|
||
let sc = crate::widgets::shortcut_text(app, id);
|
||
let on = crate::widgets::enabled(app, id);
|
||
if ui.add_enabled(on, egui::Button::new(tl!(label)).shortcut_text(sc)).clicked() {
|
||
let _ = app.run(id, params);
|
||
ui.close();
|
||
}
|
||
};
|
||
if let Some(issue) = app.canvas.context_issue.clone() {
|
||
let sugg: Vec<String> = issue
|
||
.get("suggestions")
|
||
.and_then(|v| v.as_array())
|
||
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||
.unwrap_or_default();
|
||
ui.label(egui::RichText::new(issue.get("message").and_then(|m| m.as_str()).unwrap_or("")).small().weak());
|
||
if sugg.is_empty() {
|
||
ui.label(egui::RichText::new(tl!("(no suggestions)")).italics());
|
||
}
|
||
for sgt in sugg {
|
||
if ui.button(egui::RichText::new(&sgt).strong()).clicked() {
|
||
let _ = app.run("review.applySuggestion", json!({"text": sgt}));
|
||
ui.close();
|
||
}
|
||
}
|
||
if issue.get("kind").and_then(|k| k.as_str()) == Some("spelling") {
|
||
item(ui, app, "Ignore All", "review.ignoreAll", json!({}));
|
||
item(ui, app, "Add to Dictionary", "review.addToDictionary", json!({}));
|
||
}
|
||
ui.separator();
|
||
}
|
||
item(ui, app, "Cut", "edit.cut", json!({}));
|
||
if ui.add(egui::Button::new(tl!("Copy")).shortcut_text(crate::widgets::shortcut_text(app, "edit.copy"))).clicked() {
|
||
if let Ok(r) = app.run("edit.copy", json!({}))
|
||
&& let Some(t) = r.get("text").and_then(|t| t.as_str())
|
||
{
|
||
ui.ctx().copy_text(t.to_string());
|
||
}
|
||
ui.close();
|
||
}
|
||
item(ui, app, "Paste", "edit.paste", json!({}));
|
||
ui.separator();
|
||
if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) {
|
||
ui.menu_button(tl!("Synonyms"), |ui| {
|
||
for w in syn.iter().filter_map(|x| x.as_str()) {
|
||
if ui.button(w).clicked() {
|
||
let _ = app.run("select.word", json!({}));
|
||
let (a, b) = app.session.sel.ordered();
|
||
let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0);
|
||
let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b };
|
||
app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end };
|
||
let _ = app.run("text.insert", json!({"text": w, "raw": true}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
item(ui, app, "Font…", "ui.dialog", json!({"name": "font"}));
|
||
item(ui, app, "Paragraph…", "ui.dialog", json!({"name": "paragraph"}));
|
||
item(ui, app, "Link…", "ui.dialog", json!({"name": "link"}));
|
||
item(ui, app, "New Comment", "review.newComment", json!({}));
|
||
if !wordcraft_engine::cmd::fields::selected(&app.session).is_empty() {
|
||
ui.separator();
|
||
item(ui, app, tl!("Update Field"), "references.updateFields", json!({}));
|
||
item(ui, app, tl!("Edit Field…"), "fields.setCode", json!({}));
|
||
item(ui, app, tl!("Toggle Field Codes"), "fields.toggleCode", json!({}));
|
||
}
|
||
let object = wordcraft_engine::cmd::objects::selected(&app.session).is_some();
|
||
if object || app.session.sel.focus.path.cell().is_some() {
|
||
item(ui, app, "View Alt Text…", "view.altTextPane", json!({"value": true}));
|
||
}
|
||
if crate::ribbon::has_picture_selected(&app.session) {
|
||
ui.separator();
|
||
item(ui, app, "Change Picture…", "ui.changePicture", json!({}));
|
||
item(ui, app, "Reset Picture", "picture.reset", json!({}));
|
||
item(ui, app, "Size and Crop…", "ui.tab", json!({"tab": "Picture Format"}));
|
||
}
|
||
if app.session.sel.focus.path.cell().is_some() {
|
||
ui.separator();
|
||
ui.menu_button(tl!("Insert"), |ui| {
|
||
item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({}));
|
||
item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({}));
|
||
item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({}));
|
||
item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({}));
|
||
});
|
||
ui.menu_button(tl!("Delete"), |ui| {
|
||
item(ui, app, "Delete Rows", "table.deleteRow", json!({}));
|
||
item(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
|
||
item(ui, app, "Delete Table", "table.deleteTable", json!({}));
|
||
});
|
||
item(ui, app, "Merge Cells", "table.merge", json!({}));
|
||
}
|
||
}
|
||
|
||
/// The simple field at or just before `pos` when it shows its code (View › Field Codes, or its
|
||
/// own toggle): where it is.
|
||
fn field_showing_code(app: &WordApp, pos: &wordcraft_doc::Pos) -> Option<wordcraft_doc::Pos> {
|
||
let para = app.session.doc.para_at(pos)?;
|
||
let width = wordcraft_doc::para::OBJ.len_utf8();
|
||
[Some(pos.off), pos.off.checked_sub(width)].into_iter().flatten().find_map(|off| match para.object_at(off) {
|
||
Some(wordcraft_doc::para::InlineObject::Field { code, .. }) if *code != app.session.view.field_codes => {
|
||
Some(wordcraft_doc::Pos { off, ..pos.clone() })
|
||
}
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// Render options for page rasters shown on screen: on macOS, text is darkened the way the system
|
||
/// draws it, so a page looks as it does in other Mac apps (exports never are).
|
||
pub(crate) fn screen_render_options() -> wordcraft_render::RenderOptions {
|
||
wordcraft_render::RenderOptions { text_darkening: cfg!(target_os = "macos"), ..Default::default() }
|
||
}
|
||
|
||
/// The hyperlink under the pointer at (x, y) on `page`, where `pos` is the caret position the
|
||
/// point hits. The hit snaps to the nearest text, so the link only counts when the point is on
|
||
/// the linked character itself, not beside, above or below it (issue #413).
|
||
fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, pos: &Pos) -> Option<String> {
|
||
let doc = &app.session.doc;
|
||
let para = doc.para_at(pos)?;
|
||
let at = wordcraft_geom::Point::new(x, y);
|
||
// The caret falls between characters: the one under the point is on either side of it.
|
||
[(pos.off, para.next_boundary(pos.off)), (para.prev_boundary(pos.off), pos.off)].into_iter().filter(|(a, b)| a < b).find_map(|(a, b)| {
|
||
let link = para.props_of_char(a).link.clone()?;
|
||
let (from, to) = (Pos { off: a, ..pos.clone() }, Pos { off: b, ..pos.clone() });
|
||
layout.selection_rects(doc, &from, &to, page).into_iter().any(|(pi, r)| pi == page && r.contains(at)).then_some(link)
|
||
})
|
||
}
|
||
|
||
/// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only,
|
||
/// so a document can't launch `file:` paths, programs or custom-scheme handlers.
|
||
fn is_followable_link(url: &str) -> bool {
|
||
let Some((scheme, _)) = url.split_once(':') else { return false };
|
||
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// #167: dragging a ruler's margin edge sets that margin, snapped, never negative, and
|
||
/// always leaving an inch of text.
|
||
#[test]
|
||
fn margin_drags_are_clamped() {
|
||
let s = wordcraft_doc::section::SectionProps {
|
||
page_w: 612.0,
|
||
page_h: 792.0,
|
||
margin_left: 72.0,
|
||
margin_right: 72.0,
|
||
margin_top: 72.0,
|
||
margin_bottom: 72.0,
|
||
gutter: 0.0,
|
||
..Default::default()
|
||
};
|
||
assert_eq!(dragged_margin(MarginEdge::Left, 100.0, &s), 99.0, "snapped to 1/16 inch");
|
||
assert_eq!(dragged_margin(MarginEdge::Right, 612.0 - 36.0, &s), 36.0);
|
||
assert_eq!(dragged_margin(MarginEdge::Left, -50.0, &s), 0.0, "never negative");
|
||
assert_eq!(dragged_margin(MarginEdge::Left, 600.0, &s), 612.0 - 72.0 - 72.0, "an inch of text is left");
|
||
assert_eq!(dragged_margin(MarginEdge::Right, 0.0, &s), 612.0 - 72.0 - 72.0);
|
||
assert_eq!(dragged_margin(MarginEdge::Top, 9.0, &s), 9.0);
|
||
assert_eq!(dragged_margin(MarginEdge::Bottom, 100.0, &s), 792.0 - 72.0 - 72.0);
|
||
let g = wordcraft_doc::section::SectionProps { gutter: 36.0, ..s.clone() };
|
||
assert_eq!(dragged_margin(MarginEdge::Left, 144.0, &g), 108.0, "the gutter isn't margin");
|
||
assert_eq!(dragged_margin(MarginEdge::Left, f32::NAN, &s), 72.0, "junk keeps the margin");
|
||
|
||
// Through the command: the result is applied and is one undo step.
|
||
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||
let sect = wordcraft_engine::cmd::page::sect(&app.session);
|
||
let v = dragged_margin(MarginEdge::Left, sect.page_w, §);
|
||
app.run("layout.margins", json!({"left": v})).unwrap();
|
||
let after = wordcraft_engine::cmd::page::sect(&app.session);
|
||
assert!(after.page_w - after.margin_left - after.margin_right - after.gutter >= MIN_TEXT - 0.01);
|
||
assert!(app.session.undo());
|
||
assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left);
|
||
}
|
||
|
||
/// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the
|
||
/// comment has the new text as one undo step.
|
||
#[test]
|
||
fn a_balloon_edits_its_comment() {
|
||
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
|
||
a.run("text.insert", json!({"text": "Some text here"})).unwrap();
|
||
a.run("select.text", json!({"text": "text"})).unwrap();
|
||
let id = a.run("review.newComment", json!({"text": "Original"})).unwrap()["id"].as_u64().unwrap() as u32;
|
||
a.run("view.commentsPane", json!({"value": false})).unwrap();
|
||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|
||
|ui, app: &mut WordApp| {
|
||
let ctx = ui.ctx().clone();
|
||
app.logic(&ctx);
|
||
app.ui(ui);
|
||
},
|
||
a,
|
||
);
|
||
for _ in 0..6 {
|
||
h.step();
|
||
}
|
||
let card = h.state().canvas.balloon_rects.iter().find(|(i, _)| *i == id).map(|(_, r)| *r).expect("the comment has a balloon");
|
||
let at = card.left_top() + vec2(30.0, 8.0);
|
||
h.event(egui::Event::PointerMoved(at));
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||
h.step();
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||
for _ in 0..3 {
|
||
h.step();
|
||
}
|
||
assert_eq!(h.state().canvas.balloon, Some(id), "the click selected the balloon");
|
||
let depth = h.state().session.undo_depth();
|
||
for piece in ["Edited", " twice"] {
|
||
h.event(egui::Event::Text(piece.into()));
|
||
h.step();
|
||
}
|
||
assert_eq!(h.state().session.undo_depth(), depth, "nothing is written while typing");
|
||
h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
|
||
for _ in 0..3 {
|
||
h.step();
|
||
}
|
||
let part = h.state().session.doc.comments[&id].part;
|
||
let text = h.state().session.doc.plain_text(StoryRef::Part(part));
|
||
assert!(text.contains("Edited twice") && text.contains("Original"), "{text:?}");
|
||
assert_eq!(h.state().session.undo_depth(), depth + 1, "one undo step for the edit");
|
||
assert!(h.state_mut().session.undo());
|
||
assert_eq!(h.state().session.doc.plain_text(StoryRef::Part(part)), "Original");
|
||
}
|
||
|
||
#[test]
|
||
fn only_web_and_email_links_are_followed() {
|
||
for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] {
|
||
assert!(is_followable_link(ok), "{ok}");
|
||
}
|
||
for bad in [
|
||
"file:///etc/passwd",
|
||
"file://C:/Windows/System32/calc.exe",
|
||
"C:\\Windows\\System32\\calc.exe",
|
||
"/usr/bin/xterm",
|
||
"javascript:alert(1)",
|
||
"ms-msdt:/id",
|
||
"smb://host/share",
|
||
" https://example.com",
|
||
"\thttps://example.com",
|
||
"https\u{0}://example.com",
|
||
"",
|
||
"example.com",
|
||
] {
|
||
assert!(!is_followable_link(bad), "{bad:?}");
|
||
}
|
||
}
|
||
|
||
/// Issue #123: zoomed out, pages sit side by side in rows when the width allows; at 100% a
|
||
/// letter page in an ordinary window stays one per row.
|
||
#[test]
|
||
fn pages_flow_into_rows_when_they_fit() {
|
||
let letter = 612.0 * PX_PER_PT;
|
||
assert_eq!(columns_for(1200.0, letter, 10, 1, true), 1, "100%: one page per row");
|
||
assert_eq!(columns_for(1200.0, letter * 0.6, 10, 1, true), 2, "60%: two per row");
|
||
assert_eq!(columns_for(1200.0, letter * 0.25, 10, 1, true), 5, "25%: five per row");
|
||
assert_eq!(columns_for(1200.0, letter * 0.25, 3, 1, true), 3, "never more columns than pages");
|
||
assert_eq!(columns_for(1200.0, letter * 0.25, 10, 1, false), 1, "fit modes that show one page");
|
||
assert_eq!(columns_for(300.0, letter, 10, 2, true), 2, "Multiple Pages keeps two");
|
||
// Hostile numbers never panic and give at least one column.
|
||
for (w, slot, n) in
|
||
[(f32::NAN, letter, 5), (1200.0, f32::NAN, 5), (f32::INFINITY, 1.0, 5), (1200.0, 0.0, 5), (-5.0, -1.0, 5), (1e30, 1e-30, 0)]
|
||
{
|
||
let c = columns_for(w, slot, n, 1, true);
|
||
assert!((1..=n.max(1)).contains(&c), "{w} {slot} {n}: {c}");
|
||
}
|
||
|
||
// Mixed sizes: a landscape page among portrait ones, three per row.
|
||
let sizes = [(612.0, 792.0), (792.0, 612.0), (612.0, 792.0), (612.0, 792.0)];
|
||
let scale = 0.25;
|
||
let (rects, size) = place_pages(&sizes, 0.0, scale, 3, vec2(1000.0, 600.0));
|
||
assert_eq!(rects.len(), 4);
|
||
assert_eq!(rects[0].top(), rects[1].top());
|
||
assert_eq!(rects[1].top(), rects[2].top());
|
||
assert!(rects[0].right() < rects[1].left() && rects[1].right() < rects[2].left(), "left to right: {rects:?}");
|
||
assert!(rects[3].top() > rects[0].bottom(), "next row below the tallest page");
|
||
assert_eq!(rects[3].left(), rects[0].left(), "rows share columns");
|
||
assert!((rects[1].width() - 792.0 * scale).abs() < 1e-3);
|
||
assert!(size.x >= 1000.0 && size.y >= rects[3].bottom());
|
||
// Each page's centre maps back to that page.
|
||
for (i, r) in rects.iter().enumerate() {
|
||
assert_eq!(nearest_page(&rects, r.center()), Some(i));
|
||
}
|
||
// Hostile page sizes and scales stay finite.
|
||
let bad = [(f32::NAN, f32::INFINITY), (-1.0, 0.0), (1e30, 1e30)];
|
||
for scale in [f32::NAN, 0.0, -1.0, 1e9] {
|
||
let (rects, size) = place_pages(&bad, f32::NAN, scale, 0, vec2(f32::NAN, -1.0));
|
||
assert_eq!(rects.len(), 3);
|
||
assert!(size.x.is_finite() && size.y.is_finite());
|
||
assert!(rects.iter().all(|r| r.min.x.is_finite() && r.max.y.is_finite()), "{rects:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn texel_aligned_rect_snaps_the_origin_and_sizes_from_texture_pixels() {
|
||
let layout = Rect::from_min_size(pos2(10.25, 20.75), vec2(80.0, 120.0));
|
||
let visual = texel_aligned_rect(layout, vec2(161.0, 241.0), 2.0);
|
||
|
||
assert_eq!(visual.min, pos2(10.5, 21.0));
|
||
assert_eq!(visual.size(), vec2(80.5, 120.5));
|
||
}
|
||
|
||
/// 0.5 cm in points.
|
||
const HALF_CM: f32 = 72.0 * 0.5 / 2.54;
|
||
|
||
#[test]
|
||
fn drawing_grid_starts_at_the_margins_and_stays_inside_them() {
|
||
// Letter page with 1" margins: the text area is 468 x 648 pt from (72, 72).
|
||
let body = wordcraft_geom::Rect::new(72.0, 72.0, 468.0, 648.0);
|
||
let (xs, ys) = drawing_grid(body, (HALF_CM, HALF_CM));
|
||
assert_eq!(xs.first().copied(), Some(72.0));
|
||
assert_eq!(ys.first().copied(), Some(72.0));
|
||
assert!(xs.iter().all(|x| (72.0..=540.0).contains(x)), "{xs:?}");
|
||
assert!(ys.iter().all(|y| (72.0..=720.0).contains(y)), "{ys:?}");
|
||
// 0.5 cm apart, the same both ways: 468 pt holds 33 steps, 648 pt holds 45.
|
||
assert_eq!(xs.len(), 34);
|
||
assert_eq!(ys.len(), 46);
|
||
for w in xs.windows(2).chain(ys.windows(2)) {
|
||
assert!((w[1] - w[0] - HALF_CM).abs() < 0.01, "{w:?}");
|
||
}
|
||
// The document's own spacing, different across and down: 1/8 inch across, 1/4 inch down.
|
||
let (xs, ys) = drawing_grid(body, (9.0, 18.0));
|
||
assert_eq!((xs.len(), ys.len()), (53, 37));
|
||
// A grid line that lands on the margin is kept.
|
||
let (xs, _) = drawing_grid(wordcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0), (25.0, 25.0));
|
||
assert_eq!(xs, vec![0.0, 25.0, 50.0, 75.0, 100.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn drawing_grid_survives_hostile_sizes() {
|
||
let r = wordcraft_geom::Rect::new;
|
||
for (body, step) in [
|
||
(r(72.0, 72.0, 468.0, 648.0), 0.0),
|
||
(r(72.0, 72.0, 468.0, 648.0), -5.0),
|
||
(r(72.0, 72.0, 468.0, 648.0), f32::NAN),
|
||
(wordcraft_geom::Rect { x: f32::NAN, y: 0.0, w: f32::INFINITY, h: -10.0 }, HALF_CM),
|
||
] {
|
||
let (xs, ys) = drawing_grid(body, (step, step));
|
||
assert!(xs.is_empty() && ys.is_empty(), "{body:?} {step}");
|
||
}
|
||
let (xs, ys) = drawing_grid(r(0.0, 0.0, 1.0e9, 1.0e9), (HALF_CM, HALF_CM));
|
||
assert_eq!((xs.len(), ys.len()), (DRAWING_GRID_MAX_LINES, DRAWING_GRID_MAX_LINES));
|
||
// Zoomed far out, lines thin out instead of filling the page.
|
||
let grid = wordcraft_doc::DEFAULT_GRID;
|
||
assert_eq!(drawing_grid_step(grid, PX_PER_PT), grid);
|
||
assert_eq!(drawing_grid_step(HALF_CM, PX_PER_PT), HALF_CM);
|
||
assert!(drawing_grid_step(grid, 0.1) * 0.1 >= DRAWING_GRID_MIN_PX);
|
||
assert_eq!(drawing_grid_step(grid, 0.0), grid);
|
||
assert_eq!(drawing_grid_step(grid, f32::NAN), grid);
|
||
// A missing or broken spacing falls back to the spec's 1/8 inch.
|
||
for bad in [0.0, -3.0, f32::NAN, f32::INFINITY] {
|
||
assert_eq!(drawing_grid_step(bad, PX_PER_PT), grid);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn texel_aligned_rect_keeps_integer_pixel_pages_unchanged() {
|
||
let layout = Rect::from_min_size(pos2(10.0, 20.0), vec2(80.0, 120.0));
|
||
let visual = texel_aligned_rect(layout, vec2(160.0, 240.0), 2.0);
|
||
|
||
assert_eq!(visual, layout);
|
||
}
|
||
}
|