mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 00:31:10 +00:00
799 lines
37 KiB
Rust
799 lines
37 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::{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,
|
|
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>,
|
|
}
|
|
|
|
impl Default for CanvasState {
|
|
fn default() -> Self {
|
|
CanvasState {
|
|
textures: HashMap::new(),
|
|
scroll_to_caret: true,
|
|
caret_visible_since: 0.0,
|
|
dragging: false,
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Page positions in content space (points), for the current zoom.
|
|
pub struct Geometry {
|
|
pub rects: Vec<Rect>,
|
|
pub size: egui::Vec2,
|
|
pub scale: f32,
|
|
}
|
|
|
|
/// 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;
|
|
if on && !app.session.doc.comments.is_empty() { 216.0 } else { 0.0 }
|
|
}
|
|
|
|
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| p.w).fold(0.0f32, f32::max).max(72.0) + markup;
|
|
let maxh = l.pages.iter().map(|p| p.h.min(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;
|
|
let cols = if v.multi_page || v.read_mode { 2 } else { 1 };
|
|
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);
|
|
}
|
|
let mut rects = Vec::with_capacity(l.pages.len());
|
|
let row_w = cols as f32 * maxw * scale + (cols as f32 - 1.0) * GAP;
|
|
let content_w = (row_w + 2.0 * GAP).max(avail.x);
|
|
let mut y = GAP;
|
|
for (i, chunk) in l.pages.chunks(cols).enumerate() {
|
|
let _ = i;
|
|
let h = chunk.iter().map(|p| p.h.min(1e6) * scale).fold(0.0f32, f32::max);
|
|
let mut x = (content_w - row_w) / 2.0;
|
|
for p in chunk {
|
|
// Pages narrower than the widest keep their markup area beside them.
|
|
let px = x + (maxw - markup - p.w).max(0.0) * scale / 2.0;
|
|
rects.push(Rect::from_min_size(pos2(px, y), vec2(p.w * scale, p.h.min(1e6) * scale)));
|
|
x += maxw * scale + GAP;
|
|
}
|
|
y += h + GAP;
|
|
}
|
|
Geometry { rects, size: vec2(content_w, y.max(avail.y)), scale }
|
|
}
|
|
|
|
/// Fingerprint of a page's content for the texture cache.
|
|
fn page_key(app: &WordApp, page: &Page, scale_px: f32) -> 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).hash(&mut h);
|
|
let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(id) if Some(id) == page.header_story || Some(id) == page.footer_story);
|
|
editing_hf.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, .. } => {
|
|
(std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits()).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, .. } => format!("{rect:?}{media}").hash(&mut h),
|
|
Placed::Shape { rect, kind, fill, stroke, .. } => format!("{rect:?}{kind:?}{fill:?}{stroke:?}").hash(&mut h),
|
|
Placed::Cell { .. } => {}
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|
|
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let layout = app.session.layout();
|
|
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);
|
|
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
|
app.canvas.scale = geo.scale;
|
|
let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint);
|
|
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(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));
|
|
let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).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());
|
|
rects.push(sr);
|
|
if !sr.intersects(ui.clip_rect().expand(200.0)) {
|
|
continue;
|
|
}
|
|
let Some(page) = layout.pages.get(i) else { continue };
|
|
// Shadow and paper.
|
|
painter.rect_filled(sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
|
painter.rect_filled(sr, 0.0, Color32::WHITE);
|
|
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);
|
|
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
|
if !fresh && (rendered < 2 || !app.canvas.textures.contains_key(&i) && rendered < 4) {
|
|
let mut opts = wordcraft_render::RenderOptions::default();
|
|
opts.display.marks = app.session.view.marks;
|
|
opts.display.markup = app.session.view.show_markup;
|
|
let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(_));
|
|
opts.display.dim_header = !editing_hf;
|
|
opts.display.dim_body = editing_hf;
|
|
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(), sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
}
|
|
// 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 = sr.min.y + sect_body.y * geo.scale - 4.0;
|
|
let fy = sr.min.y + sect_body.bottom() * geo.scale + 4.0;
|
|
for (y, label) in [(hy, "Header"), (fy, "Footer")] {
|
|
dashed(&painter, pos2(sr.min.x, y), pos2(sr.max.x, y), Stroke::new(1.0, t.accent));
|
|
let tr = Rect::from_min_size(pos2(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, label, regular(11.0), t.accent_text);
|
|
}
|
|
}
|
|
}
|
|
// Table gridlines.
|
|
if app.session.view.gridlines {
|
|
for it in &page.items {
|
|
if let Placed::Cell { rect, .. } = it {
|
|
let r = Rect::from_min_size(
|
|
pos2(sr.min.x + rect.x * geo.scale, sr.min.y + rect.y * geo.scale),
|
|
vec2(rect.w * geo.scale, rect.h * geo.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);
|
|
// Selection.
|
|
if !app.session.sel.is_collapsed() {
|
|
let (a, b) = app.session.sel.ordered();
|
|
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.selection);
|
|
}
|
|
}
|
|
}
|
|
// Caret.
|
|
let focused = resp.has_focus() || app.canvas.focused;
|
|
if let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint)
|
|
&& let Some(pr) = rects.get(c.page)
|
|
{
|
|
let x = pr.min.x + c.x * geo.scale;
|
|
let y0 = pr.min.y + c.top * geo.scale;
|
|
let y1 = y0 + c.height * geo.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 {
|
|
painter.line_segment([pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)], Stroke::new(1.5, t.caret));
|
|
}
|
|
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 * geo.scale * 0.8), t.caret);
|
|
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, t.caret));
|
|
}
|
|
}
|
|
(resp, rects)
|
|
});
|
|
let (resp, rects) = out.inner;
|
|
// 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();
|
|
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
|
// Right-click: move the caret there (unless inside the selection), then the context menu.
|
|
if resp.secondary_clicked()
|
|
&& let Some(p) = resp.interact_pointer_pos()
|
|
&& let Some((page, x, y)) = page_at(&rects, 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));
|
|
if resp.hovered() {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
|
}
|
|
if app.canvas.focused {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Comment balloons in the markup area right of each page, joined to their anchors.
|
|
fn balloons(app: &mut WordApp, ui: &mut Ui, painter: &egui::Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
|
let mw = markup_width(app);
|
|
if mw <= 0.0 {
|
|
return;
|
|
}
|
|
let t = Tokens::get(ui.ctx());
|
|
let clip = ui.clip_rect();
|
|
// (page, anchor x, anchor y, id) for each anchored, unresolved-or-not comment.
|
|
let mut by_page: HashMap<usize, Vec<(f32, f32, u32, Pos)>> = HashMap::new();
|
|
for (id, pos) in wordcraft_engine::cmd::review::comment_list(&app.session) {
|
|
let Some(pos) = pos else { 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, id, 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<Pos> = None;
|
|
// The markup area extends each page.
|
|
for pr in rects {
|
|
let area = Rect::from_min_max(pos2(pr.max.x, pr.min.y), pos2(pr.max.x + mw * 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)));
|
|
}
|
|
}
|
|
for (pi, list) in by_page.iter_mut() {
|
|
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 x0 = pr.max.x + 10.0;
|
|
let w = (mw * scale - 20.0).max(60.0);
|
|
let mut next_y = pr.min.y;
|
|
for (ax, ay, id, pos) in list.iter() {
|
|
let Some(c) = app.session.doc.comments.get(id) else { continue };
|
|
let text = app.session.doc.plain_text(StoryRef::Part(c.part));
|
|
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
|
|
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 * scale / PX_PER_PT).clamp(8.0, 16.0);
|
|
let head = painter.layout(author.clone(), semibold(fs), t.text, w - 16.0);
|
|
let body = painter.layout(text.trim().to_string(), regular(fs), if c.resolved { t.text_dim } else { t.text }, w - 16.0);
|
|
let h = head.size().y + body.size().y + 16.0;
|
|
let anchor = pos2(pr.min.x + ax * scale, pr.min.y + ay * scale);
|
|
let top = (anchor.y - 12.0).max(next_y);
|
|
let card = Rect::from_min_size(pos2(x0, top), vec2(w, h));
|
|
next_y = card.max.y + 6.0;
|
|
if !card.intersects(clip) {
|
|
continue;
|
|
}
|
|
let selected = 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);
|
|
painter.rect_filled(card.translate(vec2(0.0, 1.0)), 4.0, t.page_shadow);
|
|
painter.rect_filled(card, 4.0, if selected { t.checked } else { Color32::WHITE });
|
|
painter.rect_stroke(card, 4.0, Stroke::new(if selected { 1.5 } else { 1.0 }, color), egui::StrokeKind::Inside);
|
|
painter.rect_filled(Rect::from_min_size(card.min, vec2(4.0, h)), egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 }, color);
|
|
painter.galley(pos2(card.min.x + 10.0, card.min.y + 6.0), head.clone(), t.text);
|
|
painter.galley(pos2(card.min.x + 10.0, card.min.y + 10.0 + head.size().y), body, t.text);
|
|
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(pos.clone());
|
|
}
|
|
}
|
|
}
|
|
if let Some(p) = clicked {
|
|
app.session.sel = wordcraft_engine::Selection::caret(p);
|
|
app.canvas.want_focus = true;
|
|
}
|
|
}
|
|
|
|
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], scale: f32, p: Pos2) -> Option<(usize, f32, f32)> {
|
|
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));
|
|
}
|
|
}
|
|
let (i, _) = best?;
|
|
let r = rects.get(i)?;
|
|
Some((i, (p.x - r.min.x) / scale, (p.y - r.min.y) / scale))
|
|
}
|
|
|
|
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
|
let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return };
|
|
let Some((page, x, y)) = page_at(rects, scale, p) else { return };
|
|
let mods = ui.input(|i| i.modifiers);
|
|
let story = app.session.sel.focus.story;
|
|
// 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(_)) && 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;
|
|
}
|
|
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 edits it; clicking the body from a note goes back.
|
|
let story = match layout.story_at(page, x, y) {
|
|
Some(StoryRef::Part(id))
|
|
if app
|
|
.session
|
|
.doc
|
|
.parts
|
|
.get(&id)
|
|
.is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote)) =>
|
|
{
|
|
StoryRef::Part(id)
|
|
}
|
|
Some(StoryRef::Body) if matches!(story, StoryRef::Part(id) if app.session.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, wordcraft_doc::PartKind::Footnote | wordcraft_doc::PartKind::Endnote))) => {
|
|
StoryRef::Body
|
|
}
|
|
_ => story,
|
|
};
|
|
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
|
// Ctrl/⌘+click follows a hyperlink.
|
|
if mods.command
|
|
&& let Some(link) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone())
|
|
{
|
|
if let Some(name) = link.strip_prefix('#') {
|
|
let _ = app.run("edit.goto", json!({"bookmark": name}));
|
|
} else {
|
|
app.canvas.open_url = Some(link);
|
|
}
|
|
return;
|
|
}
|
|
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
|
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()) {
|
|
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;
|
|
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) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone())
|
|
{
|
|
let tip = format!("{link}\n{}+Click to follow link", if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" });
|
|
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 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;
|
|
}
|
|
}
|
|
// 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);
|
|
let first = x0 + (col_x + rp.indent_left + rp.indent_first) * scale;
|
|
let left = x0 + (col_x + rp.indent_left) * scale;
|
|
let right = x0 + (col_x + page.body.w - rp.indent_right) * scale;
|
|
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),
|
|
));
|
|
for tab in &rp.tabs {
|
|
let tx = x0 + (col_x + tab.pos) * scale;
|
|
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 + 4.0, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
|
}
|
|
// 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 = ((pp.x - x0) / scale - col_x).clamp(-col_x, page.body.w - 18.0);
|
|
let snapped = (pt / 4.5).round() * 4.5;
|
|
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 = (pp.x - x0) / scale - col_x - rp.indent_left;
|
|
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 - ((pp.x - x0) / scale - col_x);
|
|
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);
|
|
vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, y0 + page.body.y * scale), pos2(vbar.max.x, y0 + page.body.bottom() * scale)), 0.0, t.ruler);
|
|
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;
|
|
}
|
|
}
|
|
let _ = sect;
|
|
}
|
|
|
|
/// Convert a page position (points) to screen coordinates (for agents and tests).
|
|
pub fn page_to_screen(app: &WordApp, page: usize, x: f32, y: f32) -> Option<Pos2> {
|
|
let r = app.canvas.page_rects.get(page)?;
|
|
Some(to_screen(pos2(0.0, 0.0), *r, app.canvas.scale, x, y))
|
|
}
|
|
|
|
/// 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)?;
|
|
Some((p, c.height * app.canvas.scale))
|
|
}
|
|
|
|
pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option<Pos> {
|
|
let (page, x, y) = page_at(&app.canvas.page_rects, app.canvas.scale, p)?;
|
|
let story = app.session.sel.focus.story;
|
|
app.session.layout().hit(page, x, y, story)
|
|
}
|
|
|
|
fn context_menu(app: &mut WordApp, ui: &mut 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(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("(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("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("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 app.session.sel.focus.path.cell().is_some() {
|
|
ui.separator();
|
|
ui.menu_button("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("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!({}));
|
|
}
|
|
}
|