use std::cell::OnceCell;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use anyhow::Result;
use image::RgbaImage;
use pixelcoords_core::config::{SnapSettings, Style};
use pixelcoords_core::geometry::{Line, Point, Rect, ResizeHandle, Shape, Size, ToolKind};
use pixelcoords_core::hotkeys::{Action, Binding, Edge, KeyName, OverlayState, match_event};
use pixelcoords_core::locate::GrayImage;
use pixelcoords_core::selection::{GrabKind, Measure, MeasureGrab, Selection, SelectionSet};
use pixelcoords_core::session::TargetRecord;
use pixelcoords_core::snap::{EdgeMap, Snap};
use pixelcoords_core::strings::{EN, Strings};
use winit::application::ApplicationHandler;
use winit::event::{ElementState, KeyEvent, MouseButton, StartCause, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::{CursorIcon, WindowId};
use crate::capture::MonitorInfo;
use crate::render::{self, FrameState};
use crate::view::{OverlayView, Presentation};
const CARET_BLINK: Duration = Duration::from_millis(500);
const FLASH_SAVE: Duration = Duration::from_millis(2500);
const FLASH_TOOL: Duration = Duration::from_millis(1200);
const MAX_LABEL_LEN: usize = 64;
const GRAB_TOLERANCE: i32 = 6;
pub struct MonitorFrame {
pub info: MonitorInfo,
pub rgba: RgbaImage,
background: Vec<u32>,
size: Size,
draw_rect: pixelcoords_core::geometry::Rect,
ui_scale: i32,
edges: OnceCell<EdgeMap>,
}
impl MonitorFrame {
pub fn new(info: MonitorInfo, rgba: RgbaImage) -> Self {
let size = Size::new(rgba.width() as i32, rgba.height() as i32);
let background = rgba_to_0rgb(&rgba);
let ui_scale = (info.scale.round() as i32).max(1);
let draw_rect = pixelcoords_core::geometry::Rect::new(0, 0, size.w, size.h);
Self {
info,
rgba,
background,
size,
draw_rect,
ui_scale,
edges: OnceCell::new(),
}
}
pub fn edges(&self) -> &EdgeMap {
self.edges.get_or_init(|| {
EdgeMap::new(&GrayImage::from_rgba(
self.size.w.max(0) as usize,
self.size.h.max(0) as usize,
self.rgba.as_raw(),
))
})
}
pub fn with_draw_rect(mut self, rect: pixelcoords_core::geometry::Rect) -> Self {
let x = rect.x.max(0).min(self.size.w);
let y = rect.y.max(0).min(self.size.h);
let w = (rect.x + rect.w).min(self.size.w).max(x) - x;
let h = (rect.y + rect.h).min(self.size.h).max(y) - y;
if w > 0 && h > 0 {
self.draw_rect = pixelcoords_core::geometry::Rect::new(x, y, w, h);
}
self
}
pub fn draw_rect(&self) -> pixelcoords_core::geometry::Rect {
self.draw_rect
}
}
struct ViewSlot {
frame: usize,
view: OverlayView,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Release {
Done,
Blocked,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditTarget {
Selection,
Measure,
}
enum Mode {
Idle,
Drawing {
frame: usize,
start: Point,
path: Vec<Point>,
},
Dragging {
frame: usize,
index: usize,
grab_offset: Point,
original: Shape,
},
Resizing {
frame: usize,
index: usize,
handle: ResizeHandle,
original: Shape,
},
MeasureDragging {
frame: usize,
index: usize,
grab: MeasureGrab,
original: Line,
grab_offset: Point,
},
LabelEditing {
target: EditTarget,
index: usize,
text: String,
},
SessionNaming {
text: String,
},
}
pub struct App {
frames: Vec<MonitorFrame>,
style: Style,
bindings: Vec<Binding>,
strings: &'static Strings,
out_dir: PathBuf,
target: Option<TargetRecord>,
session_meta: crate::save::SessionMeta,
presentation: Presentation,
views: Vec<ViewSlot>,
selections: SelectionSet,
tool: ToolKind,
cursor: Point,
cursor_frame: usize,
mode: Mode,
cursor_icon: CursorIcon,
shift_down: bool,
alt_down: bool,
panel_held: bool,
panel_hidden: bool,
loupe_held: bool,
polygon_sides: u32,
panel_origin: Option<(usize, Point)>,
dirty: bool,
saved_once: bool,
last_crops: Vec<crate::save::WrittenCrop>,
quit_armed_until: Option<Instant>,
flash: Option<(String, Instant)>,
caret_visible: bool,
caret_deadline: Option<Instant>,
error: Option<anyhow::Error>,
warned_render_failure: bool,
snap: SnapSettings,
snap_hit: Snap,
gesture_cursor: Point,
}
impl App {
pub fn new(
frames: Vec<MonitorFrame>,
style: Style,
bindings: Vec<Binding>,
out_dir: PathBuf,
target: Option<TargetRecord>,
presentation: Presentation,
) -> Self {
assert!(!frames.is_empty(), "at least one captured monitor");
Self {
frames,
style,
bindings,
strings: &EN,
out_dir,
target,
session_meta: crate::save::SessionMeta::default(),
presentation,
views: Vec::new(),
selections: SelectionSet::new(),
tool: ToolKind::Rect,
cursor: Point::new(0, 0),
cursor_frame: 0,
mode: Mode::Idle,
cursor_icon: CursorIcon::Crosshair,
shift_down: false,
alt_down: false,
panel_held: false,
panel_hidden: false,
loupe_held: false,
polygon_sides: 6,
panel_origin: None,
dirty: false,
saved_once: false,
last_crops: Vec::new(),
quit_armed_until: None,
flash: None,
caret_visible: true,
caret_deadline: None,
error: None,
warned_render_failure: false,
snap: SnapSettings::default(),
snap_hit: Snap::default(),
gesture_cursor: Point::new(0, 0),
}
}
pub fn set_snap(&mut self, settings: SnapSettings) {
self.snap = settings;
if settings.enabled {
for frame in &self.frames {
let _ = frame.edges();
}
}
}
fn snap_radius(&self) -> i32 {
if self.snap.enabled {
self.snap.radius * self.frames[self.cursor_frame].ui_scale
} else {
0
}
}
fn snap_cursor(&mut self) -> Point {
let radius = self.snap_radius();
if radius <= 0 {
self.snap_hit = Snap::default();
return self.cursor;
}
self.snap_hit = self.frames[self.cursor_frame]
.edges()
.snap(self.cursor, radius);
self.snap_hit.apply(self.cursor)
}
pub fn set_session_meta(&mut self, meta: crate::save::SessionMeta) {
self.session_meta = meta;
}
pub fn restore_session(
&mut self,
selections: Vec<Selection>,
measures: Vec<Measure>,
previous: Vec<crate::save::WrittenCrop>,
in_place: bool,
) {
self.selections = SelectionSet::seed(selections, measures);
if in_place {
self.last_crops = previous;
self.saved_once = true;
}
}
pub fn restore_panel(&mut self, saved: Option<(usize, Point)>) {
self.panel_origin = saved.filter(|&(frame, _)| frame < self.frames.len());
}
pub fn run(mut self) -> Result<()> {
let event_loop = EventLoop::new()?;
event_loop.run_app(&mut self)?;
crate::state::save_panel(self.panel_origin);
match self.error.take() {
Some(e) => Err(e),
None => Ok(()),
}
}
fn fail(&mut self, event_loop: &ActiveEventLoop, error: anyhow::Error) {
self.error = Some(error);
event_loop.exit();
}
fn frame_of_window(&self, id: WindowId) -> Option<usize> {
self.views
.iter()
.find(|slot| slot.view.id() == id)
.map(|slot| slot.frame)
}
fn redraw_all(&self) {
for slot in &self.views {
slot.view.request_redraw();
}
}
fn redraw_frame(&self, frame: usize) {
if let Some(slot) = self.views.iter().find(|s| s.frame == frame) {
slot.view.request_redraw();
}
}
fn mark_dirty(&mut self) {
self.dirty = true;
self.quit_armed_until = None;
}
fn set_flash(&mut self, message: String, duration: Duration) {
self.flash = Some((message, Instant::now() + duration));
self.redraw_all();
}
fn overlay_state(&self) -> OverlayState {
OverlayState {
has_selection: !self.selections.is_empty(),
cursor_in_shape: self.hit_at_cursor().is_some(),
}
}
fn hit_at_cursor(&self) -> Option<usize> {
let monitor = self.frames[self.cursor_frame].info.index;
self.selections.hit_topmost(monitor, self.cursor)
}
fn measure_preview(&self, frame_idx: usize) -> Option<Line> {
match self.mode {
Mode::Drawing { frame, start, .. }
if frame == frame_idx && self.tool == ToolKind::Measure =>
{
let line = Line::new(start, self.gesture_cursor);
Some(if self.shift_down {
line.constrained()
} else {
line
})
}
_ => None,
}
}
fn preview_for(&self, frame_idx: usize) -> Option<Shape> {
let Mode::Drawing { frame, start, path } = &self.mode else {
return None;
};
if *frame != frame_idx {
return None;
}
match self.tool {
ToolKind::Polygon => {
let region = self.frames[frame_idx].draw_rect;
let vertex = Point::new(
self.gesture_cursor
.x
.clamp(region.x, region.x + region.w - 1),
self.gesture_cursor
.y
.clamp(region.y, region.y + region.h - 1),
);
Some(pixelcoords_core::geometry::regular_polygon(
*start,
vertex,
self.polygon_sides,
))
}
ToolKind::Freehand => (path.len() >= 3).then(|| Shape::Poly {
points: path.clone(),
}),
_ => Shape::compute_preview(
self.tool,
*start,
self.gesture_cursor,
self.frames[frame_idx].draw_rect,
self.shift_down,
),
}
}
fn render(&mut self, window_id: WindowId) {
let Some(frame_idx) = self.frame_of_window(window_id) else {
return;
};
let preview = self.preview_for(frame_idx);
let editing = match &self.mode {
Mode::LabelEditing {
target: EditTarget::Selection,
index,
text,
} => Some((*index, text.as_str(), self.caret_visible)),
_ => None,
};
let measure_editing = match &self.mode {
Mode::LabelEditing {
target: EditTarget::Measure,
index,
text,
} => Some((*index, text.as_str(), self.caret_visible)),
_ => None,
};
let measure_preview = self.measure_preview(frame_idx);
let flash = self
.flash
.as_ref()
.filter(|(_, until)| Instant::now() < *until)
.map(|(m, _)| m.as_str());
let frame = &self.frames[frame_idx];
let mut style = self.style;
style.thickness *= frame.ui_scale;
let target = self
.target
.as_ref()
.filter(|t| t.monitor == frame.info.index)
.map(|t| {
pixelcoords_core::geometry::Rect::new(
t.origin_px.x,
t.origin_px.y,
t.size_px.w,
t.size_px.h,
)
});
let naming = match &self.mode {
Mode::SessionNaming { text } => Some((text.as_str(), self.caret_visible)),
_ => None,
};
let state = FrameState {
selections: &self.selections,
monitor: frame.info.index,
target,
preview,
editing,
measure_editing,
measure_preview,
snap_enabled: self.snap.enabled,
snap: if frame_idx == self.cursor_frame {
self.snap_hit
} else {
Snap::default()
},
flash,
strings: self.strings,
style,
ui_scale: frame.ui_scale,
panel_origin: self
.panel_origin
.and_then(|(f, p)| (f == frame_idx).then_some(p)),
panel_hidden: self.panel_hidden,
tool: self.tool,
polygon_sides: self.polygon_sides,
cursor: (frame_idx == self.cursor_frame).then_some(self.cursor),
loupe: self.loupe_held,
naming,
};
let background = &frame.background;
let Some(slot) = self.views.iter_mut().find(|s| s.frame == frame_idx) else {
return;
};
let result = slot.view.present(|buffer, size| {
render::compose(buffer, size, background, &state);
});
let Err(e) = result else {
self.warned_render_failure = false;
return;
};
if !self.warned_render_failure {
log::error!("render failed: {e:#}");
self.warned_render_failure = true;
}
}
fn commit_label(&mut self) {
let changed = match std::mem::replace(&mut self.mode, Mode::Idle) {
Mode::LabelEditing {
target: EditTarget::Selection,
index,
text,
} => self.selections.set_label(index, text),
Mode::LabelEditing {
target: EditTarget::Measure,
index,
text,
} => self.selections.label_measure(index, text),
other => {
self.mode = other;
false
}
};
if changed {
self.mark_dirty();
}
self.caret_deadline = None;
}
fn grab_tolerance(&self) -> i32 {
GRAB_TOLERANCE * self.frames[self.cursor_frame].ui_scale
}
fn measure_at_cursor(&self) -> Option<usize> {
if self.tool != ToolKind::Measure {
return None;
}
let monitor = self.frames[self.cursor_frame].info.index;
self.selections
.grab_measure(monitor, self.cursor, self.grab_tolerance())
.map(|(index, _)| index)
}
fn grab_at_cursor(&self) -> Option<(usize, GrabKind)> {
let monitor = self.frames[self.cursor_frame].info.index;
self.selections
.grab_topmost(monitor, self.cursor, self.grab_tolerance())
}
fn mouse_pressed(&mut self) {
if matches!(self.mode, Mode::LabelEditing { .. }) {
self.commit_label();
}
if matches!(self.mode, Mode::SessionNaming { .. }) {
self.commit_session_name();
}
self.gesture_cursor = self.snap_cursor();
if self.tool == ToolKind::Measure {
let monitor = self.frames[self.cursor_frame].info.index;
if let Some((index, grab)) =
self.selections
.grab_measure(monitor, self.cursor, self.grab_tolerance())
{
let original = self.selections.measures()[index].line;
self.mode = Mode::MeasureDragging {
frame: self.cursor_frame,
index,
grab,
original,
grab_offset: Point::new(
self.cursor.x - original.a.x,
self.cursor.y - original.a.y,
),
};
self.redraw_all();
return;
}
if !self.frames[self.cursor_frame]
.draw_rect
.contains(self.cursor)
{
return;
}
self.mode = Mode::Drawing {
frame: self.cursor_frame,
start: self.gesture_cursor,
path: Vec::new(),
};
self.redraw_all();
return;
}
match self.grab_at_cursor() {
Some((index, GrabKind::Resize(handle))) => {
self.mode = Mode::Resizing {
frame: self.cursor_frame,
index,
handle,
original: self.selections.items()[index].shape.clone(),
};
}
Some((index, GrabKind::Move)) => {
let index = if self.alt_down {
let copy = self.selections.items()[index].clone();
self.selections.add(copy);
self.mark_dirty();
self.selections.len() - 1
} else {
index
};
let sel = &self.selections.items()[index];
let shape = sel.shape.clone();
let origin = shape.grab_origin_rotated(sel.rot_deg);
self.mode = Mode::Dragging {
frame: self.cursor_frame,
index,
grab_offset: Point::new(self.cursor.x - origin.x, self.cursor.y - origin.y),
original: shape,
};
}
None => {
if !self.frames[self.cursor_frame]
.draw_rect
.contains(self.cursor)
{
return;
}
self.mode = Mode::Drawing {
frame: self.cursor_frame,
start: self.gesture_cursor,
path: vec![self.gesture_cursor],
};
}
}
self.redraw_all();
}
fn mouse_released(&mut self) {
self.refresh_gesture_cursor();
let preview = self.preview_for(self.cursor_frame);
match std::mem::replace(&mut self.mode, Mode::Idle) {
Mode::Drawing { frame, start, .. } if self.tool == ToolKind::Measure => {
let line = Line::new(start, self.gesture_cursor);
let line = if self.shift_down {
line.constrained()
} else {
line
};
if line.length() >= f64::from(self.grab_tolerance()) {
self.selections
.add_measure(Measure::new(line, self.frames[frame].info.index));
self.mark_dirty();
}
}
Mode::MeasureDragging {
index, original, ..
} => {
if self.selections.commit_measure(index, original) {
self.mark_dirty();
}
}
Mode::Drawing { frame, .. } => {
let shape = match (self.tool, preview) {
(ToolKind::Freehand, Some(Shape::Poly { points })) => {
let simplified = pixelcoords_core::geometry::simplify_path(&points, 2.0);
(simplified.len() >= 3).then_some(Shape::Poly { points: simplified })
}
(_, preview) => preview,
};
if let Some(shape) = shape
&& shape_is_committable(&shape)
{
self.selections
.add(Selection::new(shape, self.frames[frame].info.index));
self.mark_dirty();
}
}
Mode::Dragging {
index, original, ..
}
| Mode::Resizing {
index, original, ..
} => {
if self.selections.commit_move(index, original) {
self.mark_dirty();
}
}
other => self.mode = other,
}
self.redraw_all();
}
fn cursor_moved(&mut self, window_id: WindowId, position: winit::dpi::PhysicalPosition<f64>) {
let Some(frame_idx) = self.frame_of_window(window_id) else {
return;
};
match self.mode {
Mode::Drawing { frame, .. }
| Mode::Dragging { frame, .. }
| Mode::Resizing { frame, .. }
if frame != frame_idx =>
{
return;
}
_ => {}
}
let previous_frame = self.cursor_frame;
self.cursor_frame = frame_idx;
let Some(slot) = self.views.iter().find(|s| s.frame == frame_idx) else {
return;
};
self.cursor = slot.view.coord_map().window_to_capture(position);
if self.panel_held {
self.panel_origin = Some((frame_idx, self.cursor));
}
if previous_frame != frame_idx {
self.redraw_frame(previous_frame);
}
self.redraw_frame(frame_idx);
self.update_active_gesture();
}
fn begin_label_edit(&mut self) {
let target = self.measure_at_cursor().map_or_else(
|| {
self.hit_at_cursor()
.map(|index| (EditTarget::Selection, index))
},
|index| Some((EditTarget::Measure, index)),
);
let Some((target, index)) = target else {
return;
};
let text = match target {
EditTarget::Selection => self.selections.items()[index].label.clone(),
EditTarget::Measure => self.selections.measures()[index].label.clone(),
};
self.mode = Mode::LabelEditing {
target,
index,
text,
};
self.caret_visible = true;
self.caret_deadline = Some(Instant::now() + CARET_BLINK);
self.redraw_all();
}
fn dragged_measure(
&self,
grab: MeasureGrab,
original: Line,
grab_offset: Point,
frame: usize,
) -> Line {
let MeasureGrab::Endpoint(is_a) = grab else {
let target = Point::new(
self.gesture_cursor.x - grab_offset.x,
self.gesture_cursor.y - grab_offset.y,
);
return original.translated(target.x - original.a.x, target.y - original.a.y);
};
let free = self.frames[frame]
.draw_rect()
.clamp_point(self.gesture_cursor);
let dragged = if is_a {
Line::new(free, original.b)
} else {
Line::new(original.a, free)
};
if !self.shift_down {
return dragged;
}
if is_a {
let snapped = Line::new(dragged.b, dragged.a).constrained();
Line::new(snapped.b, snapped.a)
} else {
dragged.constrained()
}
}
fn refresh_gesture_cursor(&mut self) {
if matches!(
self.mode,
Mode::Idle | Mode::LabelEditing { .. } | Mode::SessionNaming { .. }
) {
self.snap_hit = Snap::default();
} else {
self.gesture_cursor = self.snap_cursor();
}
}
fn update_active_gesture(&mut self) {
self.refresh_gesture_cursor();
match &mut self.mode {
Mode::Drawing { frame, path, .. } => {
let frame = *frame;
if self.tool == ToolKind::Freehand
&& self.frames[frame].draw_rect.contains(self.cursor)
&& path.last().is_none_or(|last| {
(self.cursor.x - last.x).abs() + (self.cursor.y - last.y).abs() >= 2
})
{
path.push(self.cursor);
}
self.redraw_frame(frame);
}
Mode::Dragging {
frame,
index,
grab_offset,
original,
} => {
let (frame, index, grab_offset, original) =
(*frame, *index, *grab_offset, original.clone());
let Some(rot) = self.selections.get(index).map(|s| s.rot_deg) else {
self.mode = Mode::Idle;
return;
};
let moved = original.clamp_move_rotated(
rot,
grab_offset,
self.gesture_cursor,
self.frames[frame].draw_rect(),
);
self.selections.set_shape_live(index, moved);
self.redraw_frame(frame);
}
Mode::Resizing {
frame,
index,
handle,
original,
} => {
let (frame, index, handle, original) = (*frame, *index, *handle, original.clone());
let Some(rot) = self.selections.get(index).map(|s| s.rot_deg) else {
self.mode = Mode::Idle;
return;
};
let resized = original.resize_to_rotated(
rot,
handle,
self.gesture_cursor,
self.frames[frame].draw_rect(),
self.shift_down,
);
self.selections.set_shape_live(index, resized);
self.redraw_frame(frame);
}
Mode::MeasureDragging {
frame,
index,
grab,
original,
grab_offset,
} => {
let (frame, index, grab, original, grab_offset) =
(*frame, *index, *grab, *original, *grab_offset);
let moved = self.dragged_measure(grab, original, grab_offset, frame);
self.selections.set_measure_line_live(index, moved);
self.redraw_frame(frame);
}
Mode::Idle => self.update_hover_cursor(),
Mode::LabelEditing { .. } | Mode::SessionNaming { .. } => {}
}
}
fn update_hover_cursor(&mut self) {
if self.tool == ToolKind::Measure {
let icon = match self.measure_at_cursor() {
Some(_) => CursorIcon::Move,
None if !self.frames[self.cursor_frame]
.draw_rect()
.contains(self.cursor) =>
{
CursorIcon::NotAllowed
}
None => CursorIcon::Crosshair,
};
self.set_cursor_icon(icon);
return;
}
let icon = match self.grab_at_cursor() {
Some((index, GrabKind::Resize(handle))) => {
resize_icon(handle, &self.selections.items()[index].shape, self.cursor)
}
Some((_, GrabKind::Move)) => CursorIcon::Move,
None if !self.frames[self.cursor_frame]
.draw_rect()
.contains(self.cursor) =>
{
CursorIcon::NotAllowed
}
None => CursorIcon::Crosshair,
};
self.set_cursor_icon(icon);
}
fn set_cursor_icon(&mut self, icon: CursorIcon) {
if icon == self.cursor_icon {
return;
}
self.cursor_icon = icon;
if let Some(slot) = self.views.iter().find(|s| s.frame == self.cursor_frame) {
slot.view.set_cursor(icon);
}
}
fn key_event(&mut self, event_loop: &ActiveEventLoop, event: &KeyEvent) {
if event.state == ElementState::Released && event.logical_key == Key::Named(NamedKey::Space)
{
self.panel_held = false;
}
if matches!(self.mode, Mode::LabelEditing { .. }) {
if event.state == ElementState::Pressed {
self.label_editor_key(event);
}
return;
}
if matches!(self.mode, Mode::SessionNaming { .. }) {
if event.state == ElementState::Pressed {
self.name_editor_key(event);
}
return;
}
if event.state == ElementState::Pressed && event.logical_key == Key::Named(NamedKey::Escape)
{
match std::mem::replace(&mut self.mode, Mode::Idle) {
Mode::Dragging {
index, original, ..
}
| Mode::Resizing {
index, original, ..
} => {
self.selections.set_shape_live(index, original);
}
Mode::Idle => self.request_quit(event_loop),
_ => {}
}
self.redraw_all();
return;
}
if let Key::Character(s) = &event.logical_key
&& s.eq_ignore_ascii_case("m")
{
self.loupe_held = event.state == ElementState::Pressed;
self.redraw_frame(self.cursor_frame);
return;
}
if event.logical_key == Key::Named(NamedKey::Space) {
if event.state == ElementState::Pressed {
self.panel_held = true;
}
return;
}
if self.tool == ToolKind::Polygon
&& event.state == ElementState::Pressed
&& let Key::Character(text) = &event.logical_key
&& let Some(d) = text.chars().next().and_then(|c| c.to_digit(10))
&& (3..=9).contains(&d)
{
self.polygon_sides = d;
self.set_flash(format!("Polygon sides: {d}"), FLASH_TOOL);
return;
}
if let Some((dx, dy)) = arrow_delta(&event.logical_key) {
if event.state == ElementState::Pressed && matches!(self.mode, Mode::Idle) {
self.nudge(dx, dy);
}
return;
}
let Some(key) = key_name(event) else { return };
let edge = match (event.state, event.repeat) {
(ElementState::Pressed, false) => Edge::Press,
(ElementState::Pressed, true) => Edge::Repeat,
(ElementState::Released, _) => Edge::Release,
};
if let Some(action) = match_event(&self.bindings, key, edge, self.overlay_state()) {
let action = if action == Action::Undo && self.shift_down {
Action::Redo
} else {
action
};
if !matches!(self.mode, Mode::Idle) && !allowed_mid_gesture(action) {
log::debug!("ignoring {action:?} during an active mouse gesture");
return;
}
self.apply_action(event_loop, action);
}
}
fn label_editor_key(&mut self, event: &KeyEvent) {
let Mode::LabelEditing { text, .. } = &mut self.mode else {
return;
};
match &event.logical_key {
Key::Named(NamedKey::Enter) => self.commit_label(),
Key::Named(NamedKey::Escape) => {
self.mode = Mode::Idle;
self.caret_deadline = None;
}
Key::Named(NamedKey::Backspace) => {
text.pop();
}
_ => append_typed(text, event.text.as_deref()),
}
self.caret_visible = true;
self.caret_deadline = Some(Instant::now() + CARET_BLINK);
self.redraw_all();
}
fn name_editor_key(&mut self, event: &KeyEvent) {
let Mode::SessionNaming { text } = &mut self.mode else {
return;
};
match &event.logical_key {
Key::Named(NamedKey::Enter) => self.commit_session_name(),
Key::Named(NamedKey::Escape) => {
self.mode = Mode::Idle;
self.caret_deadline = None;
}
Key::Named(NamedKey::Backspace) => {
text.pop();
}
_ => append_typed(text, event.text.as_deref()),
}
self.caret_visible = true;
self.caret_deadline = Some(Instant::now() + CARET_BLINK);
self.redraw_all();
}
fn commit_session_name(&mut self) {
let Mode::SessionNaming { text } = std::mem::replace(&mut self.mode, Mode::Idle) else {
return;
};
self.caret_deadline = None;
let name = Some(text.trim().to_string()).filter(|t| !t.is_empty());
if self.session_meta.name == name {
return;
}
let message = match &name {
Some(n) => format!("Session named {n:?} - S saves it"),
None => "Session name cleared - S saves it".to_string(),
};
self.session_meta.name = name;
self.mark_dirty();
self.set_flash(message, FLASH_SAVE);
}
fn shift_changed(&mut self, shift: bool) {
if shift == self.shift_down {
return;
}
self.shift_down = shift;
let Mode::Resizing {
frame,
index,
handle,
original,
} = &self.mode
else {
return;
};
let (frame, index, handle, original) = (*frame, *index, *handle, original.clone());
let Some(rot) = self.selections.get(index).map(|s| s.rot_deg) else {
self.mode = Mode::Idle;
return;
};
let resized = original.resize_to_rotated(
rot,
handle,
self.cursor,
self.frames[frame].draw_rect(),
shift,
);
self.selections.set_shape_live(index, resized);
self.redraw_frame(frame);
}
fn nudge(&mut self, dx: i32, dy: i32) {
let Some(index) = self.hit_at_cursor() else {
return;
};
let step = if self.shift_down { 10 } else { 1 };
let (dx, dy) = (dx * step, dy * step);
let sel = &self.selections.items()[index];
let original = sel.shape.clone();
let rot = sel.rot_deg;
let candidate = if self.alt_down {
resize_by(&original, dx, dy)
} else {
Some(original.translated(dx, dy))
};
let Some(candidate) = candidate else { return };
let size = self.frames[self.cursor_frame].size;
let bb = candidate.rotated_bbox(rot);
if bb.x < 0 || bb.y < 0 || bb.x + bb.w > size.w || bb.y + bb.h > size.h {
return;
}
self.selections.set_shape_live(index, candidate);
if self.selections.commit_move(index, original) {
self.mark_dirty();
self.redraw_frame(self.cursor_frame);
}
}
fn release_frame(&mut self, event_loop: &ActiveEventLoop, frame: usize) {
if self.try_release_frame(frame) == Release::Quit {
self.request_quit(event_loop);
}
}
fn try_release_frame(&mut self, frame: usize) -> Release {
if self.frames.len() <= 1 || !matches!(self.mode, Mode::Idle) {
return Release::Quit;
}
let monitor = self.frames[frame].info.index;
let held = self
.selections
.items()
.iter()
.filter(|s| s.monitor == monitor)
.count()
+ self
.selections
.measures()
.iter()
.filter(|m| m.monitor == monitor)
.count();
if held > 0 {
self.set_flash(
format!(
"{}{held}{}",
self.strings.hud_release_blocked_prefix,
self.strings.hud_release_blocked_suffix
),
FLASH_SAVE,
);
return Release::Blocked;
}
self.frames.remove(frame);
self.views.retain(|slot| slot.frame != frame);
for slot in &mut self.views {
if slot.frame > frame {
slot.frame -= 1;
}
}
if self.cursor_frame > frame {
self.cursor_frame -= 1;
} else if self.cursor_frame == frame {
self.cursor_frame = 0;
}
self.panel_origin = match self.panel_origin {
Some((host, _)) if host == frame => None,
Some((host, at)) if host > frame => Some((host - 1, at)),
other => other,
};
crate::state::save_panel(self.panel_origin);
self.selections = SelectionSet::seed(
self.selections.items().to_vec(),
self.selections.measures().to_vec(),
);
self.set_flash(self.strings.hud_released.to_string(), FLASH_SAVE);
self.redraw_all();
Release::Done
}
fn request_quit(&mut self, event_loop: &ActiveEventLoop) {
let unsaved = self.dirty;
let armed = self
.quit_armed_until
.is_some_and(|until| Instant::now() < until);
if unsaved && !armed {
self.quit_armed_until = Some(Instant::now() + FLASH_SAVE);
self.set_flash(self.strings.hud_quit_unsaved.to_string(), FLASH_SAVE);
return;
}
event_loop.exit();
}
fn apply_action(&mut self, event_loop: &ActiveEventLoop, action: Action) {
match action {
Action::Quit => self.request_quit(event_loop),
Action::ReleaseMonitor => self.release_frame(event_loop, self.cursor_frame),
other => self.apply_local_action(other),
}
}
fn apply_local_action(&mut self, action: Action) {
match action {
Action::Quit | Action::ReleaseMonitor => {}
Action::Save => self.save(),
Action::NextTool => {
self.tool = self.tool.next();
let name = match self.tool {
ToolKind::Rect => "TOOL: RECTANGLE",
ToolKind::Circle => "TOOL: CIRCLE",
ToolKind::Ellipse => "TOOL: ELLIPSE",
ToolKind::Triangle => "TOOL: TRIANGLE",
ToolKind::Polygon => "TOOL: POLYGON",
ToolKind::Freehand => "TOOL: FREEHAND",
ToolKind::Poly => "TOOL: POLY",
ToolKind::Measure => "TOOL: MEASURE",
};
self.set_flash(name.to_string(), FLASH_TOOL);
}
Action::DeleteAtCursor => {
if let Some(index) = self.measure_at_cursor() {
self.selections.delete_measure(index);
self.mark_dirty();
self.redraw_all();
} else if let Some(index) = self.hit_at_cursor() {
self.selections.delete(index);
self.mark_dirty();
self.redraw_all();
}
}
Action::LabelEditAtCursor => self.begin_label_edit(),
Action::RotateCcw | Action::RotateCw => {
if let Some(index) = self.hit_at_cursor() {
let step = if self.shift_down { 15 } else { 1 };
let delta = if matches!(action, Action::RotateCw) {
step
} else {
-step
};
if let Some(deg) = self.selections.rotate(index, delta) {
self.mark_dirty();
self.set_flash(format!("ROTATION {deg}"), FLASH_TOOL);
}
}
}
Action::Undo => {
if self.selections.undo() {
self.mark_dirty();
self.redraw_all();
}
}
Action::Redo => {
if self.selections.redo() {
self.mark_dirty();
self.redraw_all();
}
}
Action::NameSession => {
self.mode = Mode::SessionNaming {
text: self.session_meta.name.clone().unwrap_or_default(),
};
self.caret_visible = true;
self.caret_deadline = Some(Instant::now() + CARET_BLINK);
self.redraw_all();
}
Action::ToggleSnap => {
self.snap.enabled = !self.snap.enabled;
if self.snap.enabled {
for frame in &self.frames {
let _ = frame.edges();
}
} else {
self.snap_hit = Snap::default();
}
let message = if self.snap.enabled {
self.strings.hud_snap_on
} else {
self.strings.hud_snap_off
};
self.set_flash(message.to_string(), FLASH_TOOL);
self.redraw_all();
}
Action::TogglePanel => {
self.panel_hidden = !self.panel_hidden;
if self.panel_hidden {
self.set_flash("H shows the panel again".to_string(), FLASH_TOOL);
}
self.redraw_all();
}
Action::CycleOverlap => {
let monitor = self.frames[self.cursor_frame].info.index;
if self.selections.cycle_at(monitor, self.cursor) {
self.mark_dirty();
self.redraw_all();
}
}
Action::NextTheme => log::debug!("next_theme is a no-op in snapshot mode"),
}
}
fn save(&mut self) {
let pairs: Vec<(&MonitorInfo, &RgbaImage)> =
self.frames.iter().map(|f| (&f.info, &f.rgba)).collect();
match crate::save::write_session(
&self.out_dir,
&pairs,
&self.selections,
self.target.as_ref(),
&self.session_meta,
!self.saved_once,
&self.last_crops,
) {
Ok(outcome) => {
self.dirty = false;
self.quit_armed_until = None;
self.saved_once = true;
let message = format!(
"{}{}",
self.strings.hud_saved_prefix,
outcome.json_path.display()
);
self.last_crops = outcome.crops;
self.set_flash(message, FLASH_SAVE);
}
Err(e) => {
log::error!("save failed: {e:#}");
let message = format!("{}{e:#}", self.strings.hud_save_failed_prefix);
self.set_flash(message, FLASH_SAVE);
}
}
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if !self.views.is_empty() {
return;
}
let mut available: Vec<winit::monitor::MonitorHandle> =
event_loop.available_monitors().collect();
for (frame_idx, frame) in self.frames.iter().enumerate() {
let handle = claim_monitor(self.presentation, frame, &mut available);
match OverlayView::new(event_loop, handle.as_ref(), frame.size, self.presentation) {
Ok(view) => self.views.push(ViewSlot {
frame: frame_idx,
view,
}),
Err(e) => {
self.fail(event_loop, e.context("creating overlay window"));
return;
}
}
}
self.redraw_all();
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
window_id: WindowId,
event: WindowEvent,
) {
if self.frame_of_window(window_id).is_none() {
return;
}
match event {
WindowEvent::CloseRequested => match self.frame_of_window(window_id) {
Some(frame) => self.release_frame(event_loop, frame),
None => self.request_quit(event_loop),
},
WindowEvent::RedrawRequested => self.render(window_id),
WindowEvent::Resized(_) => self.redraw_all(),
WindowEvent::CursorMoved { position, .. } => self.cursor_moved(window_id, position),
WindowEvent::ModifiersChanged(modifiers) => {
self.shift_changed(modifiers.state().shift_key());
self.alt_down = modifiers.state().alt_key();
}
WindowEvent::MouseInput {
state,
button: MouseButton::Left,
..
} => match state {
ElementState::Pressed => self.mouse_pressed(),
ElementState::Released => self.mouse_released(),
},
WindowEvent::KeyboardInput { event, .. } => self.key_event(event_loop, &event),
_ => {}
}
}
fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) {
if !matches!(cause, StartCause::ResumeTimeReached { .. }) {
return;
}
let now = Instant::now();
if matches!(self.mode, Mode::LabelEditing { .. })
&& self.caret_deadline.is_some_and(|d| d <= now)
{
self.caret_visible = !self.caret_visible;
self.caret_deadline = Some(now + CARET_BLINK);
self.redraw_all();
}
if self.flash.as_ref().is_some_and(|(_, until)| *until <= now) {
self.flash = None;
self.redraw_all();
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
let mut deadline: Option<Instant> = None;
if matches!(self.mode, Mode::LabelEditing { .. }) {
deadline = self.caret_deadline;
}
if let Some((_, until)) = &self.flash {
deadline = Some(deadline.map_or(*until, |d| d.min(*until)));
}
event_loop.set_control_flow(match deadline {
Some(t) => ControlFlow::WaitUntil(t),
None => ControlFlow::Wait,
});
}
}
fn claim_monitor(
presentation: Presentation,
frame: &MonitorFrame,
available: &mut Vec<winit::monitor::MonitorHandle>,
) -> Option<winit::monitor::MonitorHandle> {
if presentation == Presentation::Windowed {
return None;
}
let expected = winit::dpi::PhysicalSize::new(frame.size.w as u32, frame.size.h as u32);
let origin = frame.info.origin_physical();
let expected_pos = (f64::from(origin.x), f64::from(origin.y));
let candidates: Vec<(
winit::dpi::PhysicalSize<u32>,
winit::dpi::PhysicalPosition<i32>,
)> = available.iter().map(|m| (m.size(), m.position())).collect();
let Some(index) = pick_monitor(&candidates, expected, expected_pos) else {
log::warn!(
"no unused monitor matches capture {}x{} for '{}'; using the current screen",
expected.width,
expected.height,
frame.info.name
);
return None;
};
Some(available.swap_remove(index))
}
fn pick_monitor(
candidates: &[(
winit::dpi::PhysicalSize<u32>,
winit::dpi::PhysicalPosition<i32>,
)],
expected_size: winit::dpi::PhysicalSize<u32>,
expected_pos: (f64, f64),
) -> Option<usize> {
candidates
.iter()
.enumerate()
.filter(|(_, (size, _))| *size == expected_size)
.min_by_key(|(_, (_, pos))| {
let dx = f64::from(pos.x) - expected_pos.0;
let dy = f64::from(pos.y) - expected_pos.1;
(dx * dx + dy * dy) as i64
})
.map(|(i, _)| i)
}
const fn allowed_mid_gesture(action: Action) -> bool {
matches!(action, Action::Quit)
}
fn resize_icon(handle: ResizeHandle, shape: &Shape, cursor: Point) -> CursorIcon {
let ResizeHandle::RectEdges {
left,
right,
top,
bottom,
} = handle
else {
let center = shape.grab_origin();
let horizontal = (cursor.x - center.x).abs() >= (cursor.y - center.y).abs();
return if horizontal {
CursorIcon::EwResize
} else {
CursorIcon::NsResize
};
};
if (left && top) || (right && bottom) {
return CursorIcon::NwseResize;
}
if (right && top) || (left && bottom) {
return CursorIcon::NeswResize;
}
if left || right {
return CursorIcon::EwResize;
}
CursorIcon::NsResize
}
fn append_typed(text: &mut String, typed: Option<&str>) {
let Some(typed) = typed else {
return;
};
for c in typed.chars().filter(|c| !c.is_control()) {
if text.chars().count() >= MAX_LABEL_LEN {
return;
}
text.push(c);
}
}
fn shape_is_committable(shape: &Shape) -> bool {
match shape {
Shape::Rect(r) => r.w >= 2 && r.h >= 2,
Shape::Circle { r, .. } => *r >= 2,
Shape::Ellipse { rx, ry, .. } => *rx >= 2 && *ry >= 2,
Shape::Triangle { .. } | Shape::Poly { .. } => {
let b = shape.bbox();
b.w >= 2 && b.h >= 2
}
}
}
fn arrow_delta(key: &Key) -> Option<(i32, i32)> {
match key {
Key::Named(NamedKey::ArrowLeft) => Some((-1, 0)),
Key::Named(NamedKey::ArrowRight) => Some((1, 0)),
Key::Named(NamedKey::ArrowUp) => Some((0, -1)),
Key::Named(NamedKey::ArrowDown) => Some((0, 1)),
_ => None,
}
}
fn resize_by(shape: &Shape, dx: i32, dy: i32) -> Option<Shape> {
match *shape {
Shape::Rect(r) => {
let (w, h) = (r.w + dx, r.h + dy);
(w >= 2 && h >= 2).then(|| Shape::Rect(Rect::new(r.x, r.y, w, h)))
}
Shape::Circle { cx, cy, r } => {
let grown = r + dx + dy;
(grown >= 1).then_some(Shape::Circle { cx, cy, r: grown })
}
Shape::Ellipse { cx, cy, rx, ry } => {
let (nrx, nry) = (rx + dx, ry + dy);
(nrx >= 1 && nry >= 1).then_some(Shape::Ellipse {
cx,
cy,
rx: nrx,
ry: nry,
})
}
Shape::Triangle { .. } | Shape::Poly { .. } => None,
}
}
fn key_name(event: &KeyEvent) -> Option<KeyName> {
match &event.logical_key {
Key::Named(NamedKey::Tab) => Some(KeyName::Tab),
Key::Named(NamedKey::CapsLock) => Some(KeyName::CapsLock),
Key::Character(s) => s
.chars()
.next()
.map(|c| KeyName::Character(c.to_ascii_uppercase())),
_ => None,
}
}
fn rgba_to_0rgb(img: &RgbaImage) -> Vec<u32> {
img.pixels()
.map(|p| (u32::from(p[0]) << 16) | (u32::from(p[1]) << 8) | u32::from(p[2]))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use pixelcoords_core::config::Config;
use pixelcoords_core::hotkeys::default_bindings;
#[test]
fn nudge_moves_resizes_and_respects_the_frame_edge() {
let mut app = test_app();
app.selections
.add(Selection::new(Shape::Rect(Rect::new(10, 10, 20, 15)), 0));
app.cursor = Point::new(15, 15);
app.nudge(1, 0);
assert_eq!(
app.selections.items()[0].shape,
Shape::Rect(Rect::new(11, 10, 20, 15))
);
app.shift_down = true;
app.nudge(0, 1);
assert_eq!(
app.selections.items()[0].shape,
Shape::Rect(Rect::new(11, 20, 20, 15))
);
app.shift_down = false;
app.cursor = Point::new(15, 25);
app.alt_down = true;
app.nudge(1, 0);
assert_eq!(
app.selections.items()[0].shape,
Shape::Rect(Rect::new(11, 20, 21, 15))
);
app.alt_down = false;
app.shift_down = true;
for _ in 0..10 {
app.nudge(1, 0);
}
let shape = app.selections.items()[0].shape.clone();
let bb = shape.bbox();
assert!(bb.x + bb.w <= 100, "never pushed past the edge: {bb:?}");
assert!(app.selections.undo());
}
#[test]
fn nudge_without_a_shape_under_the_cursor_is_inert() {
let mut app = test_app();
app.selections
.add(Selection::new(Shape::Rect(Rect::new(10, 10, 20, 15)), 0));
app.cursor = Point::new(90, 50);
app.nudge(1, 0);
assert_eq!(
app.selections.items()[0].shape,
Shape::Rect(Rect::new(10, 10, 20, 15))
);
assert!(!app.dirty);
}
#[test]
fn committing_a_session_name_dirties_and_empty_clears() {
let mut app = test_app();
app.mode = Mode::SessionNaming {
text: " microsoft teams ".into(),
};
app.commit_session_name();
assert_eq!(app.session_meta.name.as_deref(), Some("microsoft teams"));
assert!(app.dirty, "a name change needs a save to persist");
app.dirty = false;
app.mode = Mode::SessionNaming { text: " ".into() };
app.commit_session_name();
assert_eq!(app.session_meta.name, None, "whitespace clears");
assert!(app.dirty);
app.dirty = false;
app.mode = Mode::SessionNaming {
text: String::new(),
};
app.commit_session_name();
assert!(!app.dirty);
}
#[test]
fn restore_panel_ignores_a_frame_this_capture_lacks() {
let mut app = test_app();
app.restore_panel(Some((0, Point::new(40, 30))));
assert_eq!(app.panel_origin, Some((0, Point::new(40, 30))));
app.restore_panel(Some((7, Point::new(40, 30))));
assert_eq!(app.panel_origin, None, "a stale frame index is dropped");
}
fn test_app() -> App {
let info = MonitorInfo {
index: 0,
name: "Fake".into(),
primary: true,
origin: Point::new(0, 0),
size_native: Size::new(100, 60),
scale: 1.0,
};
let frame = MonitorFrame::new(
info,
RgbaImage::from_pixel(100, 60, image::Rgba([9, 9, 9, 255])),
);
App::new(
vec![frame],
Config::default().resolve_style().unwrap(),
default_bindings(),
std::env::temp_dir().join("pixelcoords-app-test-unused"),
None,
Presentation::Fullscreen,
)
}
fn test_app_with_button() -> App {
let info = MonitorInfo {
index: 0,
name: "Fake".into(),
primary: true,
origin: Point::new(0, 0),
size_native: Size::new(100, 60),
scale: 1.0,
};
let mut rgba = RgbaImage::from_pixel(100, 60, image::Rgba([20, 20, 20, 255]));
for y in 15..45 {
for x in 20..60 {
rgba.put_pixel(x, y, image::Rgba([230, 230, 230, 255]));
}
}
let mut app = App::new(
vec![MonitorFrame::new(info, rgba)],
Config::default().resolve_style().unwrap(),
default_bindings(),
std::env::temp_dir().join("pixelcoords-app-test-unused"),
None,
Presentation::Fullscreen,
);
app.set_snap(Config::default().resolve_snap().unwrap());
app
}
fn test_app_multi() -> App {
let frames: Vec<MonitorFrame> = (0..3)
.map(|index| {
let info = MonitorInfo {
index,
name: format!("Fake {index}"),
primary: index == 0,
origin: Point::new(index as i32 * 100, 0),
size_native: Size::new(100, 60),
scale: 1.0,
};
MonitorFrame::new(
info,
RgbaImage::from_pixel(100, 60, image::Rgba([9, 9, 9, 255])),
)
})
.collect();
App::new(
frames,
Config::default().resolve_style().unwrap(),
default_bindings(),
std::env::temp_dir().join("pixelcoords-app-test-unused"),
None,
Presentation::Fullscreen,
)
}
#[test]
fn releasing_a_frame_shifts_every_position_above_it_down() {
let mut app = test_app_multi();
app.cursor_frame = 2;
app.panel_origin = Some((2, Point::new(5, 5)));
assert_eq!(app.try_release_frame(1), Release::Done);
assert_eq!(app.frames.len(), 2);
assert_eq!(
app.frames.iter().map(|f| f.info.index).collect::<Vec<_>>(),
vec![0, 2],
"the surviving frames keep their monitor indices"
);
assert_eq!(app.cursor_frame, 1, "was 2, the frame below it went");
assert_eq!(app.panel_origin, Some((1, Point::new(5, 5))));
}
#[test]
fn releasing_a_frame_leaves_positions_below_it_alone() {
let mut app = test_app_multi();
app.cursor_frame = 0;
app.panel_origin = Some((0, Point::new(5, 5)));
assert_eq!(app.try_release_frame(2), Release::Done);
assert_eq!(app.cursor_frame, 0);
assert_eq!(app.panel_origin, Some((0, Point::new(5, 5))));
}
#[test]
fn releasing_the_panels_own_frame_sends_it_back_to_the_default_corner() {
let mut app = test_app_multi();
app.panel_origin = Some((1, Point::new(5, 5)));
app.cursor_frame = 1;
assert_eq!(app.try_release_frame(1), Release::Done);
assert_eq!(app.panel_origin, None, "its host is gone");
assert_eq!(app.cursor_frame, 0, "the cursor's frame went with it");
}
#[test]
fn a_frame_holding_marks_refuses_release_and_says_how_many() {
let mut app = test_app_multi();
for _ in 0..2 {
app.selections
.add(Selection::new(rect(1, 1, 5, 5), app.frames[1].info.index));
}
assert_eq!(app.try_release_frame(1), Release::Blocked);
assert_eq!(app.frames.len(), 3, "nothing was released");
let flash = app.flash.as_ref().expect("a flash").0.clone();
assert!(flash.contains('2'), "got: {flash}");
}
#[test]
fn a_frame_holding_only_a_ruler_still_refuses_release() {
let mut app = test_app_multi();
app.selections.add_measure(Measure::new(
Line::new(Point::new(1, 1), Point::new(20, 20)),
app.frames[1].info.index,
));
assert_eq!(app.try_release_frame(1), Release::Blocked);
assert_eq!(app.frames.len(), 3, "nothing was released");
}
#[test]
fn releasing_a_frame_keeps_the_rulers_on_the_others() {
let mut app = test_app_multi();
let kept = Measure::new(
Line::new(Point::new(1, 1), Point::new(20, 20)),
app.frames[0].info.index,
);
app.selections.add_measure(kept.clone());
assert_eq!(app.try_release_frame(1), Release::Done);
assert_eq!(
app.selections.measures(),
[kept],
"reseeding must not drop what the released display never held"
);
}
#[test]
fn the_last_frame_quits_instead_of_releasing() {
let mut app = test_app();
assert_eq!(app.try_release_frame(0), Release::Quit);
assert_eq!(app.frames.len(), 1, "still frozen until the run ends");
}
#[test]
fn a_release_mid_gesture_quits_rather_than_pulling_the_frame_out() {
let mut app = test_app_multi();
app.mode = Mode::Drawing {
frame: 2,
start: Point::new(1, 1),
path: vec![Point::new(1, 1)],
};
assert_eq!(app.try_release_frame(1), Release::Quit);
assert_eq!(app.frames.len(), 3);
}
#[test]
fn releasing_truncates_undo_history_but_keeps_the_marks() {
let mut app = test_app_multi();
app.selections
.add(Selection::new(rect(1, 1, 5, 5), app.frames[0].info.index));
assert!(app.selections.undo(), "history exists before the release");
app.selections
.add(Selection::new(rect(2, 2, 5, 5), app.frames[0].info.index));
assert_eq!(app.try_release_frame(2), Release::Done);
assert_eq!(app.selections.len(), 1, "the mark survived");
assert!(
!app.selections.undo(),
"history is truncated — entries could reference a gone frame"
);
}
fn rect(x: i32, y: i32, w: i32, h: i32) -> Shape {
Shape::Rect(pixelcoords_core::geometry::Rect::new(x, y, w, h))
}
#[test]
fn a_drag_near_a_button_snaps_both_corners_onto_its_edges() {
let mut app = test_app_with_button();
app.cursor = Point::new(23, 18);
app.mouse_pressed();
app.cursor = Point::new(57, 42);
app.update_active_gesture();
app.mouse_released();
assert_eq!(
app.selections.items()[0].shape,
rect(20, 15, 40, 30),
"the rect is the button's true size, not four pixels short"
);
}
#[test]
fn snapping_turned_off_leaves_the_drag_exactly_where_it_was() {
let mut app = test_app_with_button();
app.apply_local_action(Action::ToggleSnap);
assert!(!app.snap.enabled);
app.cursor = Point::new(23, 18);
app.mouse_pressed();
app.cursor = Point::new(57, 42);
app.update_active_gesture();
app.mouse_released();
assert_eq!(app.selections.items()[0].shape, rect(23, 18, 34, 24));
}
#[test]
fn the_toggle_flips_back_and_says_so() {
let mut app = test_app_with_button();
app.apply_local_action(Action::ToggleSnap);
assert_eq!(
app.flash.as_ref().map(|(m, _)| m.as_str()),
Some("SNAP OFF")
);
app.apply_local_action(Action::ToggleSnap);
assert!(app.snap.enabled);
assert_eq!(app.flash.as_ref().map(|(m, _)| m.as_str()), Some("SNAP ON"));
}
#[test]
fn nudging_beside_an_edge_still_moves_exactly_one_pixel() {
let mut app = test_app_with_button();
app.selections.add(Selection::new(rect(21, 30, 10, 6), 0));
app.cursor = Point::new(25, 33);
app.nudge(1, 0);
assert_eq!(app.selections.items()[0].shape, rect(22, 30, 10, 6));
}
#[test]
fn an_idle_cursor_never_snaps() {
let mut app = test_app_with_button();
app.cursor = Point::new(23, 18);
app.update_active_gesture();
assert_eq!(app.snap_hit, Snap::default(), "nothing is in flight");
}
#[test]
fn a_measure_endpoint_snaps_to_the_edge_under_it() {
let mut app = test_app_with_button();
app.tool = ToolKind::Measure;
app.cursor = Point::new(23, 30);
app.mouse_pressed();
app.cursor = Point::new(57, 30);
app.update_active_gesture();
app.mouse_released();
assert_eq!(
app.selections.measures()[0].line,
Line::new(Point::new(20, 30), Point::new(60, 30)),
"the ruler measures the button's real width"
);
assert!((app.selections.measures()[0].line.length() - 40.0).abs() < f64::EPSILON);
}
#[test]
fn draw_gesture_commits_and_dirties() {
let mut app = test_app();
app.cursor = Point::new(10, 10);
app.mouse_pressed();
assert!(matches!(app.mode, Mode::Drawing { .. }));
app.cursor = Point::new(40, 40);
app.mouse_released();
assert!(matches!(app.mode, Mode::Idle));
assert_eq!(app.selections.len(), 1);
assert_eq!(app.selections.items()[0].shape, rect(10, 10, 30, 30));
assert!(app.dirty);
}
#[test]
fn measure_gesture_commits_a_ruler_not_a_shape() {
let mut app = test_app();
app.tool = ToolKind::Measure;
app.cursor = Point::new(10, 10);
app.mouse_pressed();
assert!(matches!(app.mode, Mode::Drawing { .. }));
app.cursor = Point::new(40, 50);
app.update_active_gesture();
assert_eq!(
app.measure_preview(0),
Some(Line::new(Point::new(10, 10), Point::new(40, 50)))
);
app.mouse_released();
assert!(app.selections.is_empty(), "a ruler is not a selection");
assert_eq!(app.selections.measures().len(), 1);
assert_eq!(
app.selections.measures()[0].line,
Line::new(Point::new(10, 10), Point::new(40, 50))
);
assert!(app.dirty);
}
#[test]
fn a_measure_click_without_a_drag_commits_nothing() {
let mut app = test_app();
app.tool = ToolKind::Measure;
app.cursor = Point::new(10, 10);
app.mouse_pressed();
app.cursor = Point::new(12, 11); app.mouse_released();
assert!(app.selections.measures().is_empty());
assert!(!app.dirty);
}
#[test]
fn shift_constrains_a_measure_to_45_degrees() {
let mut app = test_app();
app.tool = ToolKind::Measure;
app.shift_down = true;
app.cursor = Point::new(10, 10);
app.mouse_pressed();
app.cursor = Point::new(50, 12); app.mouse_released();
let line = app.selections.measures()[0].line;
assert_eq!(line.b.y, 10, "snapped flat: {line:?}");
}
#[test]
fn dragging_a_measure_endpoint_moves_only_that_end() {
let mut app = test_app();
app.tool = ToolKind::Measure;
let line = Line::new(Point::new(10, 10), Point::new(50, 10));
app.selections.add_measure(Measure::new(line, 0));
app.dirty = false;
app.cursor = Point::new(10, 10);
app.mouse_pressed();
assert!(matches!(
app.mode,
Mode::MeasureDragging {
grab: MeasureGrab::Endpoint(true),
..
}
));
app.cursor = Point::new(10, 40);
app.update_active_gesture();
app.mouse_released();
let moved = app.selections.measures()[0].line;
assert_eq!(moved, Line::new(Point::new(10, 40), Point::new(50, 10)));
assert!(app.dirty);
}
#[test]
fn dragging_a_measures_body_translates_it_whole() {
let mut app = test_app();
app.tool = ToolKind::Measure;
let line = Line::new(Point::new(10, 10), Point::new(50, 10));
app.selections.add_measure(Measure::new(line, 0));
app.cursor = Point::new(30, 10);
app.mouse_pressed();
assert!(matches!(
app.mode,
Mode::MeasureDragging {
grab: MeasureGrab::Move,
..
}
));
app.cursor = Point::new(35, 20);
app.update_active_gesture();
app.mouse_released();
assert_eq!(
app.selections.measures()[0].line,
Line::new(Point::new(15, 20), Point::new(55, 20)),
"length and angle survive a move"
);
}
#[test]
fn measure_keys_reach_rulers_only_while_the_measure_tool_is_active() {
let mut app = test_app();
app.selections.add_measure(Measure::new(
Line::new(Point::new(10, 10), Point::new(50, 10)),
0,
));
app.cursor = Point::new(30, 10);
assert_eq!(app.measure_at_cursor(), None);
app.tool = ToolKind::Measure;
assert_eq!(app.measure_at_cursor(), Some(0));
}
#[test]
fn deleting_and_labeling_at_the_cursor_reach_the_measure_under_it() {
let mut app = test_app();
app.tool = ToolKind::Measure;
app.selections.add_measure(Measure::new(
Line::new(Point::new(10, 10), Point::new(50, 10)),
0,
));
app.cursor = Point::new(30, 10);
app.apply_local_action(Action::LabelEditAtCursor);
assert!(matches!(
app.mode,
Mode::LabelEditing {
target: EditTarget::Measure,
index: 0,
..
}
));
let Mode::LabelEditing { text, .. } = &mut app.mode else {
unreachable!()
};
text.push_str("gap");
app.commit_label();
assert_eq!(app.selections.measures()[0].label, "gap");
app.apply_local_action(Action::DeleteAtCursor);
assert!(app.selections.measures().is_empty());
}
#[test]
fn sub_threshold_draw_commits_nothing_and_stays_clean() {
let mut app = test_app();
app.cursor = Point::new(10, 10);
app.mouse_pressed();
app.cursor = Point::new(11, 11); app.mouse_released();
assert!(app.selections.is_empty());
assert!(!app.dirty);
}
#[test]
fn click_inside_shape_without_motion_stays_clean() {
let mut app = test_app();
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.dirty = false; app.cursor = Point::new(20, 20);
app.mouse_pressed();
assert!(matches!(app.mode, Mode::Dragging { .. }));
app.mouse_released();
assert!(!app.dirty, "no-op move must not dirty the session");
assert!(app.selections.undo());
assert!(app.selections.is_empty());
}
#[test]
fn real_move_dirties_and_disarms_quit() {
let mut app = test_app();
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.dirty = false;
app.quit_armed_until = Some(Instant::now() + Duration::from_secs(60));
app.cursor = Point::new(20, 20);
app.mouse_pressed();
app.selections.set_shape_live(0, rect(30, 10, 30, 30));
app.mouse_released();
assert!(app.dirty);
assert!(
app.quit_armed_until.is_none(),
"new work must re-arm the quit warning"
);
}
#[test]
fn border_press_starts_a_resize() {
let mut app = test_app();
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.cursor = Point::new(10, 25); app.mouse_pressed();
assert!(matches!(app.mode, Mode::Resizing { .. }));
}
#[test]
fn typed_text_stops_at_the_label_cap() {
let mut text = "x".repeat(MAX_LABEL_LEN - 1);
append_typed(&mut text, Some("ab"));
assert_eq!(text.chars().count(), MAX_LABEL_LEN);
append_typed(&mut text, Some("cd"));
assert_eq!(text.chars().count(), MAX_LABEL_LEN, "cap is not exceeded");
}
#[test]
fn typed_text_drops_control_characters() {
let mut text = String::new();
append_typed(&mut text, Some("a\tb\nc"));
assert_eq!(text, "abc");
}
#[test]
fn no_typed_text_is_a_noop() {
let mut text = "kept".to_string();
append_typed(&mut text, None);
assert_eq!(text, "kept");
}
#[test]
fn label_commit_dirties_only_on_change() {
let mut app = test_app();
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.dirty = false;
app.mode = Mode::LabelEditing {
target: EditTarget::Selection,
index: 0,
text: "target".into(),
};
app.commit_label();
assert_eq!(app.selections.items()[0].label, "target");
assert!(app.dirty);
app.dirty = false;
app.mode = Mode::LabelEditing {
target: EditTarget::Selection,
index: 0,
text: "target".into(),
};
app.commit_label();
assert!(!app.dirty, "unchanged label must not dirty the session");
}
#[test]
fn save_writes_files_resets_dirty_and_retires_crops_on_resave() {
let dir = std::env::temp_dir().join("pixelcoords-app-test-save");
let _ = std::fs::remove_dir_all(&dir);
let mut app = test_app();
app.out_dir.clone_from(&dir);
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.selections.add(Selection::new(rect(50, 10, 20, 20), 0));
app.dirty = true;
app.save();
assert!(dir.join("session.json").exists());
assert!(dir.join("screenshot-0.png").exists());
assert!(dir.join("crop-0.png").exists());
assert!(dir.join("crop-1.png").exists());
assert!(!app.dirty);
assert!(app.saved_once);
assert_eq!(app.last_crops.len(), 2);
app.selections.delete(1);
app.save();
assert!(dir.join("crop-0.png").exists());
assert!(!dir.join("crop-1.png").exists());
assert_eq!(app.last_crops.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hit_and_preview_respect_state() {
let mut app = test_app();
assert_eq!(app.hit_at_cursor(), None);
assert_eq!(app.preview_for(0), None);
app.selections.add(Selection::new(rect(10, 10, 30, 30), 0));
app.cursor = Point::new(20, 20);
assert_eq!(app.hit_at_cursor(), Some(0));
assert!(app.overlay_state().has_selection);
assert!(app.overlay_state().cursor_in_shape);
app.mode = Mode::Drawing {
frame: 0,
start: Point::new(50, 40),
path: vec![Point::new(50, 40)],
};
app.cursor = Point::new(70, 55);
app.update_active_gesture();
assert_eq!(app.preview_for(0), Some(rect(50, 40, 20, 15)));
assert_eq!(app.preview_for(1), None, "preview is frame-local");
}
#[test]
fn a_freehand_release_commits_the_simplified_stroke() {
let mut app = test_app();
app.tool = ToolKind::Freehand;
let path: Vec<Point> = (0..20)
.map(|i| Point::new(i * 2, i * 2 + (i % 2)))
.chain((0..10).map(|i| Point::new(40 + i * 2, 40)))
.collect();
app.mode = Mode::Drawing {
frame: 0,
start: path[0],
path,
};
app.mouse_released();
assert_eq!(app.selections.len(), 1, "the stroke committed");
let Shape::Poly { points } = &app.selections.items()[0].shape else {
panic!("freehand commits a poly");
};
assert!(
points.len() <= 6,
"jitter simplified away, got {} points",
points.len()
);
assert!(app.dirty);
}
#[test]
fn a_polygon_drag_commits_a_regular_ngon_with_the_chosen_sides() {
let mut app = test_app();
app.tool = ToolKind::Polygon;
app.polygon_sides = 5;
app.mode = Mode::Drawing {
frame: 0,
start: Point::new(50, 30),
path: vec![Point::new(50, 30)],
};
app.cursor = Point::new(70, 30);
app.mouse_released();
assert_eq!(app.selections.len(), 1);
let Shape::Poly { points } = &app.selections.items()[0].shape else {
panic!("polygon commits a poly");
};
assert_eq!(points.len(), 5);
assert_eq!(points[0], Point::new(70, 30));
}
#[test]
fn committable_threshold_discards_tiny_shapes() {
assert!(!shape_is_committable(&Shape::Rect(
pixelcoords_core::geometry::Rect::new(0, 0, 1, 5)
)));
assert!(shape_is_committable(&Shape::Rect(
pixelcoords_core::geometry::Rect::new(0, 0, 2, 2)
)));
assert!(!shape_is_committable(&Shape::Circle { cx: 0, cy: 0, r: 1 }));
assert!(shape_is_committable(&Shape::Circle { cx: 0, cy: 0, r: 2 }));
}
#[test]
fn rgba_conversion_packs_0rgb() {
let img = RgbaImage::from_pixel(1, 1, image::Rgba([0x12, 0x34, 0x56, 0xFF]));
assert_eq!(rgba_to_0rgb(&img), vec![0x0012_3456]);
}
#[test]
fn resize_icons_match_handle_orientation() {
let rect = Shape::Rect(pixelcoords_core::geometry::Rect::new(0, 0, 100, 100));
let corner = ResizeHandle::RectEdges {
left: false,
right: true,
top: false,
bottom: true,
};
assert_eq!(
resize_icon(corner, &rect, Point::new(100, 100)),
CursorIcon::NwseResize
);
let edge = ResizeHandle::RectEdges {
left: true,
right: false,
top: false,
bottom: false,
};
assert_eq!(
resize_icon(edge, &rect, Point::new(0, 50)),
CursorIcon::EwResize
);
let circle = Shape::Circle {
cx: 50,
cy: 50,
r: 30,
};
assert_eq!(
resize_icon(ResizeHandle::CircleRadius, &circle, Point::new(50, 80)),
CursorIcon::NsResize
);
assert_eq!(
resize_icon(ResizeHandle::CircleRadius, &circle, Point::new(80, 50)),
CursorIcon::EwResize
);
}
#[test]
fn mid_gesture_gate_allows_only_quit() {
assert!(allowed_mid_gesture(Action::Quit));
for action in [
Action::Save,
Action::NextTool,
Action::DeleteAtCursor,
Action::LabelEditAtCursor,
Action::Undo,
Action::RotateCcw,
Action::RotateCw,
Action::NextTheme,
] {
assert!(!allowed_mid_gesture(action), "{action:?} must be gated");
}
}
#[test]
fn identical_monitors_pair_by_position() {
use winit::dpi::{PhysicalPosition, PhysicalSize};
let size = PhysicalSize::new(3024, 1964);
let candidates = [
(size, PhysicalPosition::new(3024, 0)), (size, PhysicalPosition::new(0, 0)), ];
assert_eq!(pick_monitor(&candidates, size, (3024.0, 0.0)), Some(0));
assert_eq!(pick_monitor(&candidates, size, (0.0, 0.0)), Some(1));
assert_eq!(
pick_monitor(&candidates, PhysicalSize::new(800, 600), (0.0, 0.0)),
None
);
}
#[test]
fn monitor_frame_derives_scale_and_size() {
let info = MonitorInfo {
index: 0,
name: "Test".into(),
primary: true,
origin: Point::new(0, 0),
size_native: Size::new(50, 30),
scale: 2.0,
};
let frame = MonitorFrame::new(info, RgbaImage::new(100, 60));
assert_eq!(frame.size, Size::new(100, 60));
assert_eq!(frame.ui_scale, 2);
}
}