pub mod geometry;
pub mod hit;
pub mod state;
use std::collections::BTreeSet;
use eframe::egui::{Color32, Modifiers, Pos2, Rect, Vec2};
use crate::annotate::{Annotation, Shape, Style, Tool, marker_target};
use crate::document::{AnnotationId, Document};
use crate::editor::geometry::{
HANDLE_HIT, clamp_rect_within, resize_rect, rotate_around, translate_shape,
};
use crate::editor::hit::{Measure, annotation_bbox, handle_at, item_handles, topmost_hit};
use crate::editor::state::{EditorState, ItemDragKind, RegionMode, TextEditState};
use crate::view::View;
#[derive(PartialEq, Eq, Debug)]
pub enum EscapeOutcome {
Consumed,
Quit,
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum StatusKind {
Hint,
Alert,
}
pub struct Editor {
pub doc: Document,
pub state: EditorState,
pub tool: Tool,
pub selected: BTreeSet<AnnotationId>,
pub style: Style,
pub palette: Vec<Color32>,
pub color_picker: Option<Color32>,
pub view: View,
pub persist_style: bool,
style_adjusting: bool,
space_panned: bool,
}
impl Editor {
pub fn new(doc: Document, style: Style, palette: Vec<Color32>, persist_style: bool) -> Self {
Self {
doc,
state: EditorState::Idle,
tool: Tool::Select,
selected: BTreeSet::new(),
style,
palette,
color_picker: None,
view: View::new(),
persist_style,
style_adjusting: false,
space_panned: false,
}
}
pub fn single_selected(&self) -> Option<AnnotationId> {
if self.selected.len() == 1 { self.selected.iter().next().copied() } else { None }
}
pub fn editing_target(&self) -> Option<AnnotationId> {
match &self.state {
EditorState::TextEditing(edit) => edit.target,
_ => None,
}
}
pub fn tick(&mut self, now: f64) {
if let EditorState::ConfirmDiscard { until } = self.state
&& now >= until
{
self.state = EditorState::Idle;
}
}
pub fn dismiss_transient(&mut self) {
if self.state.is_confirm_discard() {
self.state = EditorState::Idle;
}
}
pub fn status(&self) -> (StatusKind, String) {
let hint = |s: &str| (StatusKind::Hint, s.to_owned());
match &self.state {
EditorState::ConfirmDiscard { .. } => {
(StatusKind::Alert, "Press Esc again to close and discard".to_owned())
}
EditorState::TextEditing(_) => {
hint("Type · Enter: done · Shift+Enter: new line · Esc: cancel")
}
EditorState::DrawingShape { .. } => hint("Release to place · Esc: cancel"),
EditorState::RegionDraw { .. } => hint("Release to set the region · Esc: cancel"),
EditorState::RegionAdjust { .. } => hint("Release to keep · Esc: put it back"),
EditorState::ItemsDrag { .. } => hint("Release to keep · Esc: put it back"),
EditorState::RubberBand { .. } => hint("Release to select what's inside"),
EditorState::Panning => hint("Panning — release to stop"),
EditorState::Idle => {
if self.doc.region.is_none() {
return hint(
"Drag out a region · Enter: copy whole screen · scroll: zoom",
);
}
match self.tool {
Tool::Select if !self.selected.is_empty() => (
StatusKind::Hint,
format!(
"{} selected · drag: move · Shift+drag: add · Del: delete · Esc: deselect",
self.selected.len()
),
),
Tool::Select => hint(
"Drag a box to select · Shift+drag: add · click an item · right-drag: new region",
),
Tool::Text => hint("Click to place text · click existing text to edit it"),
Tool::Marker => (
StatusKind::Hint,
format!(
"Click to drop marker {} · drag to pull an arrow out of it",
self.doc.marker_next
),
),
tool => (
StatusKind::Hint,
format!(
"Drag to draw a {} · tap Space for Select · right-drag: new region",
tool.label()
),
),
}
}
}
}
pub fn promote_color(&mut self, color: Color32) {
self.palette.retain(|c| *c != color);
self.palette.insert(0, color);
self.palette.truncate(6);
}
pub fn apply_color(&mut self, color: Color32) {
self.dismiss_transient();
self.promote_color(color);
if let EditorState::TextEditing(edit) = &mut self.state {
edit.style.color = color;
return;
}
if self.tool == Tool::Select && !self.selected.is_empty() {
let changes = self
.selected
.iter()
.any(|id| self.doc.get(*id).is_some_and(|a| a.style.color != color));
if changes {
self.doc.begin();
for id in self.selected.iter() {
if let Some(ann) = self.doc.get_mut(*id) {
ann.style.color = color;
}
}
self.doc.commit();
}
return;
}
self.style.color = color;
}
pub fn shown_style(&self) -> Style {
if let EditorState::TextEditing(edit) = &self.state {
return edit.style;
}
if self.tool == Tool::Select
&& let Some((_, ann)) =
self.doc.annotations().iter().find(|(id, _)| self.selected.contains(id))
{
return ann.style;
}
self.style
}
pub fn adjust_style(&mut self, width: Option<f32>, font_size: Option<f32>) {
if self.state.is_pointer_op() {
return;
}
self.dismiss_transient();
let apply = |style: &mut Style| {
if let Some(width) = width {
style.width = width;
}
if let Some(font_size) = font_size {
style.font_size = font_size;
}
};
if let EditorState::TextEditing(edit) = &mut self.state {
apply(&mut edit.style);
return;
}
if self.tool == Tool::Select && !self.selected.is_empty() {
if !self.style_adjusting {
self.doc.begin();
self.style_adjusting = true;
}
for id in self.selected.iter() {
if let Some(ann) = self.doc.get_mut(*id) {
apply(&mut ann.style);
}
}
return;
}
apply(&mut self.style);
self.persist_style = true;
}
pub fn end_style_adjust(&mut self) {
if self.style_adjusting {
self.doc.commit();
self.style_adjusting = false;
}
}
pub fn set_tool(&mut self, tool: Tool) {
if self.state.is_pointer_op() {
return;
}
self.dismiss_transient();
if self.state.is_text_editing() {
self.commit_text();
}
self.selected.clear();
self.tool = tool;
}
pub fn reset_all(&mut self) {
self.cancel_pointer_op();
self.dismiss_transient();
if self.state.is_text_editing() {
self.state = EditorState::Idle; }
self.doc.begin();
self.doc.clear_annotations();
self.doc.marker_next = 1;
self.doc.region = None;
self.doc.commit();
self.selected.clear();
self.color_picker = None;
self.tool = Tool::Select;
self.view.fitted = false; }
pub fn undo(&mut self) {
if self.state.is_pointer_op() {
self.cancel_pointer_op();
return;
}
if self.state.is_text_editing() {
self.commit_text();
}
self.dismiss_transient();
self.end_style_adjust();
self.doc.undo();
self.prune_selection();
}
pub fn redo(&mut self) {
if self.state.is_pointer_op() {
self.cancel_pointer_op();
return;
}
if self.state.is_text_editing() {
self.commit_text();
}
self.dismiss_transient();
self.end_style_adjust();
self.doc.redo();
self.prune_selection();
}
pub fn cancel_pointer_op(&mut self) {
if !self.state.is_pointer_op() {
return;
}
match std::mem::replace(&mut self.state, EditorState::Idle) {
EditorState::RegionDraw { .. }
| EditorState::RegionAdjust { .. }
| EditorState::ItemsDrag { .. } => self.doc.rollback(),
_ => {}
}
}
fn prune_selection(&mut self) {
self.selected.retain(|id| self.doc.contains(*id));
}
pub fn escape(&mut self, now: f64) -> EscapeOutcome {
if self.state.is_pointer_op() {
let was_pan = matches!(self.state, EditorState::Panning);
self.cancel_pointer_op();
if !was_pan {
return EscapeOutcome::Consumed;
}
}
if self.color_picker.take().is_some() {
return EscapeOutcome::Consumed;
}
if self.state.is_text_editing() {
self.cancel_text();
return EscapeOutcome::Consumed;
}
if !self.selected.is_empty() || self.tool != Tool::Select {
self.selected.clear();
self.tool = Tool::Select;
return EscapeOutcome::Consumed;
}
match self.state {
EditorState::ConfirmDiscard { until } if now < until => EscapeOutcome::Quit,
_ => {
self.state = EditorState::ConfirmDiscard { until: now + 1.0 };
EscapeOutcome::Consumed
}
}
}
pub fn select_all(&mut self) {
if self.state.is_pointer_op() {
return;
}
if self.state.is_text_editing() {
self.commit_text();
}
self.dismiss_transient();
self.tool = Tool::Select;
self.selected = self.doc.ids().collect();
}
pub fn delete_selected(&mut self) {
self.dismiss_transient();
if self.tool != Tool::Select || !self.state.is_idle() || self.selected.is_empty() {
return;
}
self.doc.begin();
for id in std::mem::take(&mut self.selected) {
self.doc.remove(id);
}
self.doc.commit();
}
pub fn hovered_item(&self, p: Pos2, measure: Measure) -> Option<AnnotationId> {
if !self.state.is_idle() || self.tool != Tool::Select {
return None;
}
topmost_hit(&self.doc, p, false, self.view.zoom, measure)
}
pub fn handle_under(
&self,
screen: Pos2,
canvas: Rect,
measure: Measure,
) -> Option<ItemDragKind> {
if self.tool != Tool::Select {
return None;
}
let id = self.single_selected()?;
let ann = self.doc.get(id)?;
handle_at(&item_handles(ann, &self.view, canvas, measure), screen)
}
pub fn primary_drag_start(
&mut self,
p: Pos2,
screen: Pos2,
canvas: Rect,
mods: Modifiers,
measure: Measure,
) {
if self.state.is_text_editing() {
self.commit_text();
return;
}
self.dismiss_transient();
if !self.state.is_idle() {
return;
}
let img_rect = self.doc.image_rect();
let clamped = p.clamp(img_rect.min, img_rect.max);
if self.tool == Tool::Select {
if let Some(kind) = self.handle_under(screen, canvas, measure)
&& let Some(id) = self.single_selected()
&& let Some(ann) = self.doc.get(id).cloned()
{
let bbox = annotation_bbox(&ann, measure);
self.doc.begin();
self.state = EditorState::ItemsDrag {
items: vec![(id, ann)],
kind,
bbox0: bbox,
center: bbox.center(),
grab: p,
};
return;
}
if let Some(region) = self.doc.region {
let ss = self.view.rect_to_screen(canvas, region);
if geometry::region_grip_at(ss, screen) {
self.doc.begin();
self.state = EditorState::RegionAdjust {
start_rect: region,
mode: RegionMode::Move { grab: p },
};
return;
}
if let Some(edges) = geometry::region_handle_at(ss, screen) {
self.doc.begin();
self.state = EditorState::RegionAdjust {
start_rect: region,
mode: RegionMode::Resize { edges },
};
return;
}
}
if mods.shift {
self.state = EditorState::RubberBand { anchor: p, current: p, additive: true };
return;
}
if let Some(id) = topmost_hit(&self.doc, p, false, self.view.zoom, measure) {
if mods.command {
self.selected.insert(id);
} else if !self.selected.contains(&id) {
self.selected.clear();
self.selected.insert(id);
}
let items: Vec<(AnnotationId, Annotation)> = self
.doc
.annotations()
.iter()
.filter(|(i, _)| self.selected.contains(i))
.map(|(i, a)| (*i, a.clone()))
.collect();
let bbox = items
.iter()
.map(|(_, a)| annotation_bbox(a, measure))
.reduce(|a, b| a.union(b))
.unwrap_or(Rect::ZERO);
self.doc.begin();
self.state = EditorState::ItemsDrag {
items,
kind: ItemDragKind::Move,
bbox0: bbox,
center: bbox.center(),
grab: p,
};
return;
}
if self.doc.region.is_none() {
self.begin_region_draw(clamped);
} else {
self.state = EditorState::RubberBand { anchor: p, current: p, additive: false };
}
return;
}
if self.tool != Tool::Text {
self.state = EditorState::DrawingShape { start: p, current: p, points: vec![p] };
}
}
pub fn secondary_drag_start(&mut self, p: Pos2) {
if self.state.is_text_editing() {
self.commit_text();
return;
}
self.dismiss_transient();
if !self.state.is_idle() {
return;
}
let img_rect = self.doc.image_rect();
self.begin_region_draw(p.clamp(img_rect.min, img_rect.max));
}
fn begin_region_draw(&mut self, anchor: Pos2) {
self.doc.begin();
self.doc.region = None;
self.state = EditorState::RegionDraw { anchor };
}
pub fn pointer_moved(&mut self, p: Pos2) {
let img_rect = self.doc.image_rect();
let clamped = p.clamp(img_rect.min, img_rect.max);
match &mut self.state {
EditorState::DrawingShape { current, points, .. } => {
*current = p;
let threshold = (0.75 / self.view.zoom).max(0.25);
if self.tool == Tool::Pen
&& points.last().is_none_or(|last| last.distance(p) >= threshold)
{
points.push(p);
}
}
EditorState::RegionDraw { anchor } => {
let anchor = *anchor;
self.doc.region = Some(Rect::from_two_pos(anchor, clamped));
}
EditorState::RegionAdjust { start_rect, mode } => {
let (start_rect, mode) = (*start_rect, *mode);
self.doc.region = Some(match mode {
RegionMode::Move { grab } => {
clamp_rect_within(start_rect.translate(p - grab), img_rect)
}
RegionMode::Resize { edges } => resize_rect(start_rect, edges, clamped),
});
}
EditorState::ItemsDrag { items, kind, bbox0, center, grab } => {
let (kind, bbox0, center, grab) = (*kind, *bbox0, *center, *grab);
for (id, start) in items.iter() {
let ann = dragged_item(start, kind, bbox0, center, grab, p);
if let Some(slot) = self.doc.get_mut(*id) {
*slot = ann;
}
}
}
EditorState::RubberBand { current, .. } => *current = p,
EditorState::Idle
| EditorState::Panning
| EditorState::TextEditing(_)
| EditorState::ConfirmDiscard { .. } => {}
}
}
pub fn pointer_up(&mut self, measure: Measure) {
match std::mem::replace(&mut self.state, EditorState::Idle) {
EditorState::DrawingShape { start, current, points } => {
self.finish_drawing(start, current, points);
}
EditorState::RegionDraw { .. } => {
if let Some(r) = self.doc.region
&& (r.width() < 4.0 || r.height() < 4.0)
{
self.doc.rollback();
} else {
self.doc.commit();
}
}
EditorState::RegionAdjust { .. } | EditorState::ItemsDrag { .. } => self.doc.commit(),
EditorState::RubberBand { anchor, current, additive } => {
let band = Rect::from_two_pos(anchor, current);
if !additive {
self.selected.clear();
}
let hits: Vec<AnnotationId> = self
.doc
.annotations()
.iter()
.filter(|(_, ann)| band.intersects(annotation_bbox(ann, measure)))
.map(|(id, _)| *id)
.collect();
self.selected.extend(hits);
}
EditorState::Panning => {}
other @ (EditorState::Idle
| EditorState::TextEditing(_)
| EditorState::ConfirmDiscard { .. }) => self.state = other,
}
}
pub fn click(&mut self, p: Pos2, screen: Pos2, canvas: Rect, mods: Modifiers, measure: Measure) {
if self.state.is_text_editing() {
self.commit_text();
if self.tool == Tool::Text {
self.text_tool_click(p, measure);
}
return;
}
self.dismiss_transient();
match self.tool {
Tool::Text => self.text_tool_click(p, measure),
Tool::Marker => self.drop_marker(p, None),
Tool::Select => {
if let Some(id) = topmost_hit(&self.doc, p, false, self.view.zoom, measure) {
if mods.command || mods.shift {
if !self.selected.remove(&id) {
self.selected.insert(id);
}
} else {
self.selected.clear();
self.selected.insert(id);
}
} else if mods.command || mods.shift {
} else if !self.selected.is_empty() {
self.selected.clear();
} else if self.doc.region.is_some_and(|r| {
!self.view.rect_to_screen(canvas, r).expand(HANDLE_HIT).contains(screen)
}) {
self.doc.begin();
self.doc.region = None;
self.doc.commit();
}
}
_ => {}
}
}
pub fn double_click(&mut self, p: Pos2, measure: Measure) {
self.dismiss_transient();
if self.tool == Tool::Marker || self.state.is_text_editing() {
return;
}
if let Some(id) = topmost_hit(&self.doc, p, true, self.view.zoom, measure) {
self.open_text_editor(id);
}
}
pub fn begin_space_pan(&mut self) {
self.dismiss_transient();
if self.state.is_idle() {
self.state = EditorState::Panning;
self.space_panned = true;
}
}
pub fn space_released(&mut self) {
if matches!(self.state, EditorState::Panning) {
self.state = EditorState::Idle;
}
if !self.space_panned {
self.set_tool(Tool::Select);
}
self.space_panned = false;
}
fn finish_drawing(&mut self, start: Pos2, current: Pos2, points: Vec<Pos2>) {
if self.tool == Tool::Marker {
self.drop_marker(start, Some(current));
return;
}
let min_px = 3.0 / self.view.zoom; let rect = Rect::from_two_pos(start, current);
let shape = match self.tool {
Tool::Pen if !points.is_empty() => Some(Shape::Pen { points }),
Tool::Line if (current - start).length() >= min_px => {
Some(Shape::Line { a: start, b: current })
}
Tool::Arrow if (current - start).length() >= min_px => {
Some(Shape::Arrow { a: start, b: current })
}
Tool::Rect | Tool::Ellipse | Tool::Highlight | Tool::Pixelate
if rect.width() >= min_px && rect.height() >= min_px =>
{
Some(match self.tool {
Tool::Rect => Shape::Rect { rect },
Tool::Ellipse => Shape::Ellipse { rect },
Tool::Highlight => Shape::Highlight { rect },
_ => Shape::Pixelate { rect },
})
}
_ => None,
};
if let Some(shape) = shape {
self.doc.begin();
self.doc.push(Annotation::new(shape, self.style));
self.doc.commit();
self.promote_color(self.style.color);
}
}
pub fn drop_marker(&mut self, pos: Pos2, target: Option<Pos2>) {
let img = self.doc.image_rect();
let pos = pos.clamp(img.min, img.max);
let target = marker_target(pos, target, &self.style);
self.doc.begin();
let number = self.doc.marker_next;
self.doc.push(Annotation::new(Shape::Marker { pos, number, target }, self.style));
self.doc.marker_next += 1;
self.doc.commit();
self.promote_color(self.style.color);
}
pub fn text_tool_click(&mut self, p: Pos2, measure: Measure) {
if let Some(id) = topmost_hit(&self.doc, p, true, self.view.zoom, measure) {
self.open_text_editor(id);
} else {
let img = self.doc.image_rect();
self.state = EditorState::TextEditing(TextEditState {
target: None,
pos: p.clamp(img.min, img.max),
buffer: String::new(),
style: self.style,
just_created: true,
});
}
}
pub fn open_text_editor(&mut self, id: AnnotationId) {
if let Some(ann) = self.doc.get(id)
&& let Shape::Text { pos, text } = &ann.shape
{
self.state = EditorState::TextEditing(TextEditState {
target: Some(id),
pos: *pos,
buffer: text.clone(),
style: ann.style,
just_created: true,
});
self.selected.clear();
self.selected.insert(id);
}
}
pub fn commit_text(&mut self) {
if !self.state.is_text_editing() {
return;
}
let EditorState::TextEditing(edit) = std::mem::replace(&mut self.state, EditorState::Idle)
else {
return;
};
let text = edit.buffer.trim_end().to_owned();
match edit.target {
Some(id) => {
let Some(ann) = self.doc.get(id) else { return };
let Shape::Text { text: old, .. } = &ann.shape else { return };
if text.is_empty() {
self.doc.begin();
self.doc.remove(id);
self.doc.commit();
self.selected.remove(&id);
} else if text != *old || edit.style != ann.style {
self.doc.begin();
if let Some(ann) = self.doc.get_mut(id) {
ann.style = edit.style;
if let Shape::Text { text: slot, .. } = &mut ann.shape {
*slot = text;
}
}
self.doc.commit();
}
}
None => {
if !text.is_empty() {
self.doc.begin();
self.doc
.push(Annotation::new(Shape::Text { pos: edit.pos, text }, edit.style));
self.doc.commit();
self.promote_color(edit.style.color);
}
}
}
}
pub fn cancel_text(&mut self) {
if self.state.is_text_editing() {
self.state = EditorState::Idle;
}
}
}
fn dragged_item(
start: &Annotation,
kind: ItemDragKind,
bbox0: Rect,
center: Pos2,
grab: Pos2,
p: Pos2,
) -> Annotation {
let mut ann = start.clone();
match kind {
ItemDragKind::Move => translate_shape(&mut ann.shape, p - grab),
ItemDragKind::Endpoint { second } => match &mut ann.shape {
Shape::Line { a, b } | Shape::Arrow { a, b } => {
if second {
*b = p;
} else {
*a = p;
}
}
Shape::Marker { pos, target, .. } => {
if second {
*target = Some(p);
} else {
*pos = p;
}
}
_ => {}
},
ItemDragKind::Resize { edges } => match &mut ann.shape {
Shape::Rect { rect }
| Shape::Ellipse { rect }
| Shape::Highlight { rect }
| Shape::Pixelate { rect } => {
let c0 = bbox0.center();
let pl = rotate_around(p, c0, -ann.rotation);
let r1 = resize_rect(bbox0, edges, pl);
let c1w = rotate_around(r1.center(), c0, ann.rotation);
*rect = r1.translate(c1w - r1.center());
}
Shape::Pen { points } => {
let r1 = resize_rect(bbox0, edges, p);
let s0 = bbox0.size().max(Vec2::splat(1.0));
for q in points.iter_mut() {
let t = (*q - bbox0.min) / s0;
*q = r1.min + t * r1.size();
}
}
_ => {}
},
ItemDragKind::ScaleUniform => {
let denom = (grab - center).length().max(1.0);
let factor = ((p - center).length() / denom).clamp(0.05, 20.0);
ann.style.font_size = (ann.style.font_size * factor).clamp(6.0, 400.0);
}
ItemDragKind::Rotate => {
let delta = (p - center).angle() - (grab - center).angle();
match &mut ann.shape {
Shape::Pen { points } => {
for q in points.iter_mut() {
*q = rotate_around(*q, center, delta);
}
}
Shape::Line { a, b } | Shape::Arrow { a, b } => {
*a = rotate_around(*a, center, delta);
*b = rotate_around(*b, center, delta);
}
_ => ann.rotation += delta,
}
}
}
ann
}
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
fn measure(_: &str, size: f32) -> Vec2 {
Vec2::new(size * 5.0, size)
}
fn canvas() -> Rect {
Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0))
}
fn editor() -> Editor {
let doc = Document::new(RgbaImage::new(800, 600));
let style = Style { color: Color32::RED, width: 4.0, font_size: 24.0 };
let mut ed = Editor::new(doc, style, vec![Color32::RED], false);
ed.view.fitted = true; ed
}
fn add_highlight(ed: &mut Editor, r: Rect) -> AnnotationId {
ed.doc.begin();
let id = ed.doc.push(Annotation::new(Shape::Highlight { rect: r }, ed.style));
ed.doc.commit();
id
}
#[test]
fn draw_rect_lifecycle_creates_one_undo_step() {
let mut ed = editor();
ed.set_tool(Tool::Rect);
ed.primary_drag_start(
Pos2::new(10.0, 10.0),
Pos2::new(10.0, 10.0),
canvas(),
Modifiers::NONE,
&measure,
);
assert!(matches!(ed.state, EditorState::DrawingShape { .. }));
ed.pointer_moved(Pos2::new(60.0, 40.0));
ed.pointer_up(&measure);
assert!(ed.state.is_idle());
assert_eq!(ed.doc.annotations().len(), 1);
assert!(ed.doc.can_undo());
ed.undo();
assert_eq!(ed.doc.annotations().len(), 0);
}
#[test]
fn escape_reverts_item_drag_without_history() {
let mut ed = editor();
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
let id = add_highlight(&mut ed, r);
let undo_before = ed.doc.can_undo();
ed.set_tool(Tool::Select);
ed.primary_drag_start(
Pos2::new(150.0, 120.0),
Pos2::new(150.0, 120.0),
canvas(),
Modifiers::NONE,
&measure,
);
assert!(matches!(ed.state, EditorState::ItemsDrag { .. }));
assert_eq!(ed.selected.len(), 1);
ed.pointer_moved(Pos2::new(300.0, 300.0));
ed.escape(0.0);
assert!(ed.state.is_idle());
let Shape::Highlight { rect } = ed.doc.get(id).expect("still there").shape else {
panic!("shape changed")
};
assert_eq!(rect, r);
assert_eq!(ed.doc.can_undo(), undo_before); }
#[test]
fn noop_item_drag_burns_no_undo_slot() {
let mut ed = editor();
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
add_highlight(&mut ed, r);
ed.set_tool(Tool::Select);
let undo_len_matters = ed.doc.can_undo();
ed.primary_drag_start(
Pos2::new(150.0, 120.0),
Pos2::new(150.0, 120.0),
canvas(),
Modifiers::NONE,
&measure,
);
ed.pointer_up(&measure); assert_eq!(ed.doc.can_undo(), undo_len_matters);
}
#[test]
fn rubber_band_multi_selects_and_group_deletes() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
let b = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(50.0, 10.0), Pos2::new(70.0, 30.0)));
let far = add_highlight(
&mut ed,
Rect::from_min_max(Pos2::new(400.0, 400.0), Pos2::new(420.0, 420.0)),
);
ed.set_tool(Tool::Select);
ed.primary_drag_start(
Pos2::new(0.0, 0.0),
Pos2::new(0.0, 0.0),
canvas(),
Modifiers::SHIFT,
&measure,
);
assert!(matches!(ed.state, EditorState::RubberBand { .. }));
ed.pointer_moved(Pos2::new(100.0, 100.0));
ed.pointer_up(&measure);
assert!(ed.selected.contains(&a) && ed.selected.contains(&b));
assert!(!ed.selected.contains(&far));
ed.delete_selected();
assert_eq!(ed.doc.annotations().len(), 1);
ed.undo();
assert_eq!(ed.doc.annotations().len(), 3);
assert!(ed.selected.is_empty()); }
#[test]
fn plain_drag_on_empty_rubber_bands_and_replaces() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(130.0, 130.0)));
let b = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(150.0, 100.0), Pos2::new(180.0, 130.0)));
let far = add_highlight(
&mut ed,
Rect::from_min_max(Pos2::new(400.0, 400.0), Pos2::new(420.0, 420.0)),
);
ed.doc.region = Some(ed.doc.image_rect());
ed.set_tool(Tool::Select);
ed.selected.insert(far); ed.primary_drag_start(
Pos2::new(90.0, 90.0),
Pos2::new(90.0, 90.0),
canvas(),
Modifiers::NONE,
&measure,
);
assert!(matches!(ed.state, EditorState::RubberBand { additive: false, .. }));
ed.pointer_moved(Pos2::new(200.0, 200.0));
ed.pointer_up(&measure);
assert!(ed.selected.contains(&a) && ed.selected.contains(&b));
assert!(!ed.selected.contains(&far), "a plain band replaces the selection");
}
#[test]
fn plain_drag_draws_region_when_none_exists() {
let mut ed = editor();
assert!(ed.doc.region.is_none());
ed.primary_drag_start(
Pos2::new(50.0, 50.0),
Pos2::new(50.0, 50.0),
canvas(),
Modifiers::NONE,
&measure,
);
assert!(matches!(ed.state, EditorState::RegionDraw { .. }));
}
#[test]
fn shift_drag_adds_to_the_selection() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(130.0, 130.0)));
let b = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(150.0, 100.0), Pos2::new(180.0, 130.0)));
ed.set_tool(Tool::Select);
ed.selected.insert(a);
ed.primary_drag_start(
Pos2::new(200.0, 200.0),
Pos2::new(200.0, 200.0),
canvas(),
Modifiers::SHIFT,
&measure,
);
assert!(matches!(ed.state, EditorState::RubberBand { additive: true, .. }));
ed.pointer_moved(Pos2::new(155.0, 105.0));
ed.pointer_up(&measure);
assert!(ed.selected.contains(&a), "shift keeps the existing selection");
assert!(ed.selected.contains(&b), "shift adds the band's contents");
}
#[test]
fn region_move_grip_moves_the_region() {
let mut ed = editor();
let region = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(300.0, 200.0));
ed.doc.region = Some(region);
ed.set_tool(Tool::Select);
let grip = geometry::region_move_grip(region);
ed.primary_drag_start(grip, grip, canvas(), Modifiers::NONE, &measure);
assert!(matches!(
ed.state,
EditorState::RegionAdjust { mode: RegionMode::Move { .. }, .. }
));
ed.pointer_moved(grip + Vec2::new(20.0, 20.0));
ed.pointer_up(&measure);
assert_eq!(ed.doc.region, Some(region.translate(Vec2::new(20.0, 20.0))));
}
#[test]
fn ctrl_click_toggles_membership() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
let b = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(50.0, 10.0), Pos2::new(70.0, 30.0)));
ed.set_tool(Tool::Select);
ed.click(Pos2::new(20.0, 20.0), Pos2::new(20.0, 20.0), canvas(), Modifiers::NONE, &measure);
assert_eq!(ed.selected.iter().copied().collect::<Vec<_>>(), vec![a]);
ed.click(Pos2::new(60.0, 20.0), Pos2::new(60.0, 20.0), canvas(), Modifiers::COMMAND, &measure);
assert!(ed.selected.contains(&a) && ed.selected.contains(&b));
ed.click(Pos2::new(20.0, 20.0), Pos2::new(20.0, 20.0), canvas(), Modifiers::COMMAND, &measure);
assert!(!ed.selected.contains(&a) && ed.selected.contains(&b));
}
#[test]
fn escape_restores_region_lost_to_accidental_redraw() {
let mut ed = editor();
let region = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(300.0, 200.0));
ed.doc.begin();
ed.doc.region = Some(region);
ed.doc.commit();
ed.secondary_drag_start(Pos2::new(400.0, 400.0));
ed.pointer_moved(Pos2::new(500.0, 500.0));
assert_ne!(ed.doc.region, Some(region));
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed);
assert_eq!(ed.doc.region, Some(region));
}
#[test]
fn undo_with_open_text_edit_cannot_corrupt_other_annotations() {
let mut ed = editor();
ed.doc.begin();
let t1 = ed.doc.push(Annotation::new(
Shape::Text { pos: Pos2::new(10.0, 10.0), text: "one".into() },
ed.style,
));
ed.doc.commit();
ed.doc.begin();
let t2 = ed.doc.push(Annotation::new(
Shape::Text { pos: Pos2::new(10.0, 60.0), text: "two".into() },
ed.style,
));
ed.doc.commit();
ed.open_text_editor(t2);
if let EditorState::TextEditing(edit) = &mut ed.state {
edit.buffer = "edited".into();
}
ed.undo();
let Shape::Text { text, .. } = &ed.doc.get(t1).expect("t1 alive").shape else {
panic!("t1 not text")
};
assert_eq!(text, "one");
assert!(ed.state.is_idle());
let Shape::Text { text, .. } = &ed.doc.get(t2).expect("t2 alive").shape else {
panic!("t2 not text")
};
assert_eq!(text, "two");
}
#[test]
fn escape_ladder_reaches_the_discard_confirmation() {
let mut ed = editor();
add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
ed.set_tool(Tool::Pen);
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed); assert_eq!(ed.tool, Tool::Select);
ed.click(Pos2::new(20.0, 20.0), Pos2::new(20.0, 20.0), canvas(), Modifiers::NONE, &measure);
assert!(!ed.selected.is_empty());
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed); assert!(ed.selected.is_empty());
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed);
assert!(ed.state.is_confirm_discard());
assert_eq!(ed.escape(0.5), EscapeOutcome::Quit);
}
#[test]
fn confirm_discard_expires_and_dismisses() {
let mut ed = editor();
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed);
assert!(ed.state.is_confirm_discard());
ed.tick(0.5);
assert!(ed.state.is_confirm_discard()); ed.tick(1.5);
assert!(ed.state.is_idle()); assert_eq!(ed.escape(2.0), EscapeOutcome::Consumed); ed.set_tool(Tool::Pen);
assert!(ed.state.is_idle());
assert_eq!(ed.tool, Tool::Pen);
}
#[test]
fn undo_mid_drag_cancels_only_the_drag() {
let mut ed = editor();
let r = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 150.0));
let id = add_highlight(&mut ed, r);
ed.set_tool(Tool::Select);
ed.primary_drag_start(
Pos2::new(150.0, 120.0),
Pos2::new(150.0, 120.0),
canvas(),
Modifiers::NONE,
&measure,
);
ed.pointer_moved(Pos2::new(300.0, 300.0));
ed.undo();
assert!(ed.state.is_idle());
let Shape::Highlight { rect } = ed.doc.get(id).expect("annotation survives").shape else {
panic!("shape changed")
};
assert_eq!(rect, r);
}
#[test]
fn missed_additive_click_keeps_selection() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
ed.set_tool(Tool::Select);
ed.selected.insert(a);
let miss = Pos2::new(300.0, 300.0);
ed.click(miss, miss, canvas(), Modifiers::COMMAND, &measure);
assert!(ed.selected.contains(&a), "ctrl-click miss must not wipe the selection");
ed.click(miss, miss, canvas(), Modifiers::NONE, &measure);
assert!(ed.selected.is_empty());
}
#[test]
fn degenerate_region_redraw_restores_previous_region() {
let mut ed = editor();
let region = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(300.0, 200.0));
ed.doc.begin();
ed.doc.region = Some(region);
ed.doc.commit();
ed.secondary_drag_start(Pos2::new(400.0, 400.0));
ed.pointer_moved(Pos2::new(430.0, 401.0)); ed.pointer_up(&measure);
assert_eq!(ed.doc.region, Some(region));
}
#[test]
fn escape_mid_pan_still_reaches_the_next_rung() {
let mut ed = editor();
ed.set_tool(Tool::Pen);
ed.begin_space_pan();
assert_eq!(ed.escape(0.0), EscapeOutcome::Consumed);
assert!(ed.state.is_idle());
assert_eq!(ed.tool, Tool::Select); }
#[test]
fn adjust_style_touches_only_the_moved_property() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
ed.doc.begin();
let b = ed.doc.push(Annotation::new(
Shape::Highlight {
rect: Rect::from_min_max(Pos2::new(50.0, 10.0), Pos2::new(70.0, 30.0)),
},
Style { color: Color32::RED, width: 2.0, font_size: 40.0 },
));
ed.doc.commit();
ed.set_tool(Tool::Select);
ed.selected.insert(a);
ed.selected.insert(b);
ed.adjust_style(Some(9.0), None);
ed.end_style_adjust();
assert_eq!(ed.doc.get(a).expect("a").style.width, 9.0);
assert_eq!(ed.doc.get(b).expect("b").style.width, 9.0);
assert_eq!(ed.doc.get(a).expect("a").style.font_size, 24.0);
assert_eq!(ed.doc.get(b).expect("b").style.font_size, 40.0);
assert_eq!(ed.style.width, 4.0);
}
#[test]
fn styling_while_typing_never_touches_new_object_defaults() {
let mut ed = editor();
let defaults = ed.style;
ed.set_tool(Tool::Text);
ed.text_tool_click(Pos2::new(50.0, 50.0), &measure);
assert!(ed.state.is_text_editing());
ed.adjust_style(Some(11.0), Some(60.0));
ed.apply_color(Color32::BLUE);
let EditorState::TextEditing(edit) = &mut ed.state else { panic!("still editing") };
assert_eq!(edit.style.width, 11.0);
assert_eq!(edit.style.font_size, 60.0);
assert_eq!(edit.style.color, Color32::BLUE);
edit.buffer = "styled".into();
ed.commit_text();
assert_eq!(ed.style, defaults);
ed.text_tool_click(Pos2::new(600.0, 400.0), &measure);
let EditorState::TextEditing(edit) = &ed.state else { panic!("editing again") };
assert_eq!(edit.style, defaults);
}
#[test]
fn group_move_translates_all_selected() {
let mut ed = editor();
let a = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(10.0, 10.0), Pos2::new(30.0, 30.0)));
let b = add_highlight(&mut ed, Rect::from_min_max(Pos2::new(50.0, 10.0), Pos2::new(70.0, 30.0)));
ed.set_tool(Tool::Select);
ed.selected.insert(a);
ed.selected.insert(b);
ed.primary_drag_start(
Pos2::new(20.0, 20.0),
Pos2::new(20.0, 20.0),
canvas(),
Modifiers::NONE,
&measure,
);
ed.pointer_moved(Pos2::new(120.0, 20.0)); ed.pointer_up(&measure);
let Shape::Highlight { rect: ra } = ed.doc.get(a).expect("a").shape else { panic!() };
let Shape::Highlight { rect: rb } = ed.doc.get(b).expect("b").shape else { panic!() };
assert_eq!(ra.min, Pos2::new(110.0, 10.0));
assert_eq!(rb.min, Pos2::new(150.0, 10.0));
assert_eq!(ed.selected.len(), 2);
}
}