Refactor ui canvas for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 01:06:48 +00:00
parent 1ecb22c67d
commit 260a629e9e
13 changed files with 1119 additions and 879 deletions
File diff suppressed because it is too large Load Diff
+68 -47
View File
@@ -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<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, 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<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) || (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<String> = 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<u32, Vec<u32>>,
by_page: HashMap<usize, Vec<(f32, f32, Balloon, Pos)>>,
}
fn collect_items(app: &WordApp, layout: &DocLayout, only_shown: bool, shown: Option<u32>) -> 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<u32, Vec<u32>> = 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<usize, Vec<(f32, f32, Balloon, Pos)>> = 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<usize, Vec<(f32, f32, Balloon, Pos)>>, 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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)> {
+1 -1
View File
@@ -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 {
+302 -238
View File
@@ -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<Pos2> {
// 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<StoryRef>, 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 };
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)]
+1 -1
View File
@@ -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)> {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {