Skip to main content

dartboard_editor/
lib.rs

1//! # dartboard-editor
2//!
3//! Host-neutral editor state and dispatch for the dartboard terminal drawing
4//! tool. This crate has no terminal-I/O dependencies — hosts construct
5//! [`AppIntent`] values from their own input layer and feed them to the
6//! editor.
7//!
8//! ## Host wiring
9//!
10//! 1. Own an [`EditorSession`] and a [`dartboard_core::Canvas`].
11//! 2. Translate your host's input events into [`AppKey`] and
12//!    [`AppPointerEvent`]. Pointer coordinates are 0-based cell positions
13//!    in the host's display grid; the host is responsible for any terminal
14//!    protocol conversion (e.g., SGR 1-based → 0-based).
15//! 3. Route key input through [`KeyMap::resolve`] plus [`handle_editor_action`]
16//!    (or [`handle_editor_key_press`] for the default keymap path).
17//! 4. Route pointer input through [`handle_editor_pointer`]; if your host
18//!    has overlay UI (swatches, menus), hit-test those first and only
19//!    forward events that should reach the editor. Use
20//!    [`EditorSession::viewport_contains`] for canvas hit-testing.
21//! 5. Inspect [`EditorPointerDispatch::outcome`]:
22//!    [`PointerOutcome::Consumed`] means suppress outer UI;
23//!    [`PointerOutcome::Passthrough`] means the editor did not act on the
24//!    event and the host may bubble it to outer layers.
25//!
26//! ## Default pointer policy
27//!
28//! [`handle_editor_pointer`] implements the hover policy most hosts want:
29//! passive [`AppPointerKind::Moved`] events over the canvas do **not**
30//! move the caret when no floating preview is armed, and **do** follow
31//! the cursor when one is (so brush/stamp previews track the pointer).
32//! Layered hosts should simply forward every [`AppPointerEvent`] they
33//! want the editor to see and rely on [`PointerOutcome`] to decide
34//! whether to bubble.
35//!
36//! Crossterm adapters for the reference CLI live in the `dartboard-cli`
37//! crate. Non-crossterm hosts (e.g., VTE-based shells) construct
38//! [`AppIntent`] values directly from their own parsed events.
39
40use std::collections::HashSet;
41
42use dartboard_core::{ops::CellWrite, Canvas, CanvasOp, CellValue, Pos, RgbColor};
43
44pub mod keymap;
45pub mod session_mirror;
46
47pub use keymap::{
48    ActionSpec, BindingContext, EditorContext, HelpEntry, HelpSection, KeyBinding, KeyMap,
49    KeyTrigger,
50};
51pub use session_mirror::{ConnectState, MirrorEvent, SessionMirror};
52
53pub const SWATCH_CAPACITY: usize = 5;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub struct Viewport {
57    pub x: u16,
58    pub y: u16,
59    pub width: u16,
60    pub height: u16,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub struct AppModifiers {
65    pub ctrl: bool,
66    pub alt: bool,
67    pub shift: bool,
68    pub meta: bool,
69}
70
71impl AppModifiers {
72    pub fn has_alt_like(self) -> bool {
73        self.alt || self.meta
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum AppKeyCode {
79    Backspace,
80    Enter,
81    Left,
82    Right,
83    Up,
84    Down,
85    Home,
86    End,
87    PageUp,
88    PageDown,
89    Tab,
90    BackTab,
91    Delete,
92    Esc,
93    F(u8),
94    Char(char),
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct AppKey {
99    pub code: AppKeyCode,
100    pub modifiers: AppModifiers,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum AppPointerButton {
105    Left,
106    Right,
107    Middle,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum AppPointerKind {
112    Down(AppPointerButton),
113    Up(AppPointerButton),
114    Drag(AppPointerButton),
115    Moved,
116    ScrollUp,
117    ScrollDown,
118    ScrollLeft,
119    ScrollRight,
120}
121
122/// A pointer event in the host's display grid.
123///
124/// `column` and `row` are 0-based cell coordinates; hosts that receive
125/// 1-based terminal coordinates (e.g., SGR mouse reports) must normalize
126/// before constructing this value.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct AppPointerEvent {
129    pub column: u16,
130    pub row: u16,
131    pub kind: AppPointerKind,
132    pub modifiers: AppModifiers,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum AppIntent {
137    KeyPress(AppKey),
138    Pointer(AppPointerEvent),
139    Paste(String),
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum HostEffect {
144    RequestQuit,
145    CopyToClipboard(String),
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Default)]
149pub struct EditorKeyDispatch {
150    pub handled: bool,
151    pub effects: Vec<HostEffect>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum MoveDir {
156    Left,
157    Right,
158    Up,
159    Down,
160    LineStart,
161    LineEnd,
162    PageUp,
163    PageDown,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum EditorAction {
168    Move {
169        dir: MoveDir,
170        extend_selection: bool,
171    },
172    MoveDownLine,
173    StrokeFloating {
174        dir: MoveDir,
175    },
176    Pan {
177        dx: isize,
178        dy: isize,
179    },
180    ClearSelection,
181    TransposeSelectionCorner,
182
183    PushLeft,
184    PushRight,
185    PushUp,
186    PushDown,
187    PullFromLeft,
188    PullFromRight,
189    PullFromUp,
190    PullFromDown,
191
192    CopySelection,
193    CutSelection,
194    PastePrimarySwatch,
195    ExportSystemClipboard,
196    ActivateSwatch(usize),
197
198    SmartFill,
199    DrawBorder,
200    FillSelectionOrCell(char),
201
202    InsertChar(char),
203    Backspace,
204    Delete,
205
206    ToggleFloatingTransparency,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
210pub enum Mode {
211    #[default]
212    Draw,
213    Select,
214}
215
216impl Mode {
217    pub fn is_selecting(self) -> bool {
218        matches!(self, Mode::Select)
219    }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
223pub enum SelectionShape {
224    #[default]
225    Rect,
226    Ellipse,
227}
228
229#[derive(Debug, Clone, Copy)]
230pub struct Selection {
231    pub anchor: Pos,
232    pub cursor: Pos,
233    pub shape: SelectionShape,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct Bounds {
238    pub min_x: usize,
239    pub max_x: usize,
240    pub min_y: usize,
241    pub max_y: usize,
242}
243
244impl Bounds {
245    pub fn from_points(a: Pos, b: Pos) -> Self {
246        Self {
247            min_x: a.x.min(b.x),
248            max_x: a.x.max(b.x),
249            min_y: a.y.min(b.y),
250            max_y: a.y.max(b.y),
251        }
252    }
253
254    pub fn single(pos: Pos) -> Self {
255        Self::from_points(pos, pos)
256    }
257
258    pub fn width(self) -> usize {
259        self.max_x - self.min_x + 1
260    }
261
262    pub fn height(self) -> usize {
263        self.max_y - self.min_y + 1
264    }
265
266    pub fn normalized_for_canvas(self, canvas: &Canvas) -> Self {
267        let mut bounds = self;
268        for y in self.min_y..=self.max_y {
269            if bounds.min_x > 0 && canvas.is_continuation(Pos { x: bounds.min_x, y }) {
270                bounds.min_x -= 1;
271            }
272            if matches!(
273                canvas.cell(Pos { x: bounds.max_x, y }),
274                Some(CellValue::Wide(_))
275            ) && bounds.max_x + 1 < canvas.width
276            {
277                bounds.max_x += 1;
278            }
279        }
280        bounds
281    }
282}
283
284impl Selection {
285    pub fn bounds(self) -> Bounds {
286        Bounds::from_points(self.anchor, self.cursor)
287    }
288
289    pub fn contains(self, pos: Pos) -> bool {
290        let bounds = self.bounds();
291        if pos.x < bounds.min_x
292            || pos.x > bounds.max_x
293            || pos.y < bounds.min_y
294            || pos.y > bounds.max_y
295        {
296            return false;
297        }
298
299        match self.shape {
300            SelectionShape::Rect => true,
301            SelectionShape::Ellipse => {
302                if bounds.width() <= 1 || bounds.height() <= 1 {
303                    return true;
304                }
305
306                let px = pos.x as f64 + 0.5;
307                let py = pos.y as f64 + 0.5;
308                let cx = (bounds.min_x + bounds.max_x + 1) as f64 / 2.0;
309                let cy = (bounds.min_y + bounds.max_y + 1) as f64 / 2.0;
310                let rx = bounds.width() as f64 / 2.0;
311                let ry = bounds.height() as f64 / 2.0;
312                let dx = (px - cx) / rx;
313                let dy = (py - cy) / ry;
314                dx * dx + dy * dy <= 1.0
315            }
316        }
317    }
318}
319
320#[derive(Debug, Clone)]
321pub struct Clipboard {
322    pub width: usize,
323    pub height: usize,
324    cells: Vec<Option<CellValue>>,
325}
326
327impl Clipboard {
328    pub fn new(width: usize, height: usize, cells: Vec<Option<CellValue>>) -> Self {
329        Self {
330            width,
331            height,
332            cells,
333        }
334    }
335
336    pub fn get(&self, x: usize, y: usize) -> Option<CellValue> {
337        self.cells[y * self.width + x]
338    }
339
340    pub fn cells(&self) -> &[Option<CellValue>] {
341        &self.cells
342    }
343}
344
345#[derive(Debug, Clone)]
346pub struct Swatch {
347    pub clipboard: Clipboard,
348    pub pinned: bool,
349}
350
351#[derive(Debug, Clone)]
352pub struct FloatingSelection {
353    pub clipboard: Clipboard,
354    pub transparent: bool,
355    pub source_index: Option<usize>,
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359pub enum SwatchActivation {
360    Ignored,
361    ToggledTransparency,
362    ActivatedFloating,
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
366pub struct PanDrag {
367    pub col: u16,
368    pub row: u16,
369    pub origin: Pos,
370}
371
372#[derive(Debug, Clone)]
373pub struct EditorSession {
374    pub cursor: Pos,
375    pub mode: Mode,
376    pub viewport: Viewport,
377    pub viewport_origin: Pos,
378    pub selection_anchor: Option<Pos>,
379    pub selection_shape: SelectionShape,
380    pub drag_origin: Option<Pos>,
381    pub pan_drag: Option<PanDrag>,
382    pub swatches: [Option<Swatch>; SWATCH_CAPACITY],
383    pub floating: Option<FloatingSelection>,
384    pub paint_stroke_anchor: Option<Pos>,
385    pub paint_stroke_last: Option<Pos>,
386}
387
388impl Default for EditorSession {
389    fn default() -> Self {
390        Self {
391            cursor: Pos { x: 0, y: 0 },
392            mode: Mode::Draw,
393            viewport: Viewport::default(),
394            viewport_origin: Pos { x: 0, y: 0 },
395            selection_anchor: None,
396            selection_shape: SelectionShape::Rect,
397            drag_origin: None,
398            pan_drag: None,
399            swatches: Default::default(),
400            floating: None,
401            paint_stroke_anchor: None,
402            paint_stroke_last: None,
403        }
404    }
405}
406
407impl EditorSession {
408    pub fn selection(&self) -> Option<Selection> {
409        self.selection_anchor.map(|anchor| Selection {
410            anchor,
411            cursor: self.cursor,
412            shape: self.selection_shape,
413        })
414    }
415
416    pub fn clear_selection(&mut self) {
417        self.selection_anchor = None;
418        self.selection_shape = SelectionShape::Rect;
419        self.mode = Mode::Draw;
420    }
421
422    pub fn begin_selection_with_shape(&mut self, shape: SelectionShape) {
423        if self.selection_anchor.is_none() {
424            self.selection_anchor = Some(self.cursor);
425        }
426        self.selection_shape = shape;
427        self.mode = Mode::Select;
428    }
429
430    pub fn begin_selection(&mut self) {
431        self.begin_selection_with_shape(SelectionShape::Rect);
432    }
433
434    pub fn visible_bounds(&self, canvas: &Canvas) -> Bounds {
435        if self.viewport.width == 0 || self.viewport.height == 0 {
436            return self.full_canvas_bounds(canvas);
437        }
438
439        let min_x = self.viewport_origin.x.min(canvas.width.saturating_sub(1));
440        let min_y = self.viewport_origin.y.min(canvas.height.saturating_sub(1));
441        let max_x = (self.viewport_origin.x + self.viewport.width.saturating_sub(1) as usize)
442            .min(canvas.width.saturating_sub(1));
443        let max_y = (self.viewport_origin.y + self.viewport.height.saturating_sub(1) as usize)
444            .min(canvas.height.saturating_sub(1));
445
446        Bounds {
447            min_x,
448            max_x,
449            min_y,
450            max_y,
451        }
452    }
453
454    pub fn clamp_cursor_to_visible_bounds(&mut self, canvas: &Canvas) {
455        let bounds = self.visible_bounds(canvas);
456        self.cursor.x = self.cursor.x.clamp(bounds.min_x, bounds.max_x);
457        self.cursor.y = self.cursor.y.clamp(bounds.min_y, bounds.max_y);
458    }
459
460    pub fn move_left(&mut self, canvas: &Canvas) {
461        if self.cursor.x == 0 {
462            return;
463        }
464        self.cursor.x -= 1;
465        self.scroll_viewport_to_cursor(canvas);
466    }
467
468    pub fn move_right(&mut self, canvas: &Canvas) {
469        if self.cursor.x + 1 >= canvas.width {
470            return;
471        }
472        self.cursor.x += 1;
473        self.scroll_viewport_to_cursor(canvas);
474    }
475
476    pub fn move_up(&mut self, canvas: &Canvas) {
477        if self.cursor.y == 0 {
478            return;
479        }
480        self.cursor.y -= 1;
481        self.scroll_viewport_to_cursor(canvas);
482    }
483
484    pub fn move_down(&mut self, canvas: &Canvas) {
485        if self.cursor.y + 1 >= canvas.height {
486            return;
487        }
488        self.cursor.y += 1;
489        self.scroll_viewport_to_cursor(canvas);
490    }
491
492    pub fn move_dir(&mut self, canvas: &Canvas, dir: MoveDir) {
493        match dir {
494            MoveDir::Up => self.move_up(canvas),
495            MoveDir::Down => self.move_down(canvas),
496            MoveDir::Left => self.move_left(canvas),
497            MoveDir::Right => self.move_right(canvas),
498            MoveDir::LineStart => move_to_left_edge(self, canvas),
499            MoveDir::LineEnd => move_to_right_edge(self, canvas),
500            MoveDir::PageUp => move_to_top_edge(self, canvas),
501            MoveDir::PageDown => move_to_bottom_edge(self, canvas),
502        }
503    }
504
505    pub fn scroll_viewport_to_cursor(&mut self, canvas: &Canvas) {
506        let bounds = self.visible_bounds(canvas);
507        if self.cursor.x < bounds.min_x {
508            self.viewport_origin.x -= bounds.min_x - self.cursor.x;
509        } else if self.cursor.x > bounds.max_x {
510            self.viewport_origin.x += self.cursor.x - bounds.max_x;
511        }
512        if self.cursor.y < bounds.min_y {
513            self.viewport_origin.y -= bounds.min_y - self.cursor.y;
514        } else if self.cursor.y > bounds.max_y {
515            self.viewport_origin.y += self.cursor.y - bounds.max_y;
516        }
517        self.clamp_viewport_origin(canvas);
518    }
519
520    pub fn clamp_viewport_origin(&mut self, canvas: &Canvas) {
521        let max_x = canvas
522            .width
523            .saturating_sub(self.viewport.width.max(1) as usize);
524        let max_y = canvas
525            .height
526            .saturating_sub(self.viewport.height.max(1) as usize);
527        self.viewport_origin.x = self.viewport_origin.x.min(max_x);
528        self.viewport_origin.y = self.viewport_origin.y.min(max_y);
529    }
530
531    pub fn set_viewport(&mut self, viewport: Viewport, canvas: &Canvas) {
532        self.viewport = viewport;
533        self.clamp_viewport_origin(canvas);
534        self.clamp_cursor_to_visible_bounds(canvas);
535    }
536
537    pub fn pan_by(&mut self, canvas: &Canvas, dx: isize, dy: isize) {
538        self.viewport_origin.x = self.viewport_origin.x.saturating_add_signed(dx);
539        self.viewport_origin.y = self.viewport_origin.y.saturating_add_signed(dy);
540        self.clamp_viewport_origin(canvas);
541        self.clamp_cursor_to_visible_bounds(canvas);
542    }
543
544    pub fn begin_pan(&mut self, col: u16, row: u16) {
545        self.pan_drag = Some(PanDrag {
546            col,
547            row,
548            origin: self.viewport_origin,
549        });
550    }
551
552    pub fn drag_pan(&mut self, canvas: &Canvas, col: u16, row: u16) {
553        let Some(pan_drag) = self.pan_drag else {
554            return;
555        };
556        let dx = pan_drag.col as i32 - col as i32;
557        let dy = pan_drag.row as i32 - row as i32;
558        self.viewport_origin.x = pan_drag.origin.x.saturating_add_signed(dx as isize);
559        self.viewport_origin.y = pan_drag.origin.y.saturating_add_signed(dy as isize);
560        self.clamp_viewport_origin(canvas);
561        self.clamp_cursor_to_visible_bounds(canvas);
562    }
563
564    pub fn end_pan(&mut self) {
565        self.pan_drag = None;
566    }
567
568    pub fn viewport_contains(&self, col: u16, row: u16) -> bool {
569        let col = col as usize;
570        let row = row as usize;
571        let vx = self.viewport.x as usize;
572        let vy = self.viewport.y as usize;
573        let vw = self.viewport.width as usize;
574        let vh = self.viewport.height as usize;
575        col >= vx && row >= vy && col < vx + vw && row < vy + vh
576    }
577
578    pub fn canvas_pos_for_pointer(&self, col: u16, row: u16, canvas: &Canvas) -> Option<Pos> {
579        if !self.viewport_contains(col, row) {
580            return None;
581        }
582        let col = col as usize;
583        let row = row as usize;
584        let vx = self.viewport.x as usize;
585        let vy = self.viewport.y as usize;
586        let cx = self.viewport_origin.x + col - vx;
587        let cy = self.viewport_origin.y + row - vy;
588        if cx < canvas.width && cy < canvas.height {
589            Some(Pos { x: cx, y: cy })
590        } else {
591            None
592        }
593    }
594
595    pub fn clamp_cursor(&mut self, canvas: &Canvas) {
596        self.cursor.x = self.cursor.x.min(canvas.width.saturating_sub(1));
597        self.cursor.y = self.cursor.y.min(canvas.height.saturating_sub(1));
598        self.clamp_cursor_to_visible_bounds(canvas);
599    }
600
601    pub fn selection_bounds(&self) -> Option<Bounds> {
602        self.selection().map(Selection::bounds)
603    }
604
605    pub fn selection_or_cursor_bounds(&self) -> Bounds {
606        self.selection_bounds()
607            .unwrap_or_else(|| Bounds::single(self.cursor))
608    }
609
610    pub fn full_canvas_bounds(&self, canvas: &Canvas) -> Bounds {
611        Bounds {
612            min_x: 0,
613            max_x: canvas.width.saturating_sub(1),
614            min_y: 0,
615            max_y: canvas.height.saturating_sub(1),
616        }
617    }
618
619    pub fn system_clipboard_bounds(&self, canvas: &Canvas) -> Bounds {
620        self.selection_bounds()
621            .unwrap_or_else(|| self.full_canvas_bounds(canvas))
622            .normalized_for_canvas(canvas)
623    }
624
625    pub fn push_swatch(&mut self, clipboard: Clipboard) {
626        let unpinned_slots: Vec<usize> = (0..SWATCH_CAPACITY)
627            .filter(|&i| !matches!(&self.swatches[i], Some(swatch) if swatch.pinned))
628            .collect();
629        if unpinned_slots.is_empty() {
630            return;
631        }
632
633        let mut queue: Vec<Swatch> = unpinned_slots
634            .iter()
635            .filter_map(|&i| self.swatches[i].take())
636            .collect();
637        queue.insert(
638            0,
639            Swatch {
640                clipboard,
641                pinned: false,
642            },
643        );
644        queue.truncate(unpinned_slots.len());
645
646        for (slot_idx, swatch) in unpinned_slots.iter().zip(queue.into_iter()) {
647            self.swatches[*slot_idx] = Some(swatch);
648        }
649    }
650
651    #[cfg(test)]
652    pub fn populated_swatch_count(&self) -> usize {
653        self.swatches
654            .iter()
655            .filter(|swatch| swatch.is_some())
656            .count()
657    }
658
659    pub fn toggle_pin(&mut self, idx: usize) {
660        if idx >= SWATCH_CAPACITY {
661            return;
662        }
663        if let Some(swatch) = self.swatches[idx].as_mut() {
664            swatch.pinned = !swatch.pinned;
665        }
666    }
667
668    pub fn clear_swatch(&mut self, idx: usize) {
669        if idx >= SWATCH_CAPACITY {
670            return;
671        }
672        self.swatches[idx] = None;
673        if self
674            .floating
675            .as_ref()
676            .is_some_and(|floating| floating.source_index == Some(idx))
677        {
678            dismiss_floating(self);
679        }
680    }
681
682    pub fn activate_swatch(&mut self, idx: usize) -> SwatchActivation {
683        if idx >= SWATCH_CAPACITY {
684            return SwatchActivation::Ignored;
685        }
686        let Some(swatch) = self.swatches[idx].as_ref() else {
687            return SwatchActivation::Ignored;
688        };
689        match self.floating.as_mut() {
690            Some(floating) if floating.source_index == Some(idx) => {
691                floating.transparent = !floating.transparent;
692                SwatchActivation::ToggledTransparency
693            }
694            _ => {
695                self.floating = Some(FloatingSelection {
696                    clipboard: swatch.clipboard.clone(),
697                    transparent: false,
698                    source_index: Some(idx),
699                });
700                self.clear_selection();
701                SwatchActivation::ActivatedFloating
702            }
703        }
704    }
705
706    pub fn toggle_float_transparency(&mut self) {
707        if let Some(floating) = self.floating.as_mut() {
708            floating.transparent = !floating.transparent;
709        }
710    }
711
712    pub fn floating_brush_width(&self) -> usize {
713        self.floating
714            .as_ref()
715            .map(|floating| floating.clipboard.width.max(1))
716            .unwrap_or(1)
717    }
718}
719
720pub fn diff_canvas_op(before: &Canvas, after: &Canvas, default_fg: RgbColor) -> Option<CanvasOp> {
721    let mut origins: HashSet<Pos> = HashSet::new();
722    for (pos, cell) in before.iter() {
723        if matches!(cell, CellValue::Narrow(_) | CellValue::Wide(_)) {
724            origins.insert(*pos);
725        }
726    }
727    for (pos, cell) in after.iter() {
728        if matches!(cell, CellValue::Narrow(_) | CellValue::Wide(_)) {
729            origins.insert(*pos);
730        }
731    }
732
733    let mut origins: Vec<Pos> = origins.into_iter().collect();
734    origins.sort_by_key(|p| (p.y, p.x));
735
736    let mut writes: Vec<CellWrite> = Vec::new();
737    for pos in origins {
738        let a_cell = after.cell(pos);
739        let b_cell = before.cell(pos);
740        let a_fg = after.fg(pos);
741        let b_fg = before.fg(pos);
742        if a_cell == b_cell && a_fg == b_fg {
743            continue;
744        }
745        match a_cell {
746            Some(CellValue::Narrow(ch)) | Some(CellValue::Wide(ch)) => {
747                writes.push(CellWrite::Paint {
748                    pos,
749                    ch,
750                    fg: a_fg.unwrap_or(default_fg),
751                });
752            }
753            _ => writes.push(CellWrite::Clear { pos }),
754        }
755    }
756
757    match writes.len() {
758        0 => None,
759        1 => Some(match writes.remove(0) {
760            CellWrite::Paint { pos, ch, fg } => CanvasOp::PaintCell { pos, ch, fg },
761            CellWrite::Clear { pos } => CanvasOp::ClearCell { pos },
762        }),
763        _ => Some(CanvasOp::PaintRegion { cells: writes }),
764    }
765}
766
767pub fn fill_bounds(canvas: &mut Canvas, bounds: Bounds, ch: char, fg: RgbColor) {
768    for y in bounds.min_y..=bounds.max_y {
769        let mut x = bounds.min_x;
770        while x <= bounds.max_x {
771            if ch == ' ' {
772                canvas.clear(Pos { x, y });
773                x += 1;
774                continue;
775            }
776
777            let width = Canvas::display_width(ch);
778            if width == 2 && x == bounds.max_x {
779                break;
780            }
781            let _ = canvas.put_glyph_colored(Pos { x, y }, ch, fg);
782            x += width;
783        }
784    }
785}
786
787pub fn fill_selection(
788    canvas: &mut Canvas,
789    selection: Selection,
790    bounds: Bounds,
791    ch: char,
792    fg: RgbColor,
793) {
794    if selection.shape == SelectionShape::Rect {
795        fill_bounds(canvas, bounds, ch, fg);
796        return;
797    }
798
799    let glyph_width = Canvas::display_width(ch);
800    for y in bounds.min_y..=bounds.max_y {
801        let mut x = bounds.min_x;
802        while x <= bounds.max_x {
803            let pos = Pos { x, y };
804            if !selection.contains(pos) {
805                x += 1;
806                continue;
807            }
808
809            if ch == ' ' {
810                canvas.clear(pos);
811                x += 1;
812                continue;
813            }
814
815            if glyph_width == 1 {
816                canvas.set_colored(pos, ch, fg);
817                x += 1;
818                continue;
819            }
820
821            if x < bounds.max_x && selection.contains(Pos { x: x + 1, y }) {
822                let _ = canvas.put_glyph_colored(pos, ch, fg);
823                x += glyph_width;
824            } else {
825                x += 1;
826            }
827        }
828    }
829}
830
831fn selection_has_unselected_neighbor(selection: Selection, pos: Pos) -> bool {
832    let neighbors = [
833        pos.x.checked_sub(1).map(|x| Pos { x, y: pos.y }),
834        Some(Pos {
835            x: pos.x + 1,
836            y: pos.y,
837        }),
838        pos.y.checked_sub(1).map(|y| Pos { x: pos.x, y }),
839        Some(Pos {
840            x: pos.x,
841            y: pos.y + 1,
842        }),
843    ];
844    neighbors
845        .into_iter()
846        .flatten()
847        .any(|neighbor| !selection.contains(neighbor))
848}
849
850pub fn draw_border(canvas: &mut Canvas, selection: Selection, color: RgbColor) {
851    let bounds = selection.bounds();
852    if selection.shape == SelectionShape::Ellipse {
853        for y in bounds.min_y..=bounds.max_y {
854            for x in bounds.min_x..=bounds.max_x {
855                let pos = Pos { x, y };
856                if selection.contains(pos) && selection_has_unselected_neighbor(selection, pos) {
857                    canvas.set_colored(pos, '*', color);
858                }
859            }
860        }
861        return;
862    }
863
864    if bounds.width() == 1 && bounds.height() == 1 {
865        canvas.set_colored(
866            Pos {
867                x: bounds.min_x,
868                y: bounds.min_y,
869            },
870            '*',
871            color,
872        );
873        return;
874    }
875
876    if bounds.height() == 1 {
877        canvas.set_colored(
878            Pos {
879                x: bounds.min_x,
880                y: bounds.min_y,
881            },
882            '.',
883            color,
884        );
885        for x in (bounds.min_x + 1)..bounds.max_x {
886            canvas.set_colored(Pos { x, y: bounds.min_y }, '-', color);
887        }
888        canvas.set_colored(
889            Pos {
890                x: bounds.max_x,
891                y: bounds.min_y,
892            },
893            '.',
894            color,
895        );
896        return;
897    }
898
899    if bounds.width() == 1 {
900        canvas.set_colored(
901            Pos {
902                x: bounds.min_x,
903                y: bounds.min_y,
904            },
905            '.',
906            color,
907        );
908        for y in (bounds.min_y + 1)..bounds.max_y {
909            canvas.set_colored(Pos { x: bounds.min_x, y }, '|', color);
910        }
911        canvas.set_colored(
912            Pos {
913                x: bounds.min_x,
914                y: bounds.max_y,
915            },
916            '`',
917            color,
918        );
919        return;
920    }
921
922    canvas.set_colored(
923        Pos {
924            x: bounds.min_x,
925            y: bounds.min_y,
926        },
927        '.',
928        color,
929    );
930    canvas.set_colored(
931        Pos {
932            x: bounds.max_x,
933            y: bounds.min_y,
934        },
935        '.',
936        color,
937    );
938    canvas.set_colored(
939        Pos {
940            x: bounds.min_x,
941            y: bounds.max_y,
942        },
943        '`',
944        color,
945    );
946    canvas.set_colored(
947        Pos {
948            x: bounds.max_x,
949            y: bounds.max_y,
950        },
951        '\'',
952        color,
953    );
954
955    for x in (bounds.min_x + 1)..bounds.max_x {
956        canvas.set_colored(Pos { x, y: bounds.min_y }, '-', color);
957        canvas.set_colored(Pos { x, y: bounds.max_y }, '-', color);
958    }
959
960    for y in (bounds.min_y + 1)..bounds.max_y {
961        canvas.set_colored(Pos { x: bounds.min_x, y }, '|', color);
962        canvas.set_colored(Pos { x: bounds.max_x, y }, '|', color);
963    }
964}
965
966pub fn capture_bounds(canvas: &Canvas, bounds: Bounds) -> Clipboard {
967    let mut cells = Vec::with_capacity(bounds.width() * bounds.height());
968    for y in bounds.min_y..=bounds.max_y {
969        for x in bounds.min_x..=bounds.max_x {
970            cells.push(canvas.cell(Pos { x, y }));
971        }
972    }
973    Clipboard::new(bounds.width(), bounds.height(), cells)
974}
975
976pub fn capture_selection(canvas: &Canvas, selection: Selection) -> Clipboard {
977    let bounds = selection.bounds().normalized_for_canvas(canvas);
978    let mut cells = Vec::with_capacity(bounds.width() * bounds.height());
979    for y in bounds.min_y..=bounds.max_y {
980        for x in bounds.min_x..=bounds.max_x {
981            let pos = Pos { x, y };
982            let include = selection.contains(pos)
983                || canvas
984                    .glyph_origin(pos)
985                    .is_some_and(|origin| selection.contains(origin));
986            cells.push(include.then(|| canvas.cell(pos)).flatten());
987        }
988    }
989    Clipboard::new(bounds.width(), bounds.height(), cells)
990}
991
992pub fn export_bounds_as_text(canvas: &Canvas, bounds: Bounds) -> String {
993    let mut text = String::with_capacity(bounds.width() * bounds.height() + bounds.height());
994    for y in bounds.min_y..=bounds.max_y {
995        for x in bounds.min_x..=bounds.max_x {
996            match canvas.cell(Pos { x, y }) {
997                Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => text.push(ch),
998                Some(CellValue::WideCont) => {}
999                None => text.push(' '),
1000            }
1001        }
1002        if y != bounds.max_y {
1003            text.push('\n');
1004        }
1005    }
1006    text
1007}
1008
1009pub fn export_selection_as_text(canvas: &Canvas, selection: Selection) -> String {
1010    let bounds = selection.bounds().normalized_for_canvas(canvas);
1011    let mut text = String::with_capacity(bounds.width() * bounds.height() + bounds.height());
1012    for y in bounds.min_y..=bounds.max_y {
1013        for x in bounds.min_x..=bounds.max_x {
1014            let pos = Pos { x, y };
1015            if selection.contains(pos) {
1016                match canvas.cell(pos) {
1017                    Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => text.push(ch),
1018                    Some(CellValue::WideCont) => {}
1019                    None => text.push(' '),
1020                }
1021            } else {
1022                text.push(' ');
1023            }
1024        }
1025        if y != bounds.max_y {
1026            text.push('\n');
1027        }
1028    }
1029    text
1030}
1031
1032pub fn stamp_clipboard(
1033    canvas: &mut Canvas,
1034    clipboard: &Clipboard,
1035    pos: Pos,
1036    color: RgbColor,
1037    transparent: bool,
1038) {
1039    for y in 0..clipboard.height {
1040        for x in 0..clipboard.width {
1041            let target_x = pos.x + x;
1042            let target_y = pos.y + y;
1043            if target_x >= canvas.width || target_y >= canvas.height {
1044                continue;
1045            }
1046            let target = Pos {
1047                x: target_x,
1048                y: target_y,
1049            };
1050            match clipboard.get(x, y) {
1051                Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => {
1052                    let _ = canvas.put_glyph_colored(target, ch, color);
1053                }
1054                Some(CellValue::WideCont) => {}
1055                None if !transparent => canvas.clear(target),
1056                None => {}
1057            }
1058        }
1059    }
1060}
1061
1062pub fn smart_fill_glyph(bounds: Bounds) -> char {
1063    if bounds.width() == 1 && bounds.height() > 1 {
1064        '|'
1065    } else if bounds.height() == 1 && bounds.width() > 1 {
1066        '-'
1067    } else {
1068        '*'
1069    }
1070}
1071
1072pub fn export_system_clipboard_text(editor: &EditorSession, canvas: &Canvas) -> String {
1073    match editor.selection() {
1074        Some(selection) => export_selection_as_text(canvas, selection),
1075        None => export_bounds_as_text(canvas, editor.system_clipboard_bounds(canvas)),
1076    }
1077}
1078
1079pub fn copy_selection_or_cell(editor: &mut EditorSession, canvas: &Canvas) -> bool {
1080    if editor.floating.is_some() {
1081        return false;
1082    }
1083
1084    let clipboard = match editor.selection() {
1085        Some(selection) => capture_selection(canvas, selection),
1086        None => capture_bounds(
1087            canvas,
1088            editor
1089                .selection_or_cursor_bounds()
1090                .normalized_for_canvas(canvas),
1091        ),
1092    };
1093    editor.push_swatch(clipboard);
1094    true
1095}
1096
1097pub fn cut_selection_or_cell(
1098    editor: &mut EditorSession,
1099    canvas: &mut Canvas,
1100    color: RgbColor,
1101) -> bool {
1102    if editor.floating.is_some() {
1103        return false;
1104    }
1105
1106    let selection = editor.selection();
1107    let bounds = editor
1108        .selection_or_cursor_bounds()
1109        .normalized_for_canvas(canvas);
1110    let clipboard = selection
1111        .map(|selection| capture_selection(canvas, selection))
1112        .unwrap_or_else(|| capture_bounds(canvas, bounds));
1113    editor.push_swatch(clipboard);
1114    match selection {
1115        Some(selection) => fill_selection(canvas, selection, bounds, ' ', color),
1116        None => fill_bounds(canvas, bounds, ' ', color),
1117    }
1118    true
1119}
1120
1121pub fn paste_primary_swatch(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1122    let Some(clipboard) = editor.swatches[0]
1123        .as_ref()
1124        .map(|swatch| swatch.clipboard.clone())
1125    else {
1126        return false;
1127    };
1128
1129    stamp_clipboard(canvas, &clipboard, editor.cursor, color, false);
1130    true
1131}
1132
1133pub fn smart_fill(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) {
1134    let selection = editor.selection();
1135    let bounds = editor.selection_or_cursor_bounds();
1136    let ch = smart_fill_glyph(bounds);
1137    match selection {
1138        Some(selection) => fill_selection(canvas, selection, bounds, ch, color),
1139        None => fill_bounds(canvas, bounds, ch, color),
1140    }
1141}
1142
1143pub fn draw_selection_border(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1144    let Some(selection) = editor.selection() else {
1145        return false;
1146    };
1147
1148    draw_border(canvas, selection, color);
1149    true
1150}
1151
1152pub fn fill_selection_or_cell(
1153    editor: &EditorSession,
1154    canvas: &mut Canvas,
1155    ch: char,
1156    color: RgbColor,
1157) {
1158    let selection = editor.selection();
1159    let bounds = editor
1160        .selection_or_cursor_bounds()
1161        .normalized_for_canvas(canvas);
1162    match selection {
1163        Some(selection) => fill_selection(canvas, selection, bounds, ch, color),
1164        None => fill_bounds(canvas, bounds, ch, color),
1165    }
1166}
1167
1168fn move_to_left_edge(editor: &mut EditorSession, canvas: &Canvas) {
1169    let bounds = editor.visible_bounds(canvas);
1170    if editor.cursor.x == bounds.min_x && bounds.min_x > 0 {
1171        scroll_half_viewport_left(editor, canvas, bounds);
1172    } else {
1173        editor.cursor.x = bounds.min_x;
1174    }
1175}
1176
1177fn move_to_right_edge(editor: &mut EditorSession, canvas: &Canvas) {
1178    let bounds = editor.visible_bounds(canvas);
1179    if editor.cursor.x == bounds.max_x && bounds.max_x + 1 < canvas.width {
1180        scroll_half_viewport_right(editor, canvas, bounds);
1181    } else {
1182        editor.cursor.x = bounds.max_x;
1183    }
1184}
1185
1186fn move_to_top_edge(editor: &mut EditorSession, canvas: &Canvas) {
1187    let bounds = editor.visible_bounds(canvas);
1188    if editor.cursor.y == bounds.min_y && bounds.min_y > 0 {
1189        scroll_half_viewport_up(editor, canvas, bounds);
1190    } else {
1191        editor.cursor.y = bounds.min_y;
1192    }
1193}
1194
1195fn move_to_bottom_edge(editor: &mut EditorSession, canvas: &Canvas) {
1196    let bounds = editor.visible_bounds(canvas);
1197    if editor.cursor.y == bounds.max_y && bounds.max_y + 1 < canvas.height {
1198        scroll_half_viewport_down(editor, canvas, bounds);
1199    } else {
1200        editor.cursor.y = bounds.max_y;
1201    }
1202}
1203
1204fn move_for_dir(editor: &mut EditorSession, canvas: &Canvas, dir: MoveDir) {
1205    editor.move_dir(canvas, dir);
1206}
1207
1208fn stroke_floating_move(
1209    editor: &mut EditorSession,
1210    canvas: &mut Canvas,
1211    dir: MoveDir,
1212    color: RgbColor,
1213) {
1214    if editor.floating.is_none() {
1215        return;
1216    }
1217
1218    let start = editor.cursor;
1219    let _ = paint_floating_at_cursor(editor, canvas, color);
1220    move_for_dir(editor, canvas, dir);
1221    if editor.cursor != start {
1222        let _ = paint_floating_at_cursor(editor, canvas, color);
1223    }
1224}
1225
1226fn half_page_step(span: usize) -> usize {
1227    (span / 2).max(1)
1228}
1229
1230fn scroll_half_viewport_left(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1231    let start_x = editor.viewport_origin.x;
1232    editor.viewport_origin.x = editor
1233        .viewport_origin
1234        .x
1235        .saturating_sub(half_page_step(bounds.width()));
1236    editor.clamp_viewport_origin(canvas);
1237    let delta = start_x - editor.viewport_origin.x;
1238    editor.cursor.x = editor.cursor.x.saturating_sub(delta);
1239}
1240
1241fn scroll_half_viewport_right(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1242    let start_x = editor.viewport_origin.x;
1243    editor.viewport_origin.x = editor
1244        .viewport_origin
1245        .x
1246        .saturating_add(half_page_step(bounds.width()));
1247    editor.clamp_viewport_origin(canvas);
1248    let delta = editor.viewport_origin.x - start_x;
1249    editor.cursor.x = (editor.cursor.x + delta).min(canvas.width.saturating_sub(1));
1250}
1251
1252fn scroll_half_viewport_up(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1253    let start_y = editor.viewport_origin.y;
1254    editor.viewport_origin.y = editor
1255        .viewport_origin
1256        .y
1257        .saturating_sub(half_page_step(bounds.height()));
1258    editor.clamp_viewport_origin(canvas);
1259    let delta = start_y - editor.viewport_origin.y;
1260    editor.cursor.y = editor.cursor.y.saturating_sub(delta);
1261}
1262
1263fn scroll_half_viewport_down(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1264    let start_y = editor.viewport_origin.y;
1265    editor.viewport_origin.y = editor
1266        .viewport_origin
1267        .y
1268        .saturating_add(half_page_step(bounds.height()));
1269    editor.clamp_viewport_origin(canvas);
1270    let delta = editor.viewport_origin.y - start_y;
1271    editor.cursor.y = (editor.cursor.y + delta).min(canvas.height.saturating_sub(1));
1272}
1273
1274fn glyph_anchor(editor: &EditorSession, canvas: &Canvas) -> Pos {
1275    canvas.glyph_origin(editor.cursor).unwrap_or(editor.cursor)
1276}
1277
1278pub fn paste_text_block(
1279    editor: &EditorSession,
1280    canvas: &mut Canvas,
1281    text: &str,
1282    color: RgbColor,
1283) -> bool {
1284    if text.is_empty() {
1285        return false;
1286    }
1287
1288    let origin = editor.cursor;
1289    let mut changed = false;
1290    let mut x = origin.x;
1291    let mut y = origin.y;
1292
1293    for ch in text.chars() {
1294        match ch {
1295            '\r' => {}
1296            '\n' => {
1297                x = origin.x;
1298                y += 1;
1299                if y >= canvas.height {
1300                    break;
1301                }
1302            }
1303            _ => {
1304                if x < canvas.width && y < canvas.height {
1305                    let before = canvas.cell(Pos { x, y });
1306                    let _ = canvas.put_glyph_colored(Pos { x, y }, ch, color);
1307                    changed |= before != canvas.cell(Pos { x, y });
1308                }
1309                x += Canvas::display_width(ch);
1310            }
1311        }
1312    }
1313
1314    changed
1315}
1316
1317pub fn insert_char(
1318    editor: &mut EditorSession,
1319    canvas: &mut Canvas,
1320    ch: char,
1321    color: RgbColor,
1322) -> bool {
1323    let cursor = editor.cursor;
1324    let width = Canvas::display_width(ch);
1325    let before = canvas.cell(cursor);
1326    let _ = canvas.put_glyph_colored(cursor, ch, color);
1327    for _ in 0..width {
1328        editor.move_right(canvas);
1329    }
1330    before != canvas.cell(cursor)
1331}
1332
1333pub fn backspace(editor: &mut EditorSession, canvas: &mut Canvas) -> bool {
1334    editor.move_left(canvas);
1335    let origin = canvas.glyph_origin(editor.cursor);
1336    let cursor = editor.cursor;
1337    let before = canvas.cell(cursor);
1338    canvas.clear(cursor);
1339    if let Some(origin) = origin {
1340        editor.cursor = origin;
1341    }
1342    before != canvas.cell(cursor)
1343}
1344
1345pub fn delete_at_cursor(editor: &mut EditorSession, canvas: &mut Canvas) -> bool {
1346    if let Some(origin) = canvas.glyph_origin(editor.cursor) {
1347        editor.cursor = origin;
1348    }
1349    let cursor = editor.cursor;
1350    let before = canvas.cell(cursor);
1351    canvas.clear(cursor);
1352    before != canvas.cell(cursor)
1353}
1354
1355pub fn push_left(editor: &EditorSession, canvas: &mut Canvas) {
1356    let anchor = glyph_anchor(editor, canvas);
1357    canvas.push_left(anchor.y, anchor.x);
1358}
1359
1360pub fn push_down(editor: &EditorSession, canvas: &mut Canvas) {
1361    let anchor = glyph_anchor(editor, canvas);
1362    canvas.push_down(anchor.x, anchor.y);
1363}
1364
1365pub fn push_up(editor: &EditorSession, canvas: &mut Canvas) {
1366    let anchor = glyph_anchor(editor, canvas);
1367    canvas.push_up(anchor.x, anchor.y);
1368}
1369
1370pub fn push_right(editor: &EditorSession, canvas: &mut Canvas) {
1371    let anchor = glyph_anchor(editor, canvas);
1372    canvas.push_right(anchor.y, anchor.x);
1373}
1374
1375pub fn pull_from_left(editor: &EditorSession, canvas: &mut Canvas) {
1376    let anchor = glyph_anchor(editor, canvas);
1377    canvas.pull_from_left(anchor.y, anchor.x);
1378}
1379
1380pub fn pull_from_down(editor: &EditorSession, canvas: &mut Canvas) {
1381    let anchor = glyph_anchor(editor, canvas);
1382    canvas.pull_from_down(anchor.x, anchor.y);
1383}
1384
1385pub fn pull_from_up(editor: &EditorSession, canvas: &mut Canvas) {
1386    let anchor = glyph_anchor(editor, canvas);
1387    canvas.pull_from_up(anchor.x, anchor.y);
1388}
1389
1390pub fn pull_from_right(editor: &EditorSession, canvas: &mut Canvas) {
1391    let anchor = glyph_anchor(editor, canvas);
1392    canvas.pull_from_right(anchor.y, anchor.x);
1393}
1394
1395pub fn transpose_selection_corner(editor: &mut EditorSession) -> bool {
1396    if !editor.mode.is_selecting() {
1397        return false;
1398    }
1399
1400    let Some(anchor) = editor.selection_anchor else {
1401        return false;
1402    };
1403
1404    editor.selection_anchor = Some(editor.cursor);
1405    editor.cursor = anchor;
1406    true
1407}
1408
1409pub fn handle_editor_key_press(
1410    editor: &mut EditorSession,
1411    canvas: &mut Canvas,
1412    key: AppKey,
1413    color: RgbColor,
1414) -> EditorKeyDispatch {
1415    let ctx = keymap::EditorContext {
1416        mode: editor.mode,
1417        has_selection_anchor: editor.selection_anchor.is_some(),
1418        is_floating: editor.floating.is_some(),
1419    };
1420    match KeyMap::default_standalone().resolve(key, ctx) {
1421        Some(action) => handle_editor_action(editor, canvas, action, color),
1422        None => EditorKeyDispatch::default(),
1423    }
1424}
1425
1426pub fn handle_editor_action(
1427    editor: &mut EditorSession,
1428    canvas: &mut Canvas,
1429    action: EditorAction,
1430    color: RgbColor,
1431) -> EditorKeyDispatch {
1432    let mut effects = Vec::new();
1433    match action {
1434        EditorAction::Move {
1435            dir,
1436            extend_selection,
1437        } => {
1438            if extend_selection {
1439                editor.begin_selection();
1440            } else if editor.mode.is_selecting() {
1441                editor.clear_selection();
1442            }
1443            move_for_dir(editor, canvas, dir);
1444        }
1445        EditorAction::MoveDownLine => editor.move_down(canvas),
1446        EditorAction::StrokeFloating { dir } => stroke_floating_move(editor, canvas, dir, color),
1447        EditorAction::Pan { dx, dy } => editor.pan_by(canvas, dx, dy),
1448        EditorAction::ClearSelection => editor.clear_selection(),
1449        EditorAction::TransposeSelectionCorner => {
1450            return EditorKeyDispatch {
1451                handled: transpose_selection_corner(editor),
1452                effects: Vec::new(),
1453            };
1454        }
1455        EditorAction::PushLeft => push_left(editor, canvas),
1456        EditorAction::PushRight => push_right(editor, canvas),
1457        EditorAction::PushUp => push_up(editor, canvas),
1458        EditorAction::PushDown => push_down(editor, canvas),
1459        EditorAction::PullFromLeft => pull_from_left(editor, canvas),
1460        EditorAction::PullFromRight => pull_from_right(editor, canvas),
1461        EditorAction::PullFromUp => pull_from_up(editor, canvas),
1462        EditorAction::PullFromDown => pull_from_down(editor, canvas),
1463        EditorAction::CopySelection => {
1464            let _ = copy_selection_or_cell(editor, canvas);
1465        }
1466        EditorAction::CutSelection => {
1467            let _ = cut_selection_or_cell(editor, canvas, color);
1468        }
1469        EditorAction::PastePrimarySwatch => {
1470            let _ = paste_primary_swatch(editor, canvas, color);
1471        }
1472        EditorAction::ExportSystemClipboard => {
1473            effects.push(HostEffect::CopyToClipboard(export_system_clipboard_text(
1474                editor, canvas,
1475            )));
1476        }
1477        EditorAction::ActivateSwatch(idx) => {
1478            editor.activate_swatch(idx);
1479        }
1480        EditorAction::SmartFill => smart_fill(editor, canvas, color),
1481        EditorAction::DrawBorder => {
1482            let _ = draw_selection_border(editor, canvas, color);
1483        }
1484        EditorAction::FillSelectionOrCell(ch) => {
1485            fill_selection_or_cell(editor, canvas, ch, color);
1486        }
1487        EditorAction::InsertChar(ch) => {
1488            let _ = insert_char(editor, canvas, ch, color);
1489        }
1490        EditorAction::Backspace => {
1491            let _ = backspace(editor, canvas);
1492        }
1493        EditorAction::Delete => {
1494            let _ = delete_at_cursor(editor, canvas);
1495        }
1496        EditorAction::ToggleFloatingTransparency => editor.toggle_float_transparency(),
1497    }
1498    EditorKeyDispatch {
1499        handled: true,
1500        effects,
1501    }
1502}
1503
1504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1505pub enum PointerStrokeHint {
1506    Begin,
1507    End,
1508}
1509
1510/// Whether [`handle_editor_pointer`] consumed the event or left it for the
1511/// host to bubble to outer UI layers.
1512///
1513/// Hosts that embed the editor as a widget alongside other clickable UI
1514/// (swatches, menus, other panes) should treat `Passthrough` as a signal
1515/// that the pointer event is still available for outer routing.
1516#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1517pub enum PointerOutcome {
1518    /// The editor acted on the event. Suppress outer UI routing.
1519    Consumed,
1520    /// The editor did not act on the event (e.g., click outside the
1521    /// canvas viewport, mid-drag sample without an active drag origin).
1522    /// The host may bubble this event to outer UI.
1523    #[default]
1524    Passthrough,
1525}
1526
1527impl PointerOutcome {
1528    pub fn is_consumed(self) -> bool {
1529        matches!(self, PointerOutcome::Consumed)
1530    }
1531
1532    pub fn is_passthrough(self) -> bool {
1533        matches!(self, PointerOutcome::Passthrough)
1534    }
1535}
1536
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1538pub struct EditorPointerDispatch {
1539    pub outcome: PointerOutcome,
1540    pub stroke_hint: Option<PointerStrokeHint>,
1541}
1542
1543impl EditorPointerDispatch {
1544    fn consumed() -> Self {
1545        Self {
1546            outcome: PointerOutcome::Consumed,
1547            stroke_hint: None,
1548        }
1549    }
1550
1551    fn consumed_with(stroke_hint: PointerStrokeHint) -> Self {
1552        Self {
1553            outcome: PointerOutcome::Consumed,
1554            stroke_hint: Some(stroke_hint),
1555        }
1556    }
1557}
1558
1559/// Apply a pointer event to the editor and return the dispatch outcome
1560/// plus any paint-stroke grouping hint the host should honor for undo
1561/// bookkeeping.
1562///
1563/// Hosts should run their own UI hit-testing first (swatch/help/picker
1564/// regions) — this handler only drives canvas-level pointer behavior
1565/// (floating paint drag, selection drag, viewport pan).
1566///
1567/// The returned [`PointerOutcome`] distinguishes events the editor
1568/// consumed (suppress outer routing) from events that should bubble.
1569///
1570/// **Hover policy.** Passive [`AppPointerKind::Moved`] events only move
1571/// the cursor when a floating selection is armed (so brush/stamp
1572/// previews follow the pointer); outside of that, passive motion is a
1573/// no-op and passes through. Scroll wheel events pan the viewport when
1574/// they arrive over the canvas viewport. This is the conditional policy
1575/// layered hosts typically want — there is no separate knob to toggle it.
1576pub fn handle_editor_pointer(
1577    editor: &mut EditorSession,
1578    canvas: &mut Canvas,
1579    mouse: AppPointerEvent,
1580    color: RgbColor,
1581) -> EditorPointerDispatch {
1582    let canvas_pos = editor.canvas_pos_for_pointer(mouse.column, mouse.row, canvas);
1583
1584    if let Some((dx, dy)) = scroll_pan_delta(mouse.kind) {
1585        if editor.viewport_contains(mouse.column, mouse.row) {
1586            editor.pan_by(canvas, dx, dy);
1587            return EditorPointerDispatch::consumed();
1588        }
1589        return EditorPointerDispatch::default();
1590    }
1591
1592    if editor.floating.is_some() {
1593        match mouse.kind {
1594            AppPointerKind::Moved => {
1595                if let Some(pos) = canvas_pos {
1596                    editor.cursor = pos;
1597                    return EditorPointerDispatch::consumed();
1598                }
1599                return EditorPointerDispatch::default();
1600            }
1601            AppPointerKind::Down(AppPointerButton::Left) => {
1602                if let Some(pos) = canvas_pos {
1603                    editor.cursor = pos;
1604                    begin_paint_stroke(editor);
1605                    paint_floating_drag(editor, canvas, pos, color);
1606                    return EditorPointerDispatch::consumed_with(PointerStrokeHint::Begin);
1607                }
1608                return EditorPointerDispatch::default();
1609            }
1610            AppPointerKind::Drag(AppPointerButton::Left) => {
1611                if let Some(pos) = canvas_pos {
1612                    paint_floating_drag(editor, canvas, pos, color);
1613                    return EditorPointerDispatch::consumed();
1614                }
1615                // Mid-stroke sample outside the canvas: the stroke is still
1616                // active, so keep the event from bubbling.
1617                if editor.paint_stroke_anchor.is_some() {
1618                    return EditorPointerDispatch::consumed();
1619                }
1620                return EditorPointerDispatch::default();
1621            }
1622            AppPointerKind::Up(AppPointerButton::Left) => {
1623                let had_stroke = editor.paint_stroke_anchor.is_some();
1624                end_paint_stroke(editor);
1625                if had_stroke {
1626                    return EditorPointerDispatch::consumed_with(PointerStrokeHint::End);
1627                }
1628                return EditorPointerDispatch::default();
1629            }
1630            AppPointerKind::Down(AppPointerButton::Right) => {
1631                // `dismiss_floating` calls `end_paint_stroke` internally.
1632                dismiss_floating(editor);
1633                return EditorPointerDispatch::consumed_with(PointerStrokeHint::End);
1634            }
1635            _ => {
1636                return EditorPointerDispatch::default();
1637            }
1638        }
1639    }
1640
1641    match mouse.kind {
1642        AppPointerKind::Down(AppPointerButton::Right) => {
1643            if editor.viewport_contains(mouse.column, mouse.row) {
1644                editor.begin_pan(mouse.column, mouse.row);
1645                return EditorPointerDispatch::consumed();
1646            }
1647            EditorPointerDispatch::default()
1648        }
1649        AppPointerKind::Down(AppPointerButton::Left) => {
1650            let Some(pos) = canvas_pos else {
1651                return EditorPointerDispatch::default();
1652            };
1653            let extend_selection = mouse.modifiers.alt && editor.selection_anchor.is_some();
1654            let ellipse_drag = mouse.modifiers.ctrl && !extend_selection;
1655
1656            if extend_selection {
1657                if let Some(anchor) = editor.selection_anchor {
1658                    editor.mode = Mode::Select;
1659                    editor.cursor = pos;
1660                    editor.drag_origin = Some(anchor);
1661                }
1662            } else {
1663                if editor.mode.is_selecting() {
1664                    editor.clear_selection();
1665                }
1666                editor.cursor = pos;
1667                editor.selection_shape = if ellipse_drag {
1668                    SelectionShape::Ellipse
1669                } else {
1670                    SelectionShape::Rect
1671                };
1672                editor.drag_origin = Some(pos);
1673            }
1674            EditorPointerDispatch::consumed()
1675        }
1676        AppPointerKind::Drag(AppPointerButton::Left) => {
1677            if let (Some(origin), Some(pos)) = (editor.drag_origin, canvas_pos) {
1678                if pos != origin || editor.mode.is_selecting() {
1679                    editor.selection_anchor = Some(origin);
1680                    editor.mode = Mode::Select;
1681                    editor.cursor = pos;
1682                }
1683                return EditorPointerDispatch::consumed();
1684            }
1685            EditorPointerDispatch::default()
1686        }
1687        AppPointerKind::Drag(AppPointerButton::Right) => {
1688            if editor.pan_drag.is_some() {
1689                editor.drag_pan(canvas, mouse.column, mouse.row);
1690                return EditorPointerDispatch::consumed();
1691            }
1692            EditorPointerDispatch::default()
1693        }
1694        AppPointerKind::Up(AppPointerButton::Left) => {
1695            if editor.drag_origin.take().is_some() {
1696                return EditorPointerDispatch::consumed();
1697            }
1698            EditorPointerDispatch::default()
1699        }
1700        AppPointerKind::Up(AppPointerButton::Right) => {
1701            if editor.pan_drag.is_some() {
1702                editor.end_pan();
1703                return EditorPointerDispatch::consumed();
1704            }
1705            EditorPointerDispatch::default()
1706        }
1707        _ => EditorPointerDispatch::default(),
1708    }
1709}
1710
1711fn scroll_pan_delta(kind: AppPointerKind) -> Option<(isize, isize)> {
1712    match kind {
1713        AppPointerKind::ScrollUp => Some((0, -1)),
1714        AppPointerKind::ScrollDown => Some((0, 1)),
1715        AppPointerKind::ScrollLeft => Some((-1, 0)),
1716        AppPointerKind::ScrollRight => Some((1, 0)),
1717        _ => None,
1718    }
1719}
1720
1721pub fn begin_paint_stroke(editor: &mut EditorSession) {
1722    editor.paint_stroke_anchor = Some(editor.cursor);
1723    editor.paint_stroke_last = None;
1724}
1725
1726pub fn end_paint_stroke(editor: &mut EditorSession) {
1727    editor.paint_stroke_anchor = None;
1728    editor.paint_stroke_last = None;
1729}
1730
1731pub fn dismiss_floating(editor: &mut EditorSession) {
1732    end_paint_stroke(editor);
1733    editor.floating = None;
1734}
1735
1736pub fn stamp_floating(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1737    let Some(floating) = editor.floating.as_ref() else {
1738        return false;
1739    };
1740
1741    stamp_clipboard(
1742        canvas,
1743        &floating.clipboard,
1744        editor.cursor,
1745        color,
1746        floating.transparent,
1747    );
1748    true
1749}
1750
1751fn snap_horizontal_brush_x(anchor_x: usize, raw_x: usize, brush_width: usize) -> usize {
1752    if brush_width <= 1 {
1753        return raw_x;
1754    }
1755
1756    if raw_x >= anchor_x {
1757        anchor_x + ((raw_x - anchor_x) / brush_width) * brush_width
1758    } else {
1759        anchor_x - ((anchor_x - raw_x) / brush_width) * brush_width
1760    }
1761}
1762
1763fn line_points(start: Pos, end: Pos) -> Vec<Pos> {
1764    let mut points = Vec::new();
1765    let mut x = start.x as isize;
1766    let mut y = start.y as isize;
1767    let target_x = end.x as isize;
1768    let target_y = end.y as isize;
1769    let dx = (target_x - x).abs();
1770    let sx = if x < target_x { 1 } else { -1 };
1771    let dy = -(target_y - y).abs();
1772    let sy = if y < target_y { 1 } else { -1 };
1773    let mut err = dx + dy;
1774
1775    loop {
1776        points.push(Pos {
1777            x: x as usize,
1778            y: y as usize,
1779        });
1780
1781        if x == target_x && y == target_y {
1782            break;
1783        }
1784
1785        let twice_err = 2 * err;
1786        if twice_err >= dy {
1787            err += dy;
1788            x += sx;
1789        }
1790        if twice_err <= dx {
1791            err += dx;
1792            y += sy;
1793        }
1794    }
1795
1796    points
1797}
1798
1799fn paint_floating_at_cursor(
1800    editor: &mut EditorSession,
1801    canvas: &mut Canvas,
1802    color: RgbColor,
1803) -> bool {
1804    if !stamp_floating(editor, canvas, color) {
1805        return false;
1806    }
1807    editor.paint_stroke_last = Some(editor.cursor);
1808    true
1809}
1810
1811fn paint_floating_diagonal_segment(
1812    editor: &mut EditorSession,
1813    canvas: &mut Canvas,
1814    start: Pos,
1815    end: Pos,
1816    brush_width: usize,
1817    color: RgbColor,
1818) -> bool {
1819    let mut changed = false;
1820    let mut last_stamped = start;
1821    for point in line_points(start, end).into_iter().skip(1) {
1822        let should_stamp =
1823            point.y != last_stamped.y || point.x.abs_diff(last_stamped.x) >= brush_width;
1824        if !should_stamp {
1825            continue;
1826        }
1827
1828        editor.cursor = point;
1829        changed |= paint_floating_at_cursor(editor, canvas, color);
1830        last_stamped = point;
1831    }
1832
1833    let should_stamp_end = end.y != last_stamped.y || end.x.abs_diff(last_stamped.x) >= brush_width;
1834    if should_stamp_end {
1835        editor.cursor = end;
1836        changed |= paint_floating_at_cursor(editor, canvas, color);
1837    }
1838    changed
1839}
1840
1841pub fn paint_floating_drag(
1842    editor: &mut EditorSession,
1843    canvas: &mut Canvas,
1844    raw_pos: Pos,
1845    color: RgbColor,
1846) -> bool {
1847    let Some(last) = editor.paint_stroke_last else {
1848        editor.cursor = raw_pos;
1849        return paint_floating_at_cursor(editor, canvas, color);
1850    };
1851
1852    let anchor = editor.paint_stroke_anchor.unwrap_or(last);
1853    let brush_width = editor.floating_brush_width();
1854    let is_pure_horizontal =
1855        brush_width > 1 && raw_pos.y == last.y && raw_pos.y == anchor.y && last.y == anchor.y;
1856
1857    if is_pure_horizontal {
1858        let target = Pos {
1859            x: snap_horizontal_brush_x(anchor.x, raw_pos.x, brush_width),
1860            y: raw_pos.y,
1861        };
1862        if target == last {
1863            return false;
1864        }
1865        editor.cursor = target;
1866        return paint_floating_at_cursor(editor, canvas, color);
1867    }
1868
1869    if brush_width > 1 && raw_pos.y == last.y {
1870        if raw_pos.x.abs_diff(last.x) < brush_width {
1871            return false;
1872        }
1873
1874        editor.cursor = raw_pos;
1875        return paint_floating_at_cursor(editor, canvas, color);
1876    }
1877
1878    if brush_width > 1 && raw_pos.y != last.y {
1879        return paint_floating_diagonal_segment(editor, canvas, last, raw_pos, brush_width, color);
1880    }
1881
1882    if raw_pos == last {
1883        return false;
1884    }
1885
1886    editor.cursor = raw_pos;
1887    paint_floating_at_cursor(editor, canvas, color)
1888}
1889
1890#[cfg(test)]
1891mod tests {
1892    use super::{
1893        backspace, begin_paint_stroke, capture_bounds, capture_selection, copy_selection_or_cell,
1894        cut_selection_or_cell, delete_at_cursor, diff_canvas_op, dismiss_floating, draw_border,
1895        draw_selection_border, export_selection_as_text, export_system_clipboard_text,
1896        fill_selection, fill_selection_or_cell, handle_editor_action, handle_editor_key_press,
1897        handle_editor_pointer, insert_char, paint_floating_drag, paste_primary_swatch,
1898        paste_text_block, smart_fill, smart_fill_glyph, stamp_clipboard,
1899        transpose_selection_corner, AppKey, AppKeyCode, AppModifiers, AppPointerButton,
1900        AppPointerEvent, AppPointerKind, Bounds, Clipboard, EditorAction, EditorKeyDispatch,
1901        EditorSession, FloatingSelection, HostEffect, Mode, MoveDir, PointerOutcome,
1902        PointerStrokeHint, Selection, SelectionShape, SwatchActivation, Viewport,
1903    };
1904    use dartboard_core::{Canvas, CanvasOp, CellValue, Pos, RgbColor};
1905
1906    #[test]
1907    fn ellipse_contains_degenerate_line() {
1908        let selection = Selection {
1909            anchor: Pos { x: 2, y: 4 },
1910            cursor: Pos { x: 2, y: 8 },
1911            shape: SelectionShape::Ellipse,
1912        };
1913
1914        assert!(selection.contains(Pos { x: 2, y: 6 }));
1915        assert!(!selection.contains(Pos { x: 3, y: 6 }));
1916    }
1917
1918    #[test]
1919    fn bounds_normalize_wide_glyph_edges() {
1920        let mut canvas = Canvas::with_size(8, 4);
1921        let _ = canvas.put_glyph(Pos { x: 2, y: 1 }, '🌱');
1922
1923        let bounds = Bounds {
1924            min_x: 3,
1925            max_x: 3,
1926            min_y: 1,
1927            max_y: 1,
1928        }
1929        .normalized_for_canvas(&canvas);
1930
1931        assert_eq!(bounds.min_x, 2);
1932        assert_eq!(bounds.max_x, 3);
1933    }
1934
1935    #[test]
1936    fn diff_canvas_op_uses_default_fg_for_uncolored_cells() {
1937        let before = Canvas::with_size(4, 2);
1938        let mut after = before.clone();
1939        after.set(Pos { x: 1, y: 0 }, 'X');
1940
1941        let op = diff_canvas_op(&before, &after, RgbColor::new(9, 8, 7)).unwrap();
1942        match op {
1943            CanvasOp::PaintCell { fg, .. } => assert_eq!(fg, RgbColor::new(9, 8, 7)),
1944            other => panic!("expected PaintCell, got {other:?}"),
1945        }
1946    }
1947
1948    #[test]
1949    fn editor_session_selection_tracks_cursor() {
1950        let mut session = EditorSession {
1951            viewport: Viewport {
1952                width: 20,
1953                height: 10,
1954                ..Default::default()
1955            },
1956            ..Default::default()
1957        };
1958        session.cursor = Pos { x: 3, y: 4 };
1959        session.begin_selection();
1960        session.cursor = Pos { x: 8, y: 6 };
1961
1962        let selection = session.selection().unwrap();
1963        assert_eq!(selection.anchor, Pos { x: 3, y: 4 });
1964        assert_eq!(selection.cursor, Pos { x: 8, y: 6 });
1965        assert_eq!(selection.shape, SelectionShape::Rect);
1966    }
1967
1968    #[test]
1969    fn set_viewport_clamps_origin_and_cursor() {
1970        let canvas = Canvas::with_size(40, 20);
1971        let mut session = EditorSession {
1972            cursor: Pos { x: 39, y: 19 },
1973            viewport_origin: Pos { x: 25, y: 18 },
1974            ..Default::default()
1975        };
1976
1977        session.set_viewport(
1978            Viewport {
1979                x: 2,
1980                y: 3,
1981                width: 10,
1982                height: 5,
1983            },
1984            &canvas,
1985        );
1986
1987        assert_eq!(session.viewport_origin, Pos { x: 25, y: 15 });
1988        assert_eq!(session.cursor, Pos { x: 34, y: 19 });
1989    }
1990
1991    #[test]
1992    fn move_right_scrolls_viewport_to_keep_cursor_visible() {
1993        let canvas = Canvas::with_size(40, 10);
1994        let mut session = EditorSession {
1995            cursor: Pos { x: 3, y: 2 },
1996            viewport: Viewport {
1997                width: 4,
1998                height: 3,
1999                ..Default::default()
2000            },
2001            ..Default::default()
2002        };
2003
2004        session.move_right(&canvas);
2005
2006        assert_eq!(session.cursor, Pos { x: 4, y: 2 });
2007        assert_eq!(session.viewport_origin, Pos { x: 1, y: 0 });
2008    }
2009
2010    #[test]
2011    fn move_page_down_scrolls_half_viewport_when_already_at_bottom_edge() {
2012        let canvas = Canvas::with_size(40, 30);
2013        let mut session = EditorSession {
2014            cursor: Pos { x: 3, y: 1 },
2015            viewport: Viewport {
2016                width: 8,
2017                height: 5,
2018                ..Default::default()
2019            },
2020            ..Default::default()
2021        };
2022
2023        session.move_dir(&canvas, MoveDir::PageDown);
2024        assert_eq!(session.cursor, Pos { x: 3, y: 4 });
2025        assert_eq!(session.viewport_origin, Pos { x: 0, y: 0 });
2026
2027        session.move_dir(&canvas, MoveDir::PageDown);
2028        assert_eq!(session.cursor, Pos { x: 3, y: 6 });
2029        assert_eq!(session.viewport_origin, Pos { x: 0, y: 2 });
2030    }
2031
2032    #[test]
2033    fn move_home_scrolls_half_viewport_when_already_at_left_edge() {
2034        let canvas = Canvas::with_size(40, 20);
2035        let mut session = EditorSession {
2036            cursor: Pos { x: 10, y: 2 },
2037            viewport: Viewport {
2038                width: 6,
2039                height: 4,
2040                ..Default::default()
2041            },
2042            viewport_origin: Pos { x: 8, y: 0 },
2043            ..Default::default()
2044        };
2045
2046        session.move_dir(&canvas, MoveDir::LineStart);
2047        assert_eq!(session.cursor, Pos { x: 8, y: 2 });
2048        assert_eq!(session.viewport_origin, Pos { x: 8, y: 0 });
2049
2050        session.move_dir(&canvas, MoveDir::LineStart);
2051        assert_eq!(session.cursor, Pos { x: 5, y: 2 });
2052        assert_eq!(session.viewport_origin, Pos { x: 5, y: 0 });
2053    }
2054
2055    #[test]
2056    fn drag_pan_clamps_to_canvas_bounds() {
2057        let canvas = Canvas::with_size(20, 10);
2058        let mut session = EditorSession {
2059            cursor: Pos { x: 6, y: 5 },
2060            viewport: Viewport {
2061                width: 6,
2062                height: 4,
2063                ..Default::default()
2064            },
2065            viewport_origin: Pos { x: 8, y: 4 },
2066            ..Default::default()
2067        };
2068
2069        session.begin_pan(12, 8);
2070        session.drag_pan(&canvas, 0, 0);
2071
2072        assert_eq!(session.viewport_origin, Pos { x: 14, y: 6 });
2073        assert_eq!(session.cursor, Pos { x: 14, y: 6 });
2074        session.end_pan();
2075        assert!(session.pan_drag.is_none());
2076    }
2077
2078    #[test]
2079    fn system_clipboard_bounds_falls_back_to_canvas() {
2080        let canvas = Canvas::with_size(8, 4);
2081        let session = EditorSession::default();
2082
2083        assert_eq!(
2084            session.system_clipboard_bounds(&canvas),
2085            Bounds {
2086                min_x: 0,
2087                max_x: 7,
2088                min_y: 0,
2089                max_y: 3,
2090            }
2091        );
2092    }
2093
2094    #[test]
2095    fn push_swatch_rotates_unpinned_history_only() {
2096        let clipboard_a = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]);
2097        let clipboard_b = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('B'))]);
2098        let clipboard_c = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('C'))]);
2099        let mut session = EditorSession::default();
2100
2101        session.push_swatch(clipboard_a.clone());
2102        session.push_swatch(clipboard_b.clone());
2103        session.toggle_pin(1);
2104        session.push_swatch(clipboard_c.clone());
2105
2106        assert_eq!(session.populated_swatch_count(), 3);
2107        assert_eq!(
2108            session.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2109            Some(CellValue::Narrow('C'))
2110        );
2111        assert!(session.swatches[1].as_ref().unwrap().pinned);
2112        assert_eq!(
2113            session.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2114            Some(CellValue::Narrow('A'))
2115        );
2116        assert_eq!(
2117            session.swatches[2].as_ref().unwrap().clipboard.get(0, 0),
2118            Some(CellValue::Narrow('B'))
2119        );
2120    }
2121
2122    #[test]
2123    fn activating_same_swatch_toggles_transparency() {
2124        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2125        let mut session = EditorSession::default();
2126        session.push_swatch(clipboard);
2127
2128        assert_eq!(
2129            session.activate_swatch(0),
2130            SwatchActivation::ActivatedFloating
2131        );
2132        assert_eq!(
2133            session.activate_swatch(0),
2134            SwatchActivation::ToggledTransparency
2135        );
2136        assert!(session.floating.as_ref().unwrap().transparent);
2137        assert_eq!(session.floating_brush_width(), 1);
2138    }
2139
2140    #[test]
2141    fn clearing_swatch_empties_slot() {
2142        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2143        let mut session = EditorSession::default();
2144        session.push_swatch(clipboard);
2145
2146        session.clear_swatch(0);
2147
2148        assert!(session.swatches[0].is_none());
2149    }
2150
2151    #[test]
2152    fn clearing_active_swatch_dismisses_floating() {
2153        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2154        let mut session = EditorSession::default();
2155        session.push_swatch(clipboard);
2156        assert_eq!(
2157            session.activate_swatch(0),
2158            SwatchActivation::ActivatedFloating
2159        );
2160
2161        session.clear_swatch(0);
2162
2163        assert!(session.swatches[0].is_none());
2164        assert!(session.floating.is_none());
2165    }
2166
2167    #[test]
2168    fn capture_and_export_selection_respects_mask_shape() {
2169        let mut canvas = Canvas::with_size(5, 3);
2170        canvas.set(Pos { x: 0, y: 0 }, 'A');
2171        canvas.set(Pos { x: 1, y: 0 }, 'B');
2172        canvas.set(Pos { x: 2, y: 0 }, 'C');
2173        canvas.set(Pos { x: 0, y: 1 }, 'D');
2174        canvas.set(Pos { x: 1, y: 1 }, 'E');
2175        canvas.set(Pos { x: 2, y: 1 }, 'F');
2176
2177        let selection = Selection {
2178            anchor: Pos { x: 0, y: 0 },
2179            cursor: Pos { x: 2, y: 1 },
2180            shape: SelectionShape::Ellipse,
2181        };
2182
2183        let clipboard = capture_selection(&canvas, selection);
2184        assert_eq!(clipboard.width, 3);
2185        assert_eq!(clipboard.height, 2);
2186        assert_eq!(clipboard.get(0, 0), Some(CellValue::Narrow('A')));
2187        assert_eq!(clipboard.get(1, 0), Some(CellValue::Narrow('B')));
2188        assert_eq!(clipboard.get(2, 0), Some(CellValue::Narrow('C')));
2189        assert_eq!(clipboard.get(0, 1), Some(CellValue::Narrow('D')));
2190        assert_eq!(clipboard.get(1, 1), Some(CellValue::Narrow('E')));
2191        assert_eq!(clipboard.get(2, 1), Some(CellValue::Narrow('F')));
2192        assert_eq!(export_selection_as_text(&canvas, selection), "ABC\nDEF");
2193    }
2194
2195    #[test]
2196    fn fill_selection_masks_ellipse_edges() {
2197        let mut canvas = Canvas::with_size(5, 5);
2198        let selection = Selection {
2199            anchor: Pos { x: 0, y: 0 },
2200            cursor: Pos { x: 4, y: 4 },
2201            shape: SelectionShape::Ellipse,
2202        };
2203        let bounds = selection.bounds();
2204
2205        fill_selection(&mut canvas, selection, bounds, 'x', RgbColor::new(1, 2, 3));
2206
2207        assert_eq!(canvas.cell(Pos { x: 0, y: 0 }), None);
2208        assert_eq!(
2209            canvas.cell(Pos { x: 2, y: 0 }),
2210            Some(CellValue::Narrow('x'))
2211        );
2212        assert_eq!(
2213            canvas.cell(Pos { x: 0, y: 2 }),
2214            Some(CellValue::Narrow('x'))
2215        );
2216        assert_eq!(
2217            canvas.cell(Pos { x: 2, y: 2 }),
2218            Some(CellValue::Narrow('x'))
2219        );
2220        assert_eq!(
2221            canvas.cell(Pos { x: 4, y: 2 }),
2222            Some(CellValue::Narrow('x'))
2223        );
2224        assert_eq!(
2225            canvas.cell(Pos { x: 2, y: 4 }),
2226            Some(CellValue::Narrow('x'))
2227        );
2228        assert_eq!(canvas.cell(Pos { x: 4, y: 4 }), None);
2229    }
2230
2231    #[test]
2232    fn draw_border_writes_ascii_frame_for_rect_selection() {
2233        let mut canvas = Canvas::with_size(6, 4);
2234        let selection = Selection {
2235            anchor: Pos { x: 1, y: 1 },
2236            cursor: Pos { x: 3, y: 2 },
2237            shape: SelectionShape::Rect,
2238        };
2239
2240        draw_border(&mut canvas, selection, RgbColor::new(7, 8, 9));
2241
2242        let captured = capture_bounds(
2243            &canvas,
2244            Bounds {
2245                min_x: 1,
2246                max_x: 3,
2247                min_y: 1,
2248                max_y: 2,
2249            },
2250        );
2251        assert_eq!(captured.get(0, 0), Some(CellValue::Narrow('.')));
2252        assert_eq!(captured.get(1, 0), Some(CellValue::Narrow('-')));
2253        assert_eq!(captured.get(2, 0), Some(CellValue::Narrow('.')));
2254        assert_eq!(captured.get(0, 1), Some(CellValue::Narrow('`')));
2255        assert_eq!(captured.get(1, 1), Some(CellValue::Narrow('-')));
2256        assert_eq!(captured.get(2, 1), Some(CellValue::Narrow('\'')));
2257    }
2258
2259    #[test]
2260    fn stamp_clipboard_honors_transparency() {
2261        let clipboard = Clipboard::new(
2262            2,
2263            2,
2264            vec![
2265                Some(CellValue::Narrow('A')),
2266                None,
2267                None,
2268                Some(CellValue::Narrow('B')),
2269            ],
2270        );
2271        let mut canvas = Canvas::with_size(4, 4);
2272        canvas.set(Pos { x: 2, y: 1 }, 'z');
2273        canvas.set(Pos { x: 1, y: 2 }, 'y');
2274
2275        stamp_clipboard(
2276            &mut canvas,
2277            &clipboard,
2278            Pos { x: 1, y: 1 },
2279            RgbColor::new(5, 6, 7),
2280            true,
2281        );
2282        assert_eq!(
2283            canvas.cell(Pos { x: 1, y: 1 }),
2284            Some(CellValue::Narrow('A'))
2285        );
2286        assert_eq!(
2287            canvas.cell(Pos { x: 2, y: 1 }),
2288            Some(CellValue::Narrow('z'))
2289        );
2290        assert_eq!(
2291            canvas.cell(Pos { x: 1, y: 2 }),
2292            Some(CellValue::Narrow('y'))
2293        );
2294        assert_eq!(
2295            canvas.cell(Pos { x: 2, y: 2 }),
2296            Some(CellValue::Narrow('B'))
2297        );
2298
2299        stamp_clipboard(
2300            &mut canvas,
2301            &clipboard,
2302            Pos { x: 1, y: 1 },
2303            RgbColor::new(5, 6, 7),
2304            false,
2305        );
2306        assert_eq!(canvas.cell(Pos { x: 2, y: 1 }), None);
2307        assert_eq!(canvas.cell(Pos { x: 1, y: 2 }), None);
2308    }
2309
2310    #[test]
2311    fn smart_fill_glyph_matches_bounds_shape() {
2312        assert_eq!(
2313            smart_fill_glyph(Bounds {
2314                min_x: 0,
2315                max_x: 0,
2316                min_y: 0,
2317                max_y: 2,
2318            }),
2319            '|'
2320        );
2321        assert_eq!(
2322            smart_fill_glyph(Bounds {
2323                min_x: 0,
2324                max_x: 2,
2325                min_y: 0,
2326                max_y: 0,
2327            }),
2328            '-'
2329        );
2330        assert_eq!(
2331            smart_fill_glyph(Bounds {
2332                min_x: 0,
2333                max_x: 1,
2334                min_y: 0,
2335                max_y: 1,
2336            }),
2337            '*'
2338        );
2339    }
2340
2341    #[test]
2342    fn copy_and_cut_commands_update_swatches_and_canvas() {
2343        let mut canvas = Canvas::with_size(4, 2);
2344        canvas.set(Pos { x: 1, y: 0 }, 'Q');
2345        let mut editor = EditorSession {
2346            cursor: Pos { x: 1, y: 0 },
2347            ..Default::default()
2348        };
2349
2350        assert!(copy_selection_or_cell(&mut editor, &canvas));
2351        assert_eq!(
2352            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2353            Some(CellValue::Narrow('Q'))
2354        );
2355
2356        assert!(cut_selection_or_cell(
2357            &mut editor,
2358            &mut canvas,
2359            RgbColor::new(1, 2, 3)
2360        ));
2361        assert_eq!(canvas.cell(Pos { x: 1, y: 0 }), None);
2362        assert_eq!(
2363            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2364            Some(CellValue::Narrow('Q'))
2365        );
2366    }
2367
2368    #[test]
2369    fn paste_and_fill_commands_use_editor_state() {
2370        let mut canvas = Canvas::with_size(6, 4);
2371        let mut editor = EditorSession {
2372            cursor: Pos { x: 2, y: 1 },
2373            ..Default::default()
2374        };
2375        editor.push_swatch(Clipboard::new(1, 1, vec![Some(CellValue::Narrow('P'))]));
2376
2377        assert!(paste_primary_swatch(
2378            &editor,
2379            &mut canvas,
2380            RgbColor::new(4, 5, 6)
2381        ));
2382        assert_eq!(
2383            canvas.cell(Pos { x: 2, y: 1 }),
2384            Some(CellValue::Narrow('P'))
2385        );
2386
2387        fill_selection_or_cell(&editor, &mut canvas, 'x', RgbColor::new(7, 8, 9));
2388        assert_eq!(
2389            canvas.cell(Pos { x: 2, y: 1 }),
2390            Some(CellValue::Narrow('x'))
2391        );
2392    }
2393
2394    #[test]
2395    fn smart_fill_border_and_export_commands_follow_selection() {
2396        let mut canvas = Canvas::with_size(6, 4);
2397        let mut editor = EditorSession {
2398            cursor: Pos { x: 1, y: 1 },
2399            ..Default::default()
2400        };
2401        editor.begin_selection();
2402        editor.cursor = Pos { x: 3, y: 2 };
2403
2404        smart_fill(&editor, &mut canvas, RgbColor::new(1, 2, 3));
2405        assert_eq!(export_system_clipboard_text(&editor, &canvas), "***\n***");
2406
2407        assert!(draw_selection_border(
2408            &editor,
2409            &mut canvas,
2410            RgbColor::new(9, 8, 7)
2411        ));
2412        assert_eq!(
2413            canvas.cell(Pos { x: 1, y: 1 }),
2414            Some(CellValue::Narrow('.'))
2415        );
2416        assert_eq!(
2417            canvas.cell(Pos { x: 3, y: 2 }),
2418            Some(CellValue::Narrow('\''))
2419        );
2420    }
2421
2422    #[test]
2423    fn floating_drag_updates_cursor_and_stroke_state() {
2424        let mut canvas = Canvas::with_size(8, 4);
2425        let mut editor = EditorSession {
2426            cursor: Pos { x: 1, y: 1 },
2427            floating: Some(FloatingSelection {
2428                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('F'))]),
2429                transparent: false,
2430                source_index: Some(0),
2431            }),
2432            ..Default::default()
2433        };
2434
2435        begin_paint_stroke(&mut editor);
2436        assert!(paint_floating_drag(
2437            &mut editor,
2438            &mut canvas,
2439            Pos { x: 1, y: 1 },
2440            RgbColor::new(3, 4, 5)
2441        ));
2442        assert_eq!(editor.paint_stroke_last, Some(Pos { x: 1, y: 1 }));
2443        assert_eq!(
2444            canvas.cell(Pos { x: 1, y: 1 }),
2445            Some(CellValue::Narrow('F'))
2446        );
2447
2448        assert!(paint_floating_drag(
2449            &mut editor,
2450            &mut canvas,
2451            Pos { x: 3, y: 1 },
2452            RgbColor::new(3, 4, 5)
2453        ));
2454        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2455        assert_eq!(editor.paint_stroke_last, Some(Pos { x: 3, y: 1 }));
2456        assert_eq!(
2457            canvas.cell(Pos { x: 3, y: 1 }),
2458            Some(CellValue::Narrow('F'))
2459        );
2460    }
2461
2462    #[test]
2463    fn dismiss_floating_clears_float_and_stroke_tracking() {
2464        let mut editor = EditorSession {
2465            cursor: Pos { x: 2, y: 2 },
2466            floating: Some(FloatingSelection {
2467                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]),
2468                transparent: true,
2469                source_index: None,
2470            }),
2471            paint_stroke_anchor: Some(Pos { x: 1, y: 1 }),
2472            paint_stroke_last: Some(Pos { x: 2, y: 2 }),
2473            ..Default::default()
2474        };
2475
2476        dismiss_floating(&mut editor);
2477
2478        assert!(editor.floating.is_none());
2479        assert!(editor.paint_stroke_anchor.is_none());
2480        assert!(editor.paint_stroke_last.is_none());
2481    }
2482
2483    #[test]
2484    fn insert_and_delete_commands_mutate_canvas_and_cursor() {
2485        let mut canvas = Canvas::with_size(8, 3);
2486        let mut editor = EditorSession::default();
2487
2488        assert!(insert_char(
2489            &mut editor,
2490            &mut canvas,
2491            'A',
2492            RgbColor::new(1, 2, 3)
2493        ));
2494        assert_eq!(
2495            canvas.cell(Pos { x: 0, y: 0 }),
2496            Some(CellValue::Narrow('A'))
2497        );
2498        assert_eq!(editor.cursor, Pos { x: 1, y: 0 });
2499
2500        assert!(backspace(&mut editor, &mut canvas));
2501        assert_eq!(canvas.cell(Pos { x: 0, y: 0 }), None);
2502        assert_eq!(editor.cursor, Pos { x: 0, y: 0 });
2503
2504        let _ = canvas.put_glyph_colored(Pos { x: 2, y: 1 }, 'Z', RgbColor::new(4, 5, 6));
2505        editor.cursor = Pos { x: 2, y: 1 };
2506        assert!(delete_at_cursor(&mut editor, &mut canvas));
2507        assert_eq!(canvas.cell(Pos { x: 2, y: 1 }), None);
2508    }
2509
2510    #[test]
2511    fn paste_text_block_uses_cursor_origin() {
2512        let mut canvas = Canvas::with_size(6, 4);
2513        let editor = EditorSession {
2514            cursor: Pos { x: 1, y: 1 },
2515            ..Default::default()
2516        };
2517
2518        assert!(paste_text_block(
2519            &editor,
2520            &mut canvas,
2521            "AB\nC",
2522            RgbColor::new(7, 8, 9)
2523        ));
2524        assert_eq!(
2525            canvas.cell(Pos { x: 1, y: 1 }),
2526            Some(CellValue::Narrow('A'))
2527        );
2528        assert_eq!(
2529            canvas.cell(Pos { x: 2, y: 1 }),
2530            Some(CellValue::Narrow('B'))
2531        );
2532        assert_eq!(
2533            canvas.cell(Pos { x: 1, y: 2 }),
2534            Some(CellValue::Narrow('C'))
2535        );
2536    }
2537
2538    #[test]
2539    fn transpose_selection_corner_swaps_anchor_and_cursor() {
2540        let mut editor = EditorSession {
2541            cursor: Pos { x: 4, y: 3 },
2542            selection_anchor: Some(Pos { x: 1, y: 2 }),
2543            mode: super::Mode::Select,
2544            ..Default::default()
2545        };
2546
2547        assert!(transpose_selection_corner(&mut editor));
2548        assert_eq!(editor.selection_anchor, Some(Pos { x: 4, y: 3 }));
2549        assert_eq!(editor.cursor, Pos { x: 1, y: 2 });
2550    }
2551
2552    #[test]
2553    fn handle_editor_key_press_returns_clipboard_effect_for_alt_c() {
2554        let mut canvas = Canvas::with_size(4, 2);
2555        canvas.set(Pos { x: 0, y: 0 }, 'A');
2556        let mut editor = EditorSession::default();
2557
2558        let dispatch = handle_editor_key_press(
2559            &mut editor,
2560            &mut canvas,
2561            AppKey {
2562                code: AppKeyCode::Char('c'),
2563                modifiers: AppModifiers {
2564                    alt: true,
2565                    ..Default::default()
2566                },
2567            },
2568            RgbColor::new(1, 2, 3),
2569        );
2570
2571        assert_eq!(
2572            dispatch,
2573            EditorKeyDispatch {
2574                handled: true,
2575                effects: vec![HostEffect::CopyToClipboard("A   \n    ".to_string())],
2576            }
2577        );
2578    }
2579
2580    #[test]
2581    fn handle_editor_key_press_handles_selection_fill_and_ctrl_commands() {
2582        let mut canvas = Canvas::with_size(6, 3);
2583        let mut editor = EditorSession {
2584            cursor: Pos { x: 1, y: 1 },
2585            ..Default::default()
2586        };
2587
2588        let fill_dispatch = handle_editor_key_press(
2589            &mut editor,
2590            &mut canvas,
2591            AppKey {
2592                code: AppKeyCode::Right,
2593                modifiers: AppModifiers {
2594                    shift: true,
2595                    ..Default::default()
2596                },
2597            },
2598            RgbColor::new(1, 2, 3),
2599        );
2600        assert!(fill_dispatch.handled);
2601        assert!(editor.mode.is_selecting());
2602
2603        let fill_dispatch = handle_editor_key_press(
2604            &mut editor,
2605            &mut canvas,
2606            AppKey {
2607                code: AppKeyCode::Char('x'),
2608                modifiers: AppModifiers::default(),
2609            },
2610            RgbColor::new(1, 2, 3),
2611        );
2612        assert!(fill_dispatch.handled);
2613        assert_eq!(
2614            canvas.cell(Pos { x: 1, y: 1 }),
2615            Some(CellValue::Narrow('x'))
2616        );
2617        assert_eq!(
2618            canvas.cell(Pos { x: 2, y: 1 }),
2619            Some(CellValue::Narrow('x'))
2620        );
2621
2622        let copy_dispatch = handle_editor_key_press(
2623            &mut editor,
2624            &mut canvas,
2625            AppKey {
2626                code: AppKeyCode::Char('c'),
2627                modifiers: AppModifiers {
2628                    ctrl: true,
2629                    ..Default::default()
2630                },
2631            },
2632            RgbColor::new(1, 2, 3),
2633        );
2634        assert!(copy_dispatch.handled);
2635        assert_eq!(
2636            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2637            Some(CellValue::Narrow('x'))
2638        );
2639    }
2640
2641    #[test]
2642    fn handle_editor_action_move_extends_selection_when_requested() {
2643        let mut canvas = Canvas::with_size(6, 3);
2644        let mut editor = EditorSession {
2645            cursor: Pos { x: 1, y: 1 },
2646            ..Default::default()
2647        };
2648
2649        let dispatch = handle_editor_action(
2650            &mut editor,
2651            &mut canvas,
2652            EditorAction::Move {
2653                dir: MoveDir::Right,
2654                extend_selection: true,
2655            },
2656            RgbColor::new(0, 0, 0),
2657        );
2658
2659        assert!(dispatch.handled);
2660        assert!(dispatch.effects.is_empty());
2661        assert!(editor.mode.is_selecting());
2662        assert_eq!(editor.selection_anchor, Some(Pos { x: 1, y: 1 }));
2663        assert_eq!(editor.cursor, Pos { x: 2, y: 1 });
2664    }
2665
2666    #[test]
2667    fn handle_editor_action_move_clears_selection_when_not_extending() {
2668        let mut canvas = Canvas::with_size(6, 3);
2669        let mut editor = EditorSession {
2670            cursor: Pos { x: 2, y: 1 },
2671            selection_anchor: Some(Pos { x: 1, y: 1 }),
2672            mode: Mode::Select,
2673            ..Default::default()
2674        };
2675
2676        let dispatch = handle_editor_action(
2677            &mut editor,
2678            &mut canvas,
2679            EditorAction::Move {
2680                dir: MoveDir::Right,
2681                extend_selection: false,
2682            },
2683            RgbColor::new(0, 0, 0),
2684        );
2685
2686        assert!(dispatch.handled);
2687        assert!(editor.selection_anchor.is_none());
2688        assert!(!editor.mode.is_selecting());
2689        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2690    }
2691
2692    #[test]
2693    fn handle_editor_action_export_system_clipboard_emits_effect() {
2694        let mut canvas = Canvas::with_size(4, 2);
2695        canvas.set(Pos { x: 0, y: 0 }, 'A');
2696        let mut editor = EditorSession::default();
2697
2698        let dispatch = handle_editor_action(
2699            &mut editor,
2700            &mut canvas,
2701            EditorAction::ExportSystemClipboard,
2702            RgbColor::new(0, 0, 0),
2703        );
2704
2705        assert_eq!(
2706            dispatch,
2707            EditorKeyDispatch {
2708                handled: true,
2709                effects: vec![HostEffect::CopyToClipboard("A   \n    ".to_string())],
2710            }
2711        );
2712    }
2713
2714    #[test]
2715    fn handle_editor_action_insert_char_writes_cell() {
2716        let mut canvas = Canvas::with_size(4, 2);
2717        let mut editor = EditorSession {
2718            cursor: Pos { x: 1, y: 0 },
2719            ..Default::default()
2720        };
2721
2722        let dispatch = handle_editor_action(
2723            &mut editor,
2724            &mut canvas,
2725            EditorAction::InsertChar('Z'),
2726            RgbColor::new(9, 9, 9),
2727        );
2728
2729        assert!(dispatch.handled);
2730        assert_eq!(
2731            canvas.cell(Pos { x: 1, y: 0 }),
2732            Some(CellValue::Narrow('Z'))
2733        );
2734    }
2735
2736    #[test]
2737    fn handle_editor_action_transpose_reports_unhandled_without_anchor() {
2738        let mut canvas = Canvas::with_size(4, 2);
2739        let mut editor = EditorSession::default();
2740
2741        let dispatch = handle_editor_action(
2742            &mut editor,
2743            &mut canvas,
2744            EditorAction::TransposeSelectionCorner,
2745            RgbColor::new(0, 0, 0),
2746        );
2747
2748        assert!(!dispatch.handled);
2749    }
2750
2751    #[test]
2752    fn handle_editor_action_pan_shifts_viewport_origin() {
2753        let mut canvas = Canvas::with_size(40, 20);
2754        let mut editor = EditorSession::default();
2755        editor.set_viewport(
2756            Viewport {
2757                x: 0,
2758                y: 0,
2759                width: 10,
2760                height: 5,
2761            },
2762            &canvas,
2763        );
2764        editor.viewport_origin = Pos { x: 5, y: 5 };
2765        let origin_before = editor.viewport_origin;
2766
2767        let dispatch = handle_editor_action(
2768            &mut editor,
2769            &mut canvas,
2770            EditorAction::Pan { dx: 1, dy: -1 },
2771            RgbColor::new(0, 0, 0),
2772        );
2773
2774        assert!(dispatch.handled);
2775        assert_eq!(
2776            editor.viewport_origin,
2777            Pos {
2778                x: origin_before.x + 1,
2779                y: origin_before.y - 1,
2780            }
2781        );
2782    }
2783
2784    #[test]
2785    fn handle_editor_action_stroke_floating_stamps_current_and_destination() {
2786        let mut canvas = Canvas::with_size(6, 3);
2787        let mut editor = EditorSession {
2788            cursor: Pos { x: 2, y: 1 },
2789            floating: Some(FloatingSelection {
2790                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]),
2791                transparent: false,
2792                source_index: None,
2793            }),
2794            ..Default::default()
2795        };
2796
2797        let dispatch = handle_editor_action(
2798            &mut editor,
2799            &mut canvas,
2800            EditorAction::StrokeFloating {
2801                dir: MoveDir::Right,
2802            },
2803            RgbColor::new(9, 8, 7),
2804        );
2805
2806        assert!(dispatch.handled);
2807        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2808        assert_eq!(
2809            canvas.cell(Pos { x: 2, y: 1 }),
2810            Some(CellValue::Narrow('A'))
2811        );
2812        assert_eq!(
2813            canvas.cell(Pos { x: 3, y: 1 }),
2814            Some(CellValue::Narrow('A'))
2815        );
2816        assert!(editor.floating.is_some());
2817    }
2818
2819    fn pointer(col: u16, row: u16, kind: AppPointerKind) -> AppPointerEvent {
2820        AppPointerEvent {
2821            column: col,
2822            row,
2823            kind,
2824            modifiers: AppModifiers::default(),
2825        }
2826    }
2827
2828    fn viewport_editor(canvas: &Canvas) -> EditorSession {
2829        let mut editor = EditorSession::default();
2830        editor.set_viewport(
2831            Viewport {
2832                x: 0,
2833                y: 0,
2834                width: canvas.width as u16,
2835                height: canvas.height as u16,
2836            },
2837            canvas,
2838        );
2839        editor
2840    }
2841
2842    #[test]
2843    fn pointer_left_down_outside_viewport_passes_through() {
2844        let mut canvas = Canvas::with_size(4, 2);
2845        let mut editor = viewport_editor(&canvas);
2846
2847        let dispatch = handle_editor_pointer(
2848            &mut editor,
2849            &mut canvas,
2850            pointer(99, 99, AppPointerKind::Down(AppPointerButton::Left)),
2851            RgbColor::new(0, 0, 0),
2852        );
2853
2854        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
2855        assert_eq!(dispatch.stroke_hint, None);
2856        assert!(editor.drag_origin.is_none());
2857    }
2858
2859    #[test]
2860    fn pointer_non_floating_left_down_arms_selection_drag() {
2861        let mut canvas = Canvas::with_size(8, 4);
2862        let mut editor = viewport_editor(&canvas);
2863
2864        let dispatch = handle_editor_pointer(
2865            &mut editor,
2866            &mut canvas,
2867            pointer(3, 2, AppPointerKind::Down(AppPointerButton::Left)),
2868            RgbColor::new(0, 0, 0),
2869        );
2870
2871        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2872        assert_eq!(dispatch.stroke_hint, None);
2873        assert_eq!(editor.cursor, Pos { x: 3, y: 2 });
2874        assert_eq!(editor.drag_origin, Some(Pos { x: 3, y: 2 }));
2875    }
2876
2877    #[test]
2878    fn pointer_non_floating_right_down_begins_pan_inside_viewport() {
2879        let mut canvas = Canvas::with_size(8, 4);
2880        let mut editor = viewport_editor(&canvas);
2881
2882        let dispatch = handle_editor_pointer(
2883            &mut editor,
2884            &mut canvas,
2885            pointer(2, 1, AppPointerKind::Down(AppPointerButton::Right)),
2886            RgbColor::new(0, 0, 0),
2887        );
2888
2889        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2890        assert!(editor.pan_drag.is_some());
2891    }
2892
2893    #[test]
2894    fn pointer_right_down_outside_viewport_passes_through() {
2895        let mut canvas = Canvas::with_size(4, 2);
2896        let mut editor = viewport_editor(&canvas);
2897
2898        let dispatch = handle_editor_pointer(
2899            &mut editor,
2900            &mut canvas,
2901            pointer(99, 99, AppPointerKind::Down(AppPointerButton::Right)),
2902            RgbColor::new(0, 0, 0),
2903        );
2904
2905        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
2906        assert!(editor.pan_drag.is_none());
2907    }
2908
2909    #[test]
2910    fn pointer_scroll_event_pans_viewport_inside_viewport() {
2911        let mut canvas = Canvas::with_size(20, 12);
2912        let mut editor = viewport_editor(&canvas);
2913        editor.set_viewport(
2914            Viewport {
2915                x: 2,
2916                y: 3,
2917                width: 8,
2918                height: 4,
2919            },
2920            &canvas,
2921        );
2922        editor.viewport_origin = Pos { x: 5, y: 5 };
2923
2924        let dispatch = handle_editor_pointer(
2925            &mut editor,
2926            &mut canvas,
2927            pointer(4, 5, AppPointerKind::ScrollUp),
2928            RgbColor::new(0, 0, 0),
2929        );
2930
2931        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2932        assert_eq!(editor.viewport_origin, Pos { x: 5, y: 4 });
2933    }
2934
2935    #[test]
2936    fn pointer_horizontal_scroll_event_pans_viewport_inside_viewport() {
2937        let mut canvas = Canvas::with_size(20, 12);
2938        let mut editor = viewport_editor(&canvas);
2939        editor.set_viewport(
2940            Viewport {
2941                x: 2,
2942                y: 3,
2943                width: 8,
2944                height: 4,
2945            },
2946            &canvas,
2947        );
2948        editor.viewport_origin = Pos { x: 5, y: 5 };
2949
2950        let dispatch = handle_editor_pointer(
2951            &mut editor,
2952            &mut canvas,
2953            pointer(4, 5, AppPointerKind::ScrollRight),
2954            RgbColor::new(0, 0, 0),
2955        );
2956
2957        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2958        assert_eq!(editor.viewport_origin, Pos { x: 6, y: 5 });
2959    }
2960
2961    #[test]
2962    fn pointer_scroll_event_outside_viewport_passes_through() {
2963        let mut canvas = Canvas::with_size(20, 12);
2964        let mut editor = viewport_editor(&canvas);
2965        editor.set_viewport(
2966            Viewport {
2967                x: 2,
2968                y: 3,
2969                width: 8,
2970                height: 4,
2971            },
2972            &canvas,
2973        );
2974        editor.viewport_origin = Pos { x: 5, y: 5 };
2975
2976        let dispatch = handle_editor_pointer(
2977            &mut editor,
2978            &mut canvas,
2979            pointer(0, 0, AppPointerKind::ScrollUp),
2980            RgbColor::new(0, 0, 0),
2981        );
2982
2983        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
2984        assert_eq!(editor.viewport_origin, Pos { x: 5, y: 5 });
2985    }
2986
2987    #[test]
2988    fn pointer_moved_without_floating_does_not_move_caret() {
2989        // Default host policy: passive hover over the canvas must not drag
2990        // the caret around when no floating preview is armed.
2991        let mut canvas = Canvas::with_size(8, 4);
2992        let mut editor = viewport_editor(&canvas);
2993        let initial_cursor = editor.cursor;
2994
2995        let dispatch = handle_editor_pointer(
2996            &mut editor,
2997            &mut canvas,
2998            pointer(3, 2, AppPointerKind::Moved),
2999            RgbColor::new(0, 0, 0),
3000        );
3001
3002        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
3003        assert_eq!(editor.cursor, initial_cursor);
3004    }
3005
3006    #[test]
3007    fn pointer_floating_hover_tracks_cursor_by_default() {
3008        let mut canvas = Canvas::with_size(8, 4);
3009        let mut editor = viewport_editor(&canvas);
3010        editor.floating = Some(FloatingSelection {
3011            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3012            transparent: false,
3013            source_index: None,
3014        });
3015
3016        let dispatch = handle_editor_pointer(
3017            &mut editor,
3018            &mut canvas,
3019            pointer(4, 2, AppPointerKind::Moved),
3020            RgbColor::new(0, 0, 0),
3021        );
3022
3023        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3024        assert_eq!(editor.cursor, Pos { x: 4, y: 2 });
3025    }
3026
3027    #[test]
3028    fn pointer_non_floating_left_drag_establishes_selection() {
3029        let mut canvas = Canvas::with_size(8, 4);
3030        let mut editor = viewport_editor(&canvas);
3031
3032        handle_editor_pointer(
3033            &mut editor,
3034            &mut canvas,
3035            pointer(2, 1, AppPointerKind::Down(AppPointerButton::Left)),
3036            RgbColor::new(0, 0, 0),
3037        );
3038        handle_editor_pointer(
3039            &mut editor,
3040            &mut canvas,
3041            pointer(5, 2, AppPointerKind::Drag(AppPointerButton::Left)),
3042            RgbColor::new(0, 0, 0),
3043        );
3044
3045        assert_eq!(editor.selection_anchor, Some(Pos { x: 2, y: 1 }));
3046        assert_eq!(editor.cursor, Pos { x: 5, y: 2 });
3047        assert!(editor.mode.is_selecting());
3048    }
3049
3050    #[test]
3051    fn pointer_floating_left_down_begins_stroke_and_paints() {
3052        let mut canvas = Canvas::with_size(8, 4);
3053        let mut editor = viewport_editor(&canvas);
3054        editor.floating = Some(FloatingSelection {
3055            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3056            transparent: false,
3057            source_index: None,
3058        });
3059
3060        let dispatch = handle_editor_pointer(
3061            &mut editor,
3062            &mut canvas,
3063            pointer(3, 1, AppPointerKind::Down(AppPointerButton::Left)),
3064            RgbColor::new(1, 2, 3),
3065        );
3066
3067        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3068        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::Begin));
3069        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
3070        assert!(editor.paint_stroke_anchor.is_some());
3071        assert_eq!(
3072            canvas.cell(Pos { x: 3, y: 1 }),
3073            Some(CellValue::Narrow('x'))
3074        );
3075    }
3076
3077    #[test]
3078    fn pointer_floating_left_up_ends_stroke() {
3079        let mut canvas = Canvas::with_size(8, 4);
3080        let mut editor = viewport_editor(&canvas);
3081        editor.floating = Some(FloatingSelection {
3082            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3083            transparent: false,
3084            source_index: None,
3085        });
3086        editor.paint_stroke_anchor = Some(Pos { x: 0, y: 0 });
3087
3088        let dispatch = handle_editor_pointer(
3089            &mut editor,
3090            &mut canvas,
3091            pointer(3, 1, AppPointerKind::Up(AppPointerButton::Left)),
3092            RgbColor::new(0, 0, 0),
3093        );
3094
3095        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3096        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::End));
3097        assert!(editor.paint_stroke_anchor.is_none());
3098    }
3099
3100    #[test]
3101    fn pointer_floating_right_down_dismisses_and_ends_stroke() {
3102        let mut canvas = Canvas::with_size(8, 4);
3103        let mut editor = viewport_editor(&canvas);
3104        editor.floating = Some(FloatingSelection {
3105            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3106            transparent: false,
3107            source_index: None,
3108        });
3109
3110        let dispatch = handle_editor_pointer(
3111            &mut editor,
3112            &mut canvas,
3113            pointer(3, 1, AppPointerKind::Down(AppPointerButton::Right)),
3114            RgbColor::new(0, 0, 0),
3115        );
3116
3117        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3118        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::End));
3119        assert!(editor.floating.is_none());
3120    }
3121}