448 lines
19 KiB
Rust
448 lines
19 KiB
Rust
//! Hit testing and caret geometry.
|
|
|
|
use wordcraft_doc::para::Wrap;
|
|
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
|
use wordcraft_geom::{Point, Rect};
|
|
|
|
use crate::para::ParaLayout;
|
|
use crate::{DocLayout, Page, Placed};
|
|
|
|
/// Caret geometry on a page.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Caret {
|
|
pub page: usize,
|
|
pub x: f32,
|
|
pub top: f32,
|
|
pub height: f32,
|
|
}
|
|
|
|
/// One placed line, resolved to page coordinates.
|
|
#[derive(Clone, Debug)]
|
|
pub struct LineHit<'a> {
|
|
pub story: StoryRef,
|
|
pub path: &'a Path,
|
|
pub para: &'a ParaLayout,
|
|
pub li: usize,
|
|
pub x: f32,
|
|
pub top: f32,
|
|
pub bottom: f32,
|
|
pub left: f32,
|
|
pub right: f32,
|
|
}
|
|
|
|
/// A picture, shape or text box as laid out (see [`Placed::Object`]).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ObjectHit {
|
|
pub page: usize,
|
|
pub rect: Rect,
|
|
pub story: StoryRef,
|
|
pub path: Path,
|
|
pub off: usize,
|
|
pub text_box: Option<u32>,
|
|
pub wrap: Wrap,
|
|
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
|
|
pub origin: Point,
|
|
}
|
|
|
|
impl ObjectHit {
|
|
/// The object's position (its U+FFFC).
|
|
pub fn pos(&self) -> Pos {
|
|
Pos { story: self.story, path: self.path.clone(), off: self.off }
|
|
}
|
|
pub fn floating(&self) -> bool {
|
|
self.wrap != Wrap::Inline
|
|
}
|
|
pub fn behind(&self) -> bool {
|
|
self.wrap == Wrap::BehindText
|
|
}
|
|
}
|
|
|
|
/// A page's objects (not its header's or footer's), topmost first.
|
|
fn objects(page: &Page, index: usize) -> impl Iterator<Item = ObjectHit> + '_ {
|
|
objects_in(&page.items, index)
|
|
}
|
|
|
|
/// A page's objects including its header's and footer's.
|
|
fn all_objects(page: &Page, index: usize) -> impl Iterator<Item = ObjectHit> + '_ {
|
|
objects_in(&page.items, index).chain(objects_in(&page.header, index)).chain(objects_in(&page.footer, index))
|
|
}
|
|
|
|
fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit> + '_ {
|
|
items.iter().rev().filter_map(move |it| match it {
|
|
Placed::Object { rect, story, path, off, text_box, wrap, origin } => Some(ObjectHit {
|
|
page: index,
|
|
rect: *rect,
|
|
story: *story,
|
|
path: path.clone(),
|
|
off: *off,
|
|
text_box: *text_box,
|
|
wrap: *wrap,
|
|
origin: *origin,
|
|
}),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The story of the text (not a text box's) near (x, y): body, footnotes, endnotes.
|
|
fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option<StoryRef> {
|
|
let boxes: Vec<StoryRef> = objects(p, index).filter_map(|o| o.text_box.map(StoryRef::Part)).collect();
|
|
p.items.iter().find_map(|it| match it {
|
|
Placed::Lines { story, .. } if boxes.contains(story) => None,
|
|
Placed::Lines { story, para, l0, l1, x: lx, y: ly, .. } => {
|
|
let first = para.lines.get(*l0)?;
|
|
let last = para.lines.get(l1.checked_sub(1)?)?;
|
|
let bottom = ly + last.top + last.height - first.top;
|
|
(y >= *ly - 2.0 && y <= bottom + 2.0 && x >= *lx - 40.0 && x <= lx + last.right + 40.0).then_some(*story)
|
|
}
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn items_of(page: &Page, story: StoryRef) -> Box<dyn Iterator<Item = &Placed> + '_> {
|
|
match story {
|
|
StoryRef::Body => Box::new(page.items.iter()),
|
|
// Headers and footers, and the text boxes in them, are drawn with the page's header and
|
|
// footer; everything else is in its items.
|
|
StoryRef::Part(_) => Box::new(page.header.iter().chain(page.footer.iter()).chain(page.items.iter())),
|
|
}
|
|
}
|
|
|
|
/// Every line on a page that belongs to `story` (body, or a header/footer part).
|
|
pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
|
|
let mut v = Vec::new();
|
|
for it in items_of(page, story) {
|
|
if let Placed::Lines { story: s, path, para, l0, l1, x, y } = it {
|
|
if *s != story {
|
|
continue;
|
|
}
|
|
let Some(first) = para.lines.get(*l0) else { continue };
|
|
for li in *l0..*l1 {
|
|
let Some(l) = para.lines.get(li) else { continue };
|
|
let top = y + (l.top - first.top);
|
|
v.push(LineHit { story: *s, path, para, li, x: *x, top, bottom: top + l.height, left: x + l.left, right: x + l.right });
|
|
}
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
impl DocLayout {
|
|
/// The position nearest to (x, y) on `page`, in `story`.
|
|
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {
|
|
let p = self.pages.get(page)?;
|
|
let lines = page_lines(p, story);
|
|
let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?;
|
|
let off = best.para.off_at_x(best.li, x - best.x);
|
|
Some(Pos { story: best.story, path: best.path.clone(), off })
|
|
}
|
|
|
|
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
|
|
/// headers/footers). A text box wins inside its area, except one behind the text, which only
|
|
/// wins where no body text is.
|
|
pub fn story_at(&self, page: usize, x: f32, y: f32) -> Option<StoryRef> {
|
|
let p = self.pages.get(page)?;
|
|
let text_box = |behind: bool| {
|
|
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(Point::new(x, y))).and_then(|o| o.text_box)
|
|
};
|
|
text_box(false).map(StoryRef::Part).or_else(|| text_at(p, page, x, y)).or_else(|| text_box(true).map(StoryRef::Part))
|
|
}
|
|
|
|
/// The object a press at (x, y) grabs: a picture or shape anywhere on it, a text box on its
|
|
/// border (within `edge` points either side; inside is its text). Topmost first; objects
|
|
/// behind the text only where there's no text.
|
|
pub fn object_at(&self, page: usize, x: f32, y: f32, edge: f32) -> Option<ObjectHit> {
|
|
let p = self.pages.get(page)?;
|
|
let pt = Point::new(x, y);
|
|
let grabs = |o: &ObjectHit| {
|
|
o.rect.expand(edge).contains(pt) && (o.text_box.is_none() || !o.rect.expand(-edge).contains(pt) || o.rect.w.min(o.rect.h) <= edge * 3.0)
|
|
};
|
|
objects(p, page)
|
|
.find(|o| !o.behind() && grabs(o))
|
|
.or_else(|| if text_at(p, page, x, y).is_some() { None } else { objects(p, page).find(|o| o.behind() && grabs(o)) })
|
|
}
|
|
|
|
/// The text box in the header or footer at (x, y) on `page`.
|
|
pub fn header_footer_text_box_at(&self, page: usize, x: f32, y: f32) -> Option<u32> {
|
|
let p = self.pages.get(page)?;
|
|
objects_in(&p.header, page)
|
|
.chain(objects_in(&p.footer, page))
|
|
.find(|o| o.text_box.is_some() && o.rect.contains(Point::new(x, y)))
|
|
.and_then(|o| o.text_box)
|
|
}
|
|
|
|
/// Where the object at `pos` (its U+FFFC) is laid out.
|
|
pub fn object(&self, pos: &Pos, page_hint: usize) -> Option<ObjectHit> {
|
|
self.find_object(page_hint, |o| o.story == pos.story && o.path == pos.path && o.off == pos.off)
|
|
}
|
|
|
|
/// Where the text box showing story `part` is laid out.
|
|
pub fn text_box(&self, part: u32, page_hint: usize) -> Option<ObjectHit> {
|
|
self.find_object(page_hint, |o| o.text_box == Some(part))
|
|
}
|
|
|
|
/// The first object matching `f`, looking on `page_hint` first (where the caret is: one page
|
|
/// to scan, not the whole document).
|
|
pub fn find_object(&self, page_hint: usize, f: impl Fn(&ObjectHit) -> bool) -> Option<ObjectHit> {
|
|
let on = |i: usize| self.pages.get(i).and_then(|p| all_objects(p, i).find(|o| f(o)));
|
|
on(page_hint).or_else(|| (0..self.pages.len()).filter(|i| *i != page_hint).find_map(on))
|
|
}
|
|
|
|
/// Which header/footer (if any) is at (x, y) on a page — for double-click editing.
|
|
pub fn header_footer_at(&self, page: usize, y: f32) -> Option<(StoryRef, bool)> {
|
|
let p = self.pages.get(page)?;
|
|
if y < p.body.y {
|
|
return p.header_story.map(|id| (StoryRef::Part(id), true));
|
|
}
|
|
if y > p.body.bottom() {
|
|
return p.footer_story.map(|id| (StoryRef::Part(id), false));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Pieces of a paragraph: (page, item index).
|
|
fn pieces(&self, story: StoryRef, path: &Path, page_hint: usize) -> Vec<(usize, &Placed)> {
|
|
if story == StoryRef::Body {
|
|
return self
|
|
.index
|
|
.get(&(story, path.clone()))
|
|
.map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect())
|
|
.unwrap_or_default();
|
|
}
|
|
// Headers/footers repeat: prefer the hinted page, else the first that shows the part.
|
|
let mut order: Vec<usize> = (0..self.pages.len()).collect();
|
|
if page_hint < order.len() {
|
|
order.retain(|p| *p != page_hint);
|
|
order.insert(0, page_hint);
|
|
}
|
|
for pi in order {
|
|
let Some(p) = self.pages.get(pi) else { continue };
|
|
let found: Vec<(usize, &Placed)> = items_of(p, story)
|
|
.filter(|it| matches!(it, Placed::Lines { story: s, path: q, .. } if *s == story && q == path))
|
|
.map(|it| (pi, it))
|
|
.collect();
|
|
if !found.is_empty() {
|
|
return found;
|
|
}
|
|
}
|
|
Vec::new()
|
|
}
|
|
|
|
/// Caret geometry for a position.
|
|
pub fn caret(&self, pos: &Pos) -> Option<Caret> {
|
|
self.caret_on(pos, 0)
|
|
}
|
|
|
|
pub fn caret_on(&self, pos: &Pos, page_hint: usize) -> Option<Caret> {
|
|
for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
|
let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
|
|
let li = para.line_of(pos.off);
|
|
if li < *l0 || li >= *l1 {
|
|
continue;
|
|
}
|
|
let first = para.lines.get(*l0)?;
|
|
let l = para.lines.get(li)?;
|
|
let cx = para.x_of(li, pos.off)?;
|
|
let top = y + (l.top - first.top);
|
|
// Caret spans the text height (ascent + descent), not the full line spacing.
|
|
let (asc, desc) = para.caret_metrics(li);
|
|
let base = top + (l.baseline - l.top);
|
|
let h = (asc + desc).min(l.height.max(asc + desc));
|
|
let ctop = (base - asc).max(top);
|
|
let _ = h;
|
|
return Some(Caret { page: pi, x: x + cx, top: ctop, height: (base + desc - ctop).max(4.0) });
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The position one line up (`dir < 0`) or down from `pos`, keeping `goal_x` (page x).
|
|
pub fn vertical(&self, pos: &Pos, goal_x: Option<f32>, dir: i32, page_hint: usize) -> Option<(Pos, f32)> {
|
|
let c = self.caret_on(pos, page_hint)?;
|
|
let gx = goal_x.unwrap_or(c.x);
|
|
let page = self.pages.get(c.page)?;
|
|
let lines = page_lines(page, pos.story);
|
|
let cur_mid = c.top + c.height / 2.0;
|
|
let cands: Vec<&LineHit> = lines
|
|
.iter()
|
|
.filter(|l| {
|
|
let mid = (l.top + l.bottom) / 2.0;
|
|
if dir < 0 {
|
|
mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5
|
|
} else {
|
|
mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5
|
|
}
|
|
})
|
|
.collect();
|
|
let target = if dir < 0 {
|
|
cands
|
|
.iter()
|
|
.filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0)
|
|
.max_by(|a, b| a.top.total_cmp(&b.top))
|
|
.or_else(|| cands.iter().max_by(|a, b| a.top.total_cmp(&b.top)))
|
|
} else {
|
|
cands
|
|
.iter()
|
|
.filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0)
|
|
.min_by(|a, b| a.top.total_cmp(&b.top))
|
|
.or_else(|| cands.iter().min_by(|a, b| a.top.total_cmp(&b.top)))
|
|
};
|
|
if let Some(l) = target {
|
|
let off = l.para.off_at_x(l.li, gx - l.x);
|
|
return Some((Pos { story: l.story, path: l.path.clone(), off }, gx));
|
|
}
|
|
// Next/previous page.
|
|
let np = if dir < 0 { c.page.checked_sub(1)? } else { c.page + 1 };
|
|
let p = self.pages.get(np)?;
|
|
let lines = page_lines(p, pos.story);
|
|
let l = if dir < 0 { lines.iter().max_by(|a, b| a.top.total_cmp(&b.top))? } else { lines.iter().min_by(|a, b| a.top.total_cmp(&b.top))? };
|
|
let off = l.para.off_at_x(l.li, gx - l.x);
|
|
Some((Pos { story: l.story, path: l.path.clone(), off }, gx))
|
|
}
|
|
|
|
/// Start and end of the visual line holding `pos` (Home / End).
|
|
pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {
|
|
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
|
let Placed::Lines { para, l0, l1, .. } = it else { continue };
|
|
let li = para.line_of(pos.off);
|
|
if li < *l0 || li >= *l1 {
|
|
continue;
|
|
}
|
|
let l = para.lines.get(li)?;
|
|
let mut end = l.stop;
|
|
// Don't put the caret after a trailing break on that line, nor past wrap spaces.
|
|
if matches!(l.end, crate::LineEnd::LineBreak | crate::LineEnd::PageBreak | crate::LineEnd::ColumnBreak) && end > l.start {
|
|
end = para.clusters.get(l.c1.saturating_sub(1)).map(|c| c.start).unwrap_or(end);
|
|
} else if l.end == crate::LineEnd::Wrap {
|
|
end = para.off_at_x(li, l.right + 1000.0);
|
|
}
|
|
let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };
|
|
return Some((mk(l.start), mk(end)));
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Highlight rectangles for the selection `a..b`, per page.
|
|
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
|
|
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
|
let mut out = Vec::new();
|
|
for path in doc.paths_between(a, b) {
|
|
for (pi, it) in self.pieces(a.story, &path, page_hint) {
|
|
let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
|
|
let Some(first) = para.lines.get(*l0) else { continue };
|
|
for li in *l0..*l1 {
|
|
let Some(l) = para.lines.get(li) else { continue };
|
|
let from = if path == a.path { a.off.max(l.start) } else { l.start };
|
|
let to = if path == b.path { b.off.min(l.stop) } else { l.stop };
|
|
if path == a.path && a.off > l.stop {
|
|
continue;
|
|
}
|
|
if path == b.path && b.off < l.start {
|
|
continue;
|
|
}
|
|
if from > to {
|
|
continue;
|
|
}
|
|
let x0 = para.x_of(li, from).unwrap_or(l.left);
|
|
let mut x1 = para.x_of(li, to).unwrap_or(x0);
|
|
// Selection continuing past the paragraph end shows the paragraph mark.
|
|
let last_line = li + 1 == para.lines.len();
|
|
if last_line && path != b.path {
|
|
x1 += 5.0;
|
|
}
|
|
if path == b.path && to >= l.stop && li + 1 < para.lines.len() && b.off > l.stop {
|
|
x1 = para.x_of(li, l.stop).unwrap_or(x1);
|
|
}
|
|
if x1 <= x0 && !(last_line && path != b.path) {
|
|
continue;
|
|
}
|
|
let top = y + (l.top - first.top);
|
|
out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height)));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Where the equation at `pos` (its U+FFFC) is drawn: page, x of its left edge and its
|
|
/// baseline (page coordinates), and its layout.
|
|
pub fn equation_geom(&self, pos: &Pos, page_hint: usize) -> Option<(usize, f32, f32, std::sync::Arc<crate::math::MathLayout>)> {
|
|
for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) {
|
|
let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
|
|
let li = para.line_of(pos.off);
|
|
if li < *l0 || li >= *l1 {
|
|
continue;
|
|
}
|
|
let first = para.lines.get(*l0)?;
|
|
let l = para.lines.get(li)?;
|
|
for k in l.c0..l.c1 {
|
|
let Some(c) = para.clusters.get(k) else { continue };
|
|
let crate::para::ClKind::Object(oi) = c.kind else { continue };
|
|
if c.start != pos.off {
|
|
continue;
|
|
}
|
|
let ml = para.maths.iter().find(|(o, _)| *o == oi)?.1.clone();
|
|
let cx = l.xs.get(k - l.c0).copied()?;
|
|
let base = y + (l.baseline - first.top);
|
|
return Some((pi, x + cx, base, ml));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// The equation under (x, y) on `page` and the caret position inside it nearest the point.
|
|
pub fn equation_hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<(Pos, wordcraft_doc::math_edit::MathPos)> {
|
|
let p = self.pages.get(page)?;
|
|
for l in page_lines(p, story) {
|
|
let Some(line) = l.para.lines.get(l.li) else { continue };
|
|
let base = l.top + (line.baseline - line.top);
|
|
for k in line.c0..line.c1 {
|
|
let Some(c) = l.para.clusters.get(k) else { continue };
|
|
let crate::para::ClKind::Object(oi) = c.kind else { continue };
|
|
let Some((_, ml)) = l.para.maths.iter().find(|(o, _)| *o == oi) else { continue };
|
|
let x0 = l.x + line.xs.get(k - line.c0).copied().unwrap_or(0.0);
|
|
let pad = 2.0;
|
|
if x < x0 - pad || x > x0 + ml.width + pad || y < base - ml.ascent - pad || y > base + ml.descent + pad {
|
|
continue;
|
|
}
|
|
let slot = ml.hit(x - x0, base - y)?;
|
|
let pos = Pos { story: l.story, path: l.path.clone(), off: c.start };
|
|
return Some((pos, wordcraft_doc::math_edit::MathPos { path: slot.path.clone(), off: slot.off }));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Page index for a body position (first page showing its line).
|
|
pub fn page_of(&self, pos: &Pos) -> Option<usize> {
|
|
self.caret(pos).map(|c| c.page)
|
|
}
|
|
|
|
/// Table cell under a point: (story, table path, row, cell).
|
|
pub fn cell_at(&self, page: usize, x: f32, y: f32) -> Option<(StoryRef, Path, usize, usize)> {
|
|
let p = self.pages.get(page)?;
|
|
p.items.iter().rev().find_map(|it| match it {
|
|
Placed::Cell { rect, table, row, cell, story } if rect.contains(wordcraft_geom::Point::new(x, y)) => {
|
|
Some((*story, table.clone(), *row, *cell))
|
|
}
|
|
_ => None,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn score(l: &LineHit, x: f32, y: f32) -> f32 {
|
|
let dy = if y < l.top {
|
|
l.top - y
|
|
} else if y > l.bottom {
|
|
y - l.bottom
|
|
} else {
|
|
0.0
|
|
};
|
|
let dx = if x < l.left - 4.0 {
|
|
l.left - 4.0 - x
|
|
} else if x > l.right + 4.0 {
|
|
x - l.right - 4.0
|
|
} else {
|
|
0.0
|
|
};
|
|
dy * 4.0 + dx
|
|
}
|