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) {
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            Some(CellValue::WideCont) => {
754                // A continuation-only change is implied by the wide glyph paint at
755                // its origin. Emitting a clear here would clear that new glyph
756                // when the op is replayed.
757            }
758            None => writes.push(CellWrite::Clear { pos }),
759        }
760    }
761
762    match writes.len() {
763        0 => None,
764        1 => Some(match writes.remove(0) {
765            CellWrite::Paint { pos, ch, fg } => CanvasOp::PaintCell { pos, ch, fg },
766            CellWrite::Clear { pos } => CanvasOp::ClearCell { pos },
767        }),
768        _ => Some(CanvasOp::PaintRegion { cells: writes }),
769    }
770}
771
772pub fn fill_bounds(canvas: &mut Canvas, bounds: Bounds, ch: char, fg: RgbColor) {
773    for y in bounds.min_y..=bounds.max_y {
774        let mut x = bounds.min_x;
775        while x <= bounds.max_x {
776            if ch == ' ' {
777                canvas.clear(Pos { x, y });
778                x += 1;
779                continue;
780            }
781
782            let width = Canvas::display_width(ch);
783            if width == 2 && x == bounds.max_x {
784                break;
785            }
786            let _ = canvas.put_glyph_colored(Pos { x, y }, ch, fg);
787            x += width;
788        }
789    }
790}
791
792pub fn fill_selection(
793    canvas: &mut Canvas,
794    selection: Selection,
795    bounds: Bounds,
796    ch: char,
797    fg: RgbColor,
798) {
799    if selection.shape == SelectionShape::Rect {
800        fill_bounds(canvas, bounds, ch, fg);
801        return;
802    }
803
804    let glyph_width = Canvas::display_width(ch);
805    for y in bounds.min_y..=bounds.max_y {
806        let mut x = bounds.min_x;
807        while x <= bounds.max_x {
808            let pos = Pos { x, y };
809            if !selection.contains(pos) {
810                x += 1;
811                continue;
812            }
813
814            if ch == ' ' {
815                canvas.clear(pos);
816                x += 1;
817                continue;
818            }
819
820            if glyph_width == 1 {
821                canvas.set_colored(pos, ch, fg);
822                x += 1;
823                continue;
824            }
825
826            if x < bounds.max_x && selection.contains(Pos { x: x + 1, y }) {
827                let _ = canvas.put_glyph_colored(pos, ch, fg);
828                x += glyph_width;
829            } else {
830                x += 1;
831            }
832        }
833    }
834}
835
836fn selection_has_unselected_neighbor(selection: Selection, pos: Pos) -> bool {
837    let neighbors = [
838        pos.x.checked_sub(1).map(|x| Pos { x, y: pos.y }),
839        Some(Pos {
840            x: pos.x + 1,
841            y: pos.y,
842        }),
843        pos.y.checked_sub(1).map(|y| Pos { x: pos.x, y }),
844        Some(Pos {
845            x: pos.x,
846            y: pos.y + 1,
847        }),
848    ];
849    neighbors
850        .into_iter()
851        .flatten()
852        .any(|neighbor| !selection.contains(neighbor))
853}
854
855pub fn draw_border(canvas: &mut Canvas, selection: Selection, color: RgbColor) {
856    let bounds = selection.bounds();
857    if selection.shape == SelectionShape::Ellipse {
858        for y in bounds.min_y..=bounds.max_y {
859            for x in bounds.min_x..=bounds.max_x {
860                let pos = Pos { x, y };
861                if selection.contains(pos) && selection_has_unselected_neighbor(selection, pos) {
862                    canvas.set_colored(pos, '*', color);
863                }
864            }
865        }
866        return;
867    }
868
869    if bounds.width() == 1 && bounds.height() == 1 {
870        canvas.set_colored(
871            Pos {
872                x: bounds.min_x,
873                y: bounds.min_y,
874            },
875            '*',
876            color,
877        );
878        return;
879    }
880
881    if bounds.height() == 1 {
882        canvas.set_colored(
883            Pos {
884                x: bounds.min_x,
885                y: bounds.min_y,
886            },
887            '.',
888            color,
889        );
890        for x in (bounds.min_x + 1)..bounds.max_x {
891            canvas.set_colored(Pos { x, y: bounds.min_y }, '-', color);
892        }
893        canvas.set_colored(
894            Pos {
895                x: bounds.max_x,
896                y: bounds.min_y,
897            },
898            '.',
899            color,
900        );
901        return;
902    }
903
904    if bounds.width() == 1 {
905        canvas.set_colored(
906            Pos {
907                x: bounds.min_x,
908                y: bounds.min_y,
909            },
910            '.',
911            color,
912        );
913        for y in (bounds.min_y + 1)..bounds.max_y {
914            canvas.set_colored(Pos { x: bounds.min_x, y }, '|', color);
915        }
916        canvas.set_colored(
917            Pos {
918                x: bounds.min_x,
919                y: bounds.max_y,
920            },
921            '`',
922            color,
923        );
924        return;
925    }
926
927    canvas.set_colored(
928        Pos {
929            x: bounds.min_x,
930            y: bounds.min_y,
931        },
932        '.',
933        color,
934    );
935    canvas.set_colored(
936        Pos {
937            x: bounds.max_x,
938            y: bounds.min_y,
939        },
940        '.',
941        color,
942    );
943    canvas.set_colored(
944        Pos {
945            x: bounds.min_x,
946            y: bounds.max_y,
947        },
948        '`',
949        color,
950    );
951    canvas.set_colored(
952        Pos {
953            x: bounds.max_x,
954            y: bounds.max_y,
955        },
956        '\'',
957        color,
958    );
959
960    for x in (bounds.min_x + 1)..bounds.max_x {
961        canvas.set_colored(Pos { x, y: bounds.min_y }, '-', color);
962        canvas.set_colored(Pos { x, y: bounds.max_y }, '-', color);
963    }
964
965    for y in (bounds.min_y + 1)..bounds.max_y {
966        canvas.set_colored(Pos { x: bounds.min_x, y }, '|', color);
967        canvas.set_colored(Pos { x: bounds.max_x, y }, '|', color);
968    }
969}
970
971pub fn capture_bounds(canvas: &Canvas, bounds: Bounds) -> Clipboard {
972    let mut cells = Vec::with_capacity(bounds.width() * bounds.height());
973    for y in bounds.min_y..=bounds.max_y {
974        for x in bounds.min_x..=bounds.max_x {
975            cells.push(canvas.cell(Pos { x, y }));
976        }
977    }
978    Clipboard::new(bounds.width(), bounds.height(), cells)
979}
980
981fn selection_covers_cell(canvas: &Canvas, selection: Selection, pos: Pos) -> bool {
982    if selection.contains(pos) {
983        return true;
984    }
985    let Some(origin) = canvas.glyph_origin(pos) else {
986        return false;
987    };
988    let Some(glyph) = canvas.glyph_at(origin) else {
989        return false;
990    };
991    (0..glyph.width).any(|dx| {
992        selection.contains(Pos {
993            x: origin.x + dx,
994            y: origin.y,
995        })
996    })
997}
998
999pub fn capture_selection(canvas: &Canvas, selection: Selection) -> Clipboard {
1000    let bounds = selection.bounds().normalized_for_canvas(canvas);
1001    let mut cells = Vec::with_capacity(bounds.width() * bounds.height());
1002    for y in bounds.min_y..=bounds.max_y {
1003        for x in bounds.min_x..=bounds.max_x {
1004            let pos = Pos { x, y };
1005            let include = selection_covers_cell(canvas, selection, pos);
1006            cells.push(include.then(|| canvas.cell(pos)).flatten());
1007        }
1008    }
1009    Clipboard::new(bounds.width(), bounds.height(), cells)
1010}
1011
1012pub fn export_bounds_as_text(canvas: &Canvas, bounds: Bounds) -> String {
1013    let mut text = String::with_capacity(bounds.width() * bounds.height() + bounds.height());
1014    for y in bounds.min_y..=bounds.max_y {
1015        for x in bounds.min_x..=bounds.max_x {
1016            match canvas.cell(Pos { x, y }) {
1017                Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => text.push(ch),
1018                Some(CellValue::WideCont) => {}
1019                None => text.push(' '),
1020            }
1021        }
1022        if y != bounds.max_y {
1023            text.push('\n');
1024        }
1025    }
1026    text
1027}
1028
1029pub fn export_selection_as_text(canvas: &Canvas, selection: Selection) -> String {
1030    let bounds = selection.bounds().normalized_for_canvas(canvas);
1031    let mut text = String::with_capacity(bounds.width() * bounds.height() + bounds.height());
1032    for y in bounds.min_y..=bounds.max_y {
1033        for x in bounds.min_x..=bounds.max_x {
1034            let pos = Pos { x, y };
1035            if selection_covers_cell(canvas, selection, pos) {
1036                match canvas.cell(pos) {
1037                    Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => text.push(ch),
1038                    Some(CellValue::WideCont) => {}
1039                    None => text.push(' '),
1040                }
1041            } else {
1042                text.push(' ');
1043            }
1044        }
1045        if y != bounds.max_y {
1046            text.push('\n');
1047        }
1048    }
1049    text
1050}
1051
1052pub fn stamp_clipboard(
1053    canvas: &mut Canvas,
1054    clipboard: &Clipboard,
1055    pos: Pos,
1056    color: RgbColor,
1057    transparent: bool,
1058) {
1059    for y in 0..clipboard.height {
1060        for x in 0..clipboard.width {
1061            let target_x = pos.x + x;
1062            let target_y = pos.y + y;
1063            if target_x >= canvas.width || target_y >= canvas.height {
1064                continue;
1065            }
1066            let target = Pos {
1067                x: target_x,
1068                y: target_y,
1069            };
1070            match clipboard.get(x, y) {
1071                Some(CellValue::Narrow(ch) | CellValue::Wide(ch)) => {
1072                    let _ = canvas.put_glyph_colored(target, ch, color);
1073                }
1074                Some(CellValue::WideCont) => {}
1075                None if !transparent => canvas.clear(target),
1076                None => {}
1077            }
1078        }
1079    }
1080}
1081
1082pub fn smart_fill_glyph(bounds: Bounds) -> char {
1083    if bounds.width() == 1 && bounds.height() > 1 {
1084        '|'
1085    } else if bounds.height() == 1 && bounds.width() > 1 {
1086        '-'
1087    } else {
1088        '*'
1089    }
1090}
1091
1092pub fn export_system_clipboard_text(editor: &EditorSession, canvas: &Canvas) -> String {
1093    match editor.selection() {
1094        Some(selection) => export_selection_as_text(canvas, selection),
1095        None => export_bounds_as_text(canvas, editor.system_clipboard_bounds(canvas)),
1096    }
1097}
1098
1099pub fn copy_selection_or_cell(editor: &mut EditorSession, canvas: &Canvas) -> bool {
1100    if editor.floating.is_some() {
1101        return false;
1102    }
1103
1104    let clipboard = match editor.selection() {
1105        Some(selection) => capture_selection(canvas, selection),
1106        None => capture_bounds(
1107            canvas,
1108            editor
1109                .selection_or_cursor_bounds()
1110                .normalized_for_canvas(canvas),
1111        ),
1112    };
1113    editor.push_swatch(clipboard);
1114    true
1115}
1116
1117pub fn cut_selection_or_cell(
1118    editor: &mut EditorSession,
1119    canvas: &mut Canvas,
1120    color: RgbColor,
1121) -> bool {
1122    if editor.floating.is_some() {
1123        return false;
1124    }
1125
1126    let selection = editor.selection();
1127    let bounds = editor
1128        .selection_or_cursor_bounds()
1129        .normalized_for_canvas(canvas);
1130    let clipboard = selection
1131        .map(|selection| capture_selection(canvas, selection))
1132        .unwrap_or_else(|| capture_bounds(canvas, bounds));
1133    editor.push_swatch(clipboard);
1134    match selection {
1135        Some(selection) => fill_selection(canvas, selection, bounds, ' ', color),
1136        None => fill_bounds(canvas, bounds, ' ', color),
1137    }
1138    true
1139}
1140
1141pub fn paste_primary_swatch(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1142    let Some(clipboard) = editor.swatches[0]
1143        .as_ref()
1144        .map(|swatch| swatch.clipboard.clone())
1145    else {
1146        return false;
1147    };
1148
1149    stamp_clipboard(canvas, &clipboard, editor.cursor, color, false);
1150    true
1151}
1152
1153pub fn smart_fill(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) {
1154    let selection = editor.selection();
1155    let bounds = editor.selection_or_cursor_bounds();
1156    let ch = smart_fill_glyph(bounds);
1157    match selection {
1158        Some(selection) => fill_selection(canvas, selection, bounds, ch, color),
1159        None => fill_bounds(canvas, bounds, ch, color),
1160    }
1161}
1162
1163pub fn draw_selection_border(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1164    let Some(selection) = editor.selection() else {
1165        return false;
1166    };
1167
1168    draw_border(canvas, selection, color);
1169    true
1170}
1171
1172pub fn fill_selection_or_cell(
1173    editor: &EditorSession,
1174    canvas: &mut Canvas,
1175    ch: char,
1176    color: RgbColor,
1177) {
1178    let selection = editor.selection();
1179    let bounds = editor
1180        .selection_or_cursor_bounds()
1181        .normalized_for_canvas(canvas);
1182    match selection {
1183        Some(selection) => fill_selection(canvas, selection, bounds, ch, color),
1184        None => fill_bounds(canvas, bounds, ch, color),
1185    }
1186}
1187
1188fn move_to_left_edge(editor: &mut EditorSession, canvas: &Canvas) {
1189    let bounds = editor.visible_bounds(canvas);
1190    if editor.cursor.x == bounds.min_x && bounds.min_x > 0 {
1191        scroll_half_viewport_left(editor, canvas, bounds);
1192    } else {
1193        editor.cursor.x = bounds.min_x;
1194    }
1195}
1196
1197fn move_to_right_edge(editor: &mut EditorSession, canvas: &Canvas) {
1198    let bounds = editor.visible_bounds(canvas);
1199    if editor.cursor.x == bounds.max_x && bounds.max_x + 1 < canvas.width {
1200        scroll_half_viewport_right(editor, canvas, bounds);
1201    } else {
1202        editor.cursor.x = bounds.max_x;
1203    }
1204}
1205
1206fn move_to_top_edge(editor: &mut EditorSession, canvas: &Canvas) {
1207    let bounds = editor.visible_bounds(canvas);
1208    if editor.cursor.y == bounds.min_y && bounds.min_y > 0 {
1209        scroll_half_viewport_up(editor, canvas, bounds);
1210    } else {
1211        editor.cursor.y = bounds.min_y;
1212    }
1213}
1214
1215fn move_to_bottom_edge(editor: &mut EditorSession, canvas: &Canvas) {
1216    let bounds = editor.visible_bounds(canvas);
1217    if editor.cursor.y == bounds.max_y && bounds.max_y + 1 < canvas.height {
1218        scroll_half_viewport_down(editor, canvas, bounds);
1219    } else {
1220        editor.cursor.y = bounds.max_y;
1221    }
1222}
1223
1224fn move_for_dir(editor: &mut EditorSession, canvas: &Canvas, dir: MoveDir) {
1225    editor.move_dir(canvas, dir);
1226}
1227
1228fn stroke_floating_move(
1229    editor: &mut EditorSession,
1230    canvas: &mut Canvas,
1231    dir: MoveDir,
1232    color: RgbColor,
1233) {
1234    if editor.floating.is_none() {
1235        return;
1236    }
1237
1238    let start = editor.cursor;
1239    let _ = paint_floating_at_cursor(editor, canvas, color);
1240    move_for_dir(editor, canvas, dir);
1241    if editor.cursor != start {
1242        let _ = paint_floating_at_cursor(editor, canvas, color);
1243    }
1244}
1245
1246fn half_page_step(span: usize) -> usize {
1247    (span / 2).max(1)
1248}
1249
1250fn scroll_half_viewport_left(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1251    let start_x = editor.viewport_origin.x;
1252    editor.viewport_origin.x = editor
1253        .viewport_origin
1254        .x
1255        .saturating_sub(half_page_step(bounds.width()));
1256    editor.clamp_viewport_origin(canvas);
1257    let delta = start_x - editor.viewport_origin.x;
1258    editor.cursor.x = editor.cursor.x.saturating_sub(delta);
1259}
1260
1261fn scroll_half_viewport_right(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1262    let start_x = editor.viewport_origin.x;
1263    editor.viewport_origin.x = editor
1264        .viewport_origin
1265        .x
1266        .saturating_add(half_page_step(bounds.width()));
1267    editor.clamp_viewport_origin(canvas);
1268    let delta = editor.viewport_origin.x - start_x;
1269    editor.cursor.x = (editor.cursor.x + delta).min(canvas.width.saturating_sub(1));
1270}
1271
1272fn scroll_half_viewport_up(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1273    let start_y = editor.viewport_origin.y;
1274    editor.viewport_origin.y = editor
1275        .viewport_origin
1276        .y
1277        .saturating_sub(half_page_step(bounds.height()));
1278    editor.clamp_viewport_origin(canvas);
1279    let delta = start_y - editor.viewport_origin.y;
1280    editor.cursor.y = editor.cursor.y.saturating_sub(delta);
1281}
1282
1283fn scroll_half_viewport_down(editor: &mut EditorSession, canvas: &Canvas, bounds: Bounds) {
1284    let start_y = editor.viewport_origin.y;
1285    editor.viewport_origin.y = editor
1286        .viewport_origin
1287        .y
1288        .saturating_add(half_page_step(bounds.height()));
1289    editor.clamp_viewport_origin(canvas);
1290    let delta = editor.viewport_origin.y - start_y;
1291    editor.cursor.y = (editor.cursor.y + delta).min(canvas.height.saturating_sub(1));
1292}
1293
1294fn glyph_anchor(editor: &EditorSession, canvas: &Canvas) -> Pos {
1295    canvas.glyph_origin(editor.cursor).unwrap_or(editor.cursor)
1296}
1297
1298pub fn paste_text_block(
1299    editor: &EditorSession,
1300    canvas: &mut Canvas,
1301    text: &str,
1302    color: RgbColor,
1303) -> bool {
1304    if text.is_empty() {
1305        return false;
1306    }
1307
1308    let origin = editor.cursor;
1309    let mut changed = false;
1310    let mut x = origin.x;
1311    let mut y = origin.y;
1312
1313    for ch in text.chars() {
1314        match ch {
1315            '\r' => {}
1316            '\n' => {
1317                x = origin.x;
1318                y += 1;
1319                if y >= canvas.height {
1320                    break;
1321                }
1322            }
1323            _ => {
1324                if x < canvas.width && y < canvas.height {
1325                    let before = canvas.cell(Pos { x, y });
1326                    let _ = canvas.put_glyph_colored(Pos { x, y }, ch, color);
1327                    changed |= before != canvas.cell(Pos { x, y });
1328                }
1329                x += Canvas::display_width(ch);
1330            }
1331        }
1332    }
1333
1334    changed
1335}
1336
1337pub fn insert_char(
1338    editor: &mut EditorSession,
1339    canvas: &mut Canvas,
1340    ch: char,
1341    color: RgbColor,
1342) -> bool {
1343    let cursor = editor.cursor;
1344    let width = Canvas::display_width(ch);
1345    let before = canvas.cell(cursor);
1346    let _ = canvas.put_glyph_colored(cursor, ch, color);
1347    for _ in 0..width {
1348        editor.move_right(canvas);
1349    }
1350    before != canvas.cell(cursor)
1351}
1352
1353pub fn backspace(editor: &mut EditorSession, canvas: &mut Canvas) -> bool {
1354    editor.move_left(canvas);
1355    let origin = canvas.glyph_origin(editor.cursor);
1356    let cursor = editor.cursor;
1357    let before = canvas.cell(cursor);
1358    canvas.clear(cursor);
1359    if let Some(origin) = origin {
1360        editor.cursor = origin;
1361    }
1362    before != canvas.cell(cursor)
1363}
1364
1365pub fn delete_at_cursor(editor: &mut EditorSession, canvas: &mut Canvas) -> bool {
1366    if let Some(origin) = canvas.glyph_origin(editor.cursor) {
1367        editor.cursor = origin;
1368    }
1369    let cursor = editor.cursor;
1370    let before = canvas.cell(cursor);
1371    canvas.clear(cursor);
1372    before != canvas.cell(cursor)
1373}
1374
1375pub fn push_left(editor: &EditorSession, canvas: &mut Canvas) {
1376    let anchor = glyph_anchor(editor, canvas);
1377    canvas.push_left(anchor.y, anchor.x);
1378}
1379
1380pub fn push_down(editor: &EditorSession, canvas: &mut Canvas) {
1381    let anchor = glyph_anchor(editor, canvas);
1382    canvas.push_down(anchor.x, anchor.y);
1383}
1384
1385pub fn push_up(editor: &EditorSession, canvas: &mut Canvas) {
1386    let anchor = glyph_anchor(editor, canvas);
1387    canvas.push_up(anchor.x, anchor.y);
1388}
1389
1390pub fn push_right(editor: &EditorSession, canvas: &mut Canvas) {
1391    let anchor = glyph_anchor(editor, canvas);
1392    canvas.push_right(anchor.y, anchor.x);
1393}
1394
1395pub fn pull_from_left(editor: &EditorSession, canvas: &mut Canvas) {
1396    let anchor = glyph_anchor(editor, canvas);
1397    canvas.pull_from_left(anchor.y, anchor.x);
1398}
1399
1400pub fn pull_from_down(editor: &EditorSession, canvas: &mut Canvas) {
1401    let anchor = glyph_anchor(editor, canvas);
1402    canvas.pull_from_down(anchor.x, anchor.y);
1403}
1404
1405pub fn pull_from_up(editor: &EditorSession, canvas: &mut Canvas) {
1406    let anchor = glyph_anchor(editor, canvas);
1407    canvas.pull_from_up(anchor.x, anchor.y);
1408}
1409
1410pub fn pull_from_right(editor: &EditorSession, canvas: &mut Canvas) {
1411    let anchor = glyph_anchor(editor, canvas);
1412    canvas.pull_from_right(anchor.y, anchor.x);
1413}
1414
1415pub fn transpose_selection_corner(editor: &mut EditorSession) -> bool {
1416    if !editor.mode.is_selecting() {
1417        return false;
1418    }
1419
1420    let Some(anchor) = editor.selection_anchor else {
1421        return false;
1422    };
1423
1424    editor.selection_anchor = Some(editor.cursor);
1425    editor.cursor = anchor;
1426    true
1427}
1428
1429pub fn handle_editor_key_press(
1430    editor: &mut EditorSession,
1431    canvas: &mut Canvas,
1432    key: AppKey,
1433    color: RgbColor,
1434) -> EditorKeyDispatch {
1435    let ctx = keymap::EditorContext {
1436        mode: editor.mode,
1437        has_selection_anchor: editor.selection_anchor.is_some(),
1438        is_floating: editor.floating.is_some(),
1439    };
1440    match KeyMap::default_standalone().resolve(key, ctx) {
1441        Some(action) => handle_editor_action(editor, canvas, action, color),
1442        None => EditorKeyDispatch::default(),
1443    }
1444}
1445
1446pub fn handle_editor_action(
1447    editor: &mut EditorSession,
1448    canvas: &mut Canvas,
1449    action: EditorAction,
1450    color: RgbColor,
1451) -> EditorKeyDispatch {
1452    let mut effects = Vec::new();
1453    match action {
1454        EditorAction::Move {
1455            dir,
1456            extend_selection,
1457        } => {
1458            if extend_selection {
1459                editor.begin_selection();
1460            } else if editor.mode.is_selecting() {
1461                editor.clear_selection();
1462            }
1463            move_for_dir(editor, canvas, dir);
1464        }
1465        EditorAction::MoveDownLine => editor.move_down(canvas),
1466        EditorAction::StrokeFloating { dir } => stroke_floating_move(editor, canvas, dir, color),
1467        EditorAction::Pan { dx, dy } => editor.pan_by(canvas, dx, dy),
1468        EditorAction::ClearSelection => editor.clear_selection(),
1469        EditorAction::TransposeSelectionCorner => {
1470            return EditorKeyDispatch {
1471                handled: transpose_selection_corner(editor),
1472                effects: Vec::new(),
1473            };
1474        }
1475        EditorAction::PushLeft => push_left(editor, canvas),
1476        EditorAction::PushRight => push_right(editor, canvas),
1477        EditorAction::PushUp => push_up(editor, canvas),
1478        EditorAction::PushDown => push_down(editor, canvas),
1479        EditorAction::PullFromLeft => pull_from_left(editor, canvas),
1480        EditorAction::PullFromRight => pull_from_right(editor, canvas),
1481        EditorAction::PullFromUp => pull_from_up(editor, canvas),
1482        EditorAction::PullFromDown => pull_from_down(editor, canvas),
1483        EditorAction::CopySelection => {
1484            let _ = copy_selection_or_cell(editor, canvas);
1485        }
1486        EditorAction::CutSelection => {
1487            let _ = cut_selection_or_cell(editor, canvas, color);
1488        }
1489        EditorAction::PastePrimarySwatch => {
1490            let _ = paste_primary_swatch(editor, canvas, color);
1491        }
1492        EditorAction::ExportSystemClipboard => {
1493            effects.push(HostEffect::CopyToClipboard(export_system_clipboard_text(
1494                editor, canvas,
1495            )));
1496        }
1497        EditorAction::ActivateSwatch(idx) => {
1498            editor.activate_swatch(idx);
1499        }
1500        EditorAction::SmartFill => smart_fill(editor, canvas, color),
1501        EditorAction::DrawBorder => {
1502            let _ = draw_selection_border(editor, canvas, color);
1503        }
1504        EditorAction::FillSelectionOrCell(ch) => {
1505            fill_selection_or_cell(editor, canvas, ch, color);
1506        }
1507        EditorAction::InsertChar(ch) => {
1508            let _ = insert_char(editor, canvas, ch, color);
1509        }
1510        EditorAction::Backspace => {
1511            let _ = backspace(editor, canvas);
1512        }
1513        EditorAction::Delete => {
1514            let _ = delete_at_cursor(editor, canvas);
1515        }
1516        EditorAction::ToggleFloatingTransparency => editor.toggle_float_transparency(),
1517    }
1518    EditorKeyDispatch {
1519        handled: true,
1520        effects,
1521    }
1522}
1523
1524#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1525pub enum PointerStrokeHint {
1526    Begin,
1527    End,
1528}
1529
1530/// Whether [`handle_editor_pointer`] consumed the event or left it for the
1531/// host to bubble to outer UI layers.
1532///
1533/// Hosts that embed the editor as a widget alongside other clickable UI
1534/// (swatches, menus, other panes) should treat `Passthrough` as a signal
1535/// that the pointer event is still available for outer routing.
1536#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1537pub enum PointerOutcome {
1538    /// The editor acted on the event. Suppress outer UI routing.
1539    Consumed,
1540    /// The editor did not act on the event (e.g., click outside the
1541    /// canvas viewport, mid-drag sample without an active drag origin).
1542    /// The host may bubble this event to outer UI.
1543    #[default]
1544    Passthrough,
1545}
1546
1547impl PointerOutcome {
1548    pub fn is_consumed(self) -> bool {
1549        matches!(self, PointerOutcome::Consumed)
1550    }
1551
1552    pub fn is_passthrough(self) -> bool {
1553        matches!(self, PointerOutcome::Passthrough)
1554    }
1555}
1556
1557#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1558pub struct EditorPointerDispatch {
1559    pub outcome: PointerOutcome,
1560    pub stroke_hint: Option<PointerStrokeHint>,
1561}
1562
1563impl EditorPointerDispatch {
1564    fn consumed() -> Self {
1565        Self {
1566            outcome: PointerOutcome::Consumed,
1567            stroke_hint: None,
1568        }
1569    }
1570
1571    fn consumed_with(stroke_hint: PointerStrokeHint) -> Self {
1572        Self {
1573            outcome: PointerOutcome::Consumed,
1574            stroke_hint: Some(stroke_hint),
1575        }
1576    }
1577}
1578
1579/// Apply a pointer event to the editor and return the dispatch outcome
1580/// plus any paint-stroke grouping hint the host should honor for undo
1581/// bookkeeping.
1582///
1583/// Hosts should run their own UI hit-testing first (swatch/help/picker
1584/// regions) — this handler only drives canvas-level pointer behavior
1585/// (floating paint drag, selection drag, viewport pan).
1586///
1587/// The returned [`PointerOutcome`] distinguishes events the editor
1588/// consumed (suppress outer routing) from events that should bubble.
1589///
1590/// **Hover policy.** Passive [`AppPointerKind::Moved`] events only move
1591/// the cursor when a floating selection is armed (so brush/stamp
1592/// previews follow the pointer); outside of that, passive motion is a
1593/// no-op and passes through. Scroll wheel events pan the viewport when
1594/// they arrive over the canvas viewport. This is the conditional policy
1595/// layered hosts typically want — there is no separate knob to toggle it.
1596pub fn handle_editor_pointer(
1597    editor: &mut EditorSession,
1598    canvas: &mut Canvas,
1599    mouse: AppPointerEvent,
1600    color: RgbColor,
1601) -> EditorPointerDispatch {
1602    let canvas_pos = editor.canvas_pos_for_pointer(mouse.column, mouse.row, canvas);
1603
1604    if let Some((dx, dy)) = scroll_pan_delta(mouse.kind) {
1605        if editor.viewport_contains(mouse.column, mouse.row) {
1606            editor.pan_by(canvas, dx, dy);
1607            return EditorPointerDispatch::consumed();
1608        }
1609        return EditorPointerDispatch::default();
1610    }
1611
1612    if editor.floating.is_some() {
1613        match mouse.kind {
1614            AppPointerKind::Moved => {
1615                if let Some(pos) = canvas_pos {
1616                    editor.cursor = pos;
1617                    return EditorPointerDispatch::consumed();
1618                }
1619                return EditorPointerDispatch::default();
1620            }
1621            AppPointerKind::Down(AppPointerButton::Left) => {
1622                if let Some(pos) = canvas_pos {
1623                    editor.cursor = pos;
1624                    begin_paint_stroke(editor);
1625                    paint_floating_drag(editor, canvas, pos, color);
1626                    return EditorPointerDispatch::consumed_with(PointerStrokeHint::Begin);
1627                }
1628                return EditorPointerDispatch::default();
1629            }
1630            AppPointerKind::Drag(AppPointerButton::Left) => {
1631                if let Some(pos) = canvas_pos {
1632                    paint_floating_drag(editor, canvas, pos, color);
1633                    return EditorPointerDispatch::consumed();
1634                }
1635                // Mid-stroke sample outside the canvas: the stroke is still
1636                // active, so keep the event from bubbling.
1637                if editor.paint_stroke_anchor.is_some() {
1638                    return EditorPointerDispatch::consumed();
1639                }
1640                return EditorPointerDispatch::default();
1641            }
1642            AppPointerKind::Up(AppPointerButton::Left) => {
1643                let had_stroke = editor.paint_stroke_anchor.is_some();
1644                end_paint_stroke(editor);
1645                if had_stroke {
1646                    return EditorPointerDispatch::consumed_with(PointerStrokeHint::End);
1647                }
1648                return EditorPointerDispatch::default();
1649            }
1650            AppPointerKind::Down(AppPointerButton::Right) => {
1651                // `dismiss_floating` calls `end_paint_stroke` internally.
1652                dismiss_floating(editor);
1653                return EditorPointerDispatch::consumed_with(PointerStrokeHint::End);
1654            }
1655            _ => {
1656                return EditorPointerDispatch::default();
1657            }
1658        }
1659    }
1660
1661    match mouse.kind {
1662        AppPointerKind::Down(AppPointerButton::Right) => {
1663            if editor.viewport_contains(mouse.column, mouse.row) {
1664                editor.begin_pan(mouse.column, mouse.row);
1665                return EditorPointerDispatch::consumed();
1666            }
1667            EditorPointerDispatch::default()
1668        }
1669        AppPointerKind::Down(AppPointerButton::Left) => {
1670            let Some(pos) = canvas_pos else {
1671                return EditorPointerDispatch::default();
1672            };
1673            let extend_selection = mouse.modifiers.alt && editor.selection_anchor.is_some();
1674            let ellipse_drag = mouse.modifiers.ctrl && !extend_selection;
1675
1676            if extend_selection {
1677                if let Some(anchor) = editor.selection_anchor {
1678                    editor.mode = Mode::Select;
1679                    editor.cursor = pos;
1680                    editor.drag_origin = Some(anchor);
1681                }
1682            } else {
1683                if editor.mode.is_selecting() {
1684                    editor.clear_selection();
1685                }
1686                editor.cursor = pos;
1687                editor.selection_shape = if ellipse_drag {
1688                    SelectionShape::Ellipse
1689                } else {
1690                    SelectionShape::Rect
1691                };
1692                editor.drag_origin = Some(pos);
1693            }
1694            EditorPointerDispatch::consumed()
1695        }
1696        AppPointerKind::Drag(AppPointerButton::Left) => {
1697            if let (Some(origin), Some(pos)) = (editor.drag_origin, canvas_pos) {
1698                if pos != origin || editor.mode.is_selecting() {
1699                    editor.selection_anchor = Some(origin);
1700                    editor.mode = Mode::Select;
1701                    editor.cursor = pos;
1702                }
1703                return EditorPointerDispatch::consumed();
1704            }
1705            EditorPointerDispatch::default()
1706        }
1707        AppPointerKind::Drag(AppPointerButton::Right) => {
1708            if editor.pan_drag.is_some() {
1709                editor.drag_pan(canvas, mouse.column, mouse.row);
1710                return EditorPointerDispatch::consumed();
1711            }
1712            EditorPointerDispatch::default()
1713        }
1714        AppPointerKind::Up(AppPointerButton::Left) => {
1715            if editor.drag_origin.take().is_some() {
1716                return EditorPointerDispatch::consumed();
1717            }
1718            EditorPointerDispatch::default()
1719        }
1720        AppPointerKind::Up(AppPointerButton::Right) => {
1721            if editor.pan_drag.is_some() {
1722                editor.end_pan();
1723                return EditorPointerDispatch::consumed();
1724            }
1725            EditorPointerDispatch::default()
1726        }
1727        _ => EditorPointerDispatch::default(),
1728    }
1729}
1730
1731fn scroll_pan_delta(kind: AppPointerKind) -> Option<(isize, isize)> {
1732    match kind {
1733        AppPointerKind::ScrollUp => Some((0, -1)),
1734        AppPointerKind::ScrollDown => Some((0, 1)),
1735        AppPointerKind::ScrollLeft => Some((-1, 0)),
1736        AppPointerKind::ScrollRight => Some((1, 0)),
1737        _ => None,
1738    }
1739}
1740
1741pub fn begin_paint_stroke(editor: &mut EditorSession) {
1742    editor.paint_stroke_anchor = Some(editor.cursor);
1743    editor.paint_stroke_last = None;
1744}
1745
1746pub fn end_paint_stroke(editor: &mut EditorSession) {
1747    editor.paint_stroke_anchor = None;
1748    editor.paint_stroke_last = None;
1749}
1750
1751pub fn dismiss_floating(editor: &mut EditorSession) {
1752    end_paint_stroke(editor);
1753    editor.floating = None;
1754}
1755
1756pub fn stamp_floating(editor: &EditorSession, canvas: &mut Canvas, color: RgbColor) -> bool {
1757    let Some(floating) = editor.floating.as_ref() else {
1758        return false;
1759    };
1760
1761    stamp_clipboard(
1762        canvas,
1763        &floating.clipboard,
1764        editor.cursor,
1765        color,
1766        floating.transparent,
1767    );
1768    true
1769}
1770
1771fn snap_horizontal_brush_x(anchor_x: usize, raw_x: usize, brush_width: usize) -> usize {
1772    if brush_width <= 1 {
1773        return raw_x;
1774    }
1775
1776    if raw_x >= anchor_x {
1777        anchor_x + ((raw_x - anchor_x) / brush_width) * brush_width
1778    } else {
1779        anchor_x - ((anchor_x - raw_x) / brush_width) * brush_width
1780    }
1781}
1782
1783fn line_points(start: Pos, end: Pos) -> Vec<Pos> {
1784    let mut points = Vec::new();
1785    let mut x = start.x as isize;
1786    let mut y = start.y as isize;
1787    let target_x = end.x as isize;
1788    let target_y = end.y as isize;
1789    let dx = (target_x - x).abs();
1790    let sx = if x < target_x { 1 } else { -1 };
1791    let dy = -(target_y - y).abs();
1792    let sy = if y < target_y { 1 } else { -1 };
1793    let mut err = dx + dy;
1794
1795    loop {
1796        points.push(Pos {
1797            x: x as usize,
1798            y: y as usize,
1799        });
1800
1801        if x == target_x && y == target_y {
1802            break;
1803        }
1804
1805        let twice_err = 2 * err;
1806        if twice_err >= dy {
1807            err += dy;
1808            x += sx;
1809        }
1810        if twice_err <= dx {
1811            err += dx;
1812            y += sy;
1813        }
1814    }
1815
1816    points
1817}
1818
1819fn paint_floating_at_cursor(
1820    editor: &mut EditorSession,
1821    canvas: &mut Canvas,
1822    color: RgbColor,
1823) -> bool {
1824    if !stamp_floating(editor, canvas, color) {
1825        return false;
1826    }
1827    editor.paint_stroke_last = Some(editor.cursor);
1828    true
1829}
1830
1831fn paint_floating_diagonal_segment(
1832    editor: &mut EditorSession,
1833    canvas: &mut Canvas,
1834    start: Pos,
1835    end: Pos,
1836    brush_width: usize,
1837    color: RgbColor,
1838) -> bool {
1839    let mut changed = false;
1840    let mut last_stamped = start;
1841    for point in line_points(start, end).into_iter().skip(1) {
1842        let should_stamp =
1843            point.y != last_stamped.y || point.x.abs_diff(last_stamped.x) >= brush_width;
1844        if !should_stamp {
1845            continue;
1846        }
1847
1848        editor.cursor = point;
1849        changed |= paint_floating_at_cursor(editor, canvas, color);
1850        last_stamped = point;
1851    }
1852
1853    let should_stamp_end = end.y != last_stamped.y || end.x.abs_diff(last_stamped.x) >= brush_width;
1854    if should_stamp_end {
1855        editor.cursor = end;
1856        changed |= paint_floating_at_cursor(editor, canvas, color);
1857    }
1858    changed
1859}
1860
1861pub fn paint_floating_drag(
1862    editor: &mut EditorSession,
1863    canvas: &mut Canvas,
1864    raw_pos: Pos,
1865    color: RgbColor,
1866) -> bool {
1867    let Some(last) = editor.paint_stroke_last else {
1868        editor.cursor = raw_pos;
1869        return paint_floating_at_cursor(editor, canvas, color);
1870    };
1871
1872    let anchor = editor.paint_stroke_anchor.unwrap_or(last);
1873    let brush_width = editor.floating_brush_width();
1874    let is_pure_horizontal =
1875        brush_width > 1 && raw_pos.y == last.y && raw_pos.y == anchor.y && last.y == anchor.y;
1876
1877    if is_pure_horizontal {
1878        let target = Pos {
1879            x: snap_horizontal_brush_x(anchor.x, raw_pos.x, brush_width),
1880            y: raw_pos.y,
1881        };
1882        if target == last {
1883            return false;
1884        }
1885        editor.cursor = target;
1886        return paint_floating_at_cursor(editor, canvas, color);
1887    }
1888
1889    if brush_width > 1 && raw_pos.y == last.y {
1890        if raw_pos.x.abs_diff(last.x) < brush_width {
1891            return false;
1892        }
1893
1894        editor.cursor = raw_pos;
1895        return paint_floating_at_cursor(editor, canvas, color);
1896    }
1897
1898    if brush_width > 1 && raw_pos.y != last.y {
1899        return paint_floating_diagonal_segment(editor, canvas, last, raw_pos, brush_width, color);
1900    }
1901
1902    if raw_pos == last {
1903        return false;
1904    }
1905
1906    editor.cursor = raw_pos;
1907    paint_floating_at_cursor(editor, canvas, color)
1908}
1909
1910#[cfg(test)]
1911mod tests {
1912    use super::{
1913        backspace, begin_paint_stroke, capture_bounds, capture_selection, copy_selection_or_cell,
1914        cut_selection_or_cell, delete_at_cursor, diff_canvas_op, dismiss_floating, draw_border,
1915        draw_selection_border, export_selection_as_text, export_system_clipboard_text,
1916        fill_selection, fill_selection_or_cell, handle_editor_action, handle_editor_key_press,
1917        handle_editor_pointer, insert_char, paint_floating_drag, paste_primary_swatch,
1918        paste_text_block, smart_fill, smart_fill_glyph, stamp_clipboard,
1919        transpose_selection_corner, AppKey, AppKeyCode, AppModifiers, AppPointerButton,
1920        AppPointerEvent, AppPointerKind, Bounds, Clipboard, EditorAction, EditorKeyDispatch,
1921        EditorSession, FloatingSelection, HostEffect, Mode, MoveDir, PointerOutcome,
1922        PointerStrokeHint, Selection, SelectionShape, SwatchActivation, Viewport,
1923    };
1924    use dartboard_core::{Canvas, CanvasOp, CellValue, Pos, RgbColor};
1925
1926    #[test]
1927    fn ellipse_contains_degenerate_line() {
1928        let selection = Selection {
1929            anchor: Pos { x: 2, y: 4 },
1930            cursor: Pos { x: 2, y: 8 },
1931            shape: SelectionShape::Ellipse,
1932        };
1933
1934        assert!(selection.contains(Pos { x: 2, y: 6 }));
1935        assert!(!selection.contains(Pos { x: 3, y: 6 }));
1936    }
1937
1938    #[test]
1939    fn bounds_normalize_wide_glyph_edges() {
1940        let mut canvas = Canvas::with_size(8, 4);
1941        let _ = canvas.put_glyph(Pos { x: 2, y: 1 }, '🌱');
1942
1943        let bounds = Bounds {
1944            min_x: 3,
1945            max_x: 3,
1946            min_y: 1,
1947            max_y: 1,
1948        }
1949        .normalized_for_canvas(&canvas);
1950
1951        assert_eq!(bounds.min_x, 2);
1952        assert_eq!(bounds.max_x, 3);
1953    }
1954
1955    #[test]
1956    fn diff_canvas_op_uses_default_fg_for_uncolored_cells() {
1957        let before = Canvas::with_size(4, 2);
1958        let mut after = before.clone();
1959        after.set(Pos { x: 1, y: 0 }, 'X');
1960
1961        let op = diff_canvas_op(&before, &after, RgbColor::new(9, 8, 7)).unwrap();
1962        match op {
1963            CanvasOp::PaintCell { fg, .. } => assert_eq!(fg, RgbColor::new(9, 8, 7)),
1964            other => panic!("expected PaintCell, got {other:?}"),
1965        }
1966    }
1967
1968    #[test]
1969    fn diff_canvas_op_wide_insert_left_of_filled_cell_replays_cleanly() {
1970        let mut before = Canvas::with_size(5, 1);
1971        before.set_colored(Pos { x: 1, y: 0 }, 'A', RgbColor::new(1, 2, 3));
1972
1973        let mut after = before.clone();
1974        let _ = after.put_glyph_colored(Pos { x: 0, y: 0 }, '👍', RgbColor::new(4, 5, 6));
1975
1976        let op = diff_canvas_op(&before, &after, RgbColor::new(4, 5, 6)).expect("wide insert op");
1977        let mut replay = before.clone();
1978        replay.apply(&op);
1979
1980        assert_eq!(
1981            op,
1982            CanvasOp::PaintCell {
1983                pos: Pos { x: 0, y: 0 },
1984                ch: '👍',
1985                fg: RgbColor::new(4, 5, 6),
1986            }
1987        );
1988        assert_eq!(replay, after);
1989        assert_eq!(replay.get(Pos { x: 0, y: 0 }), '👍');
1990        assert_eq!(replay.cell(Pos { x: 1, y: 0 }), Some(CellValue::WideCont));
1991    }
1992
1993    #[test]
1994    fn editor_session_selection_tracks_cursor() {
1995        let mut session = EditorSession {
1996            viewport: Viewport {
1997                width: 20,
1998                height: 10,
1999                ..Default::default()
2000            },
2001            ..Default::default()
2002        };
2003        session.cursor = Pos { x: 3, y: 4 };
2004        session.begin_selection();
2005        session.cursor = Pos { x: 8, y: 6 };
2006
2007        let selection = session.selection().unwrap();
2008        assert_eq!(selection.anchor, Pos { x: 3, y: 4 });
2009        assert_eq!(selection.cursor, Pos { x: 8, y: 6 });
2010        assert_eq!(selection.shape, SelectionShape::Rect);
2011    }
2012
2013    #[test]
2014    fn set_viewport_clamps_origin_and_cursor() {
2015        let canvas = Canvas::with_size(40, 20);
2016        let mut session = EditorSession {
2017            cursor: Pos { x: 39, y: 19 },
2018            viewport_origin: Pos { x: 25, y: 18 },
2019            ..Default::default()
2020        };
2021
2022        session.set_viewport(
2023            Viewport {
2024                x: 2,
2025                y: 3,
2026                width: 10,
2027                height: 5,
2028            },
2029            &canvas,
2030        );
2031
2032        assert_eq!(session.viewport_origin, Pos { x: 25, y: 15 });
2033        assert_eq!(session.cursor, Pos { x: 34, y: 19 });
2034    }
2035
2036    #[test]
2037    fn move_right_scrolls_viewport_to_keep_cursor_visible() {
2038        let canvas = Canvas::with_size(40, 10);
2039        let mut session = EditorSession {
2040            cursor: Pos { x: 3, y: 2 },
2041            viewport: Viewport {
2042                width: 4,
2043                height: 3,
2044                ..Default::default()
2045            },
2046            ..Default::default()
2047        };
2048
2049        session.move_right(&canvas);
2050
2051        assert_eq!(session.cursor, Pos { x: 4, y: 2 });
2052        assert_eq!(session.viewport_origin, Pos { x: 1, y: 0 });
2053    }
2054
2055    #[test]
2056    fn move_page_down_scrolls_half_viewport_when_already_at_bottom_edge() {
2057        let canvas = Canvas::with_size(40, 30);
2058        let mut session = EditorSession {
2059            cursor: Pos { x: 3, y: 1 },
2060            viewport: Viewport {
2061                width: 8,
2062                height: 5,
2063                ..Default::default()
2064            },
2065            ..Default::default()
2066        };
2067
2068        session.move_dir(&canvas, MoveDir::PageDown);
2069        assert_eq!(session.cursor, Pos { x: 3, y: 4 });
2070        assert_eq!(session.viewport_origin, Pos { x: 0, y: 0 });
2071
2072        session.move_dir(&canvas, MoveDir::PageDown);
2073        assert_eq!(session.cursor, Pos { x: 3, y: 6 });
2074        assert_eq!(session.viewport_origin, Pos { x: 0, y: 2 });
2075    }
2076
2077    #[test]
2078    fn move_home_scrolls_half_viewport_when_already_at_left_edge() {
2079        let canvas = Canvas::with_size(40, 20);
2080        let mut session = EditorSession {
2081            cursor: Pos { x: 10, y: 2 },
2082            viewport: Viewport {
2083                width: 6,
2084                height: 4,
2085                ..Default::default()
2086            },
2087            viewport_origin: Pos { x: 8, y: 0 },
2088            ..Default::default()
2089        };
2090
2091        session.move_dir(&canvas, MoveDir::LineStart);
2092        assert_eq!(session.cursor, Pos { x: 8, y: 2 });
2093        assert_eq!(session.viewport_origin, Pos { x: 8, y: 0 });
2094
2095        session.move_dir(&canvas, MoveDir::LineStart);
2096        assert_eq!(session.cursor, Pos { x: 5, y: 2 });
2097        assert_eq!(session.viewport_origin, Pos { x: 5, y: 0 });
2098    }
2099
2100    #[test]
2101    fn drag_pan_clamps_to_canvas_bounds() {
2102        let canvas = Canvas::with_size(20, 10);
2103        let mut session = EditorSession {
2104            cursor: Pos { x: 6, y: 5 },
2105            viewport: Viewport {
2106                width: 6,
2107                height: 4,
2108                ..Default::default()
2109            },
2110            viewport_origin: Pos { x: 8, y: 4 },
2111            ..Default::default()
2112        };
2113
2114        session.begin_pan(12, 8);
2115        session.drag_pan(&canvas, 0, 0);
2116
2117        assert_eq!(session.viewport_origin, Pos { x: 14, y: 6 });
2118        assert_eq!(session.cursor, Pos { x: 14, y: 6 });
2119        session.end_pan();
2120        assert!(session.pan_drag.is_none());
2121    }
2122
2123    #[test]
2124    fn system_clipboard_bounds_falls_back_to_canvas() {
2125        let canvas = Canvas::with_size(8, 4);
2126        let session = EditorSession::default();
2127
2128        assert_eq!(
2129            session.system_clipboard_bounds(&canvas),
2130            Bounds {
2131                min_x: 0,
2132                max_x: 7,
2133                min_y: 0,
2134                max_y: 3,
2135            }
2136        );
2137    }
2138
2139    #[test]
2140    fn push_swatch_rotates_unpinned_history_only() {
2141        let clipboard_a = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]);
2142        let clipboard_b = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('B'))]);
2143        let clipboard_c = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('C'))]);
2144        let mut session = EditorSession::default();
2145
2146        session.push_swatch(clipboard_a.clone());
2147        session.push_swatch(clipboard_b.clone());
2148        session.toggle_pin(1);
2149        session.push_swatch(clipboard_c.clone());
2150
2151        assert_eq!(session.populated_swatch_count(), 3);
2152        assert_eq!(
2153            session.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2154            Some(CellValue::Narrow('C'))
2155        );
2156        assert!(session.swatches[1].as_ref().unwrap().pinned);
2157        assert_eq!(
2158            session.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2159            Some(CellValue::Narrow('A'))
2160        );
2161        assert_eq!(
2162            session.swatches[2].as_ref().unwrap().clipboard.get(0, 0),
2163            Some(CellValue::Narrow('B'))
2164        );
2165    }
2166
2167    #[test]
2168    fn activating_same_swatch_toggles_transparency() {
2169        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2170        let mut session = EditorSession::default();
2171        session.push_swatch(clipboard);
2172
2173        assert_eq!(
2174            session.activate_swatch(0),
2175            SwatchActivation::ActivatedFloating
2176        );
2177        assert_eq!(
2178            session.activate_swatch(0),
2179            SwatchActivation::ToggledTransparency
2180        );
2181        assert!(session.floating.as_ref().unwrap().transparent);
2182        assert_eq!(session.floating_brush_width(), 1);
2183    }
2184
2185    #[test]
2186    fn clearing_swatch_empties_slot() {
2187        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2188        let mut session = EditorSession::default();
2189        session.push_swatch(clipboard);
2190
2191        session.clear_swatch(0);
2192
2193        assert!(session.swatches[0].is_none());
2194    }
2195
2196    #[test]
2197    fn clearing_active_swatch_dismisses_floating() {
2198        let clipboard = Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]);
2199        let mut session = EditorSession::default();
2200        session.push_swatch(clipboard);
2201        assert_eq!(
2202            session.activate_swatch(0),
2203            SwatchActivation::ActivatedFloating
2204        );
2205
2206        session.clear_swatch(0);
2207
2208        assert!(session.swatches[0].is_none());
2209        assert!(session.floating.is_none());
2210    }
2211
2212    #[test]
2213    fn capture_and_export_selection_respects_mask_shape() {
2214        let mut canvas = Canvas::with_size(5, 3);
2215        canvas.set(Pos { x: 0, y: 0 }, 'A');
2216        canvas.set(Pos { x: 1, y: 0 }, 'B');
2217        canvas.set(Pos { x: 2, y: 0 }, 'C');
2218        canvas.set(Pos { x: 0, y: 1 }, 'D');
2219        canvas.set(Pos { x: 1, y: 1 }, 'E');
2220        canvas.set(Pos { x: 2, y: 1 }, 'F');
2221
2222        let selection = Selection {
2223            anchor: Pos { x: 0, y: 0 },
2224            cursor: Pos { x: 2, y: 1 },
2225            shape: SelectionShape::Ellipse,
2226        };
2227
2228        let clipboard = capture_selection(&canvas, selection);
2229        assert_eq!(clipboard.width, 3);
2230        assert_eq!(clipboard.height, 2);
2231        assert_eq!(clipboard.get(0, 0), Some(CellValue::Narrow('A')));
2232        assert_eq!(clipboard.get(1, 0), Some(CellValue::Narrow('B')));
2233        assert_eq!(clipboard.get(2, 0), Some(CellValue::Narrow('C')));
2234        assert_eq!(clipboard.get(0, 1), Some(CellValue::Narrow('D')));
2235        assert_eq!(clipboard.get(1, 1), Some(CellValue::Narrow('E')));
2236        assert_eq!(clipboard.get(2, 1), Some(CellValue::Narrow('F')));
2237        assert_eq!(export_selection_as_text(&canvas, selection), "ABC\nDEF");
2238    }
2239
2240    #[test]
2241    fn capture_selection_on_wide_glyph_origin_includes_both_cells() {
2242        let mut canvas = Canvas::with_size(6, 1);
2243        canvas.set(Pos { x: 2, y: 0 }, '🌱');
2244        let selection = Selection {
2245            anchor: Pos { x: 2, y: 0 },
2246            cursor: Pos { x: 2, y: 0 },
2247            shape: SelectionShape::Rect,
2248        };
2249
2250        let clipboard = capture_selection(&canvas, selection);
2251
2252        assert_eq!(clipboard.width, 2);
2253        assert_eq!(clipboard.height, 1);
2254        assert_eq!(clipboard.get(0, 0), Some(CellValue::Wide('🌱')));
2255        assert_eq!(clipboard.get(1, 0), Some(CellValue::WideCont));
2256        assert_eq!(export_selection_as_text(&canvas, selection), "🌱");
2257    }
2258
2259    #[test]
2260    fn capture_selection_on_wide_glyph_continuation_includes_both_cells() {
2261        let mut canvas = Canvas::with_size(6, 1);
2262        canvas.set(Pos { x: 2, y: 0 }, '🌱');
2263        let selection = Selection {
2264            anchor: Pos { x: 3, y: 0 },
2265            cursor: Pos { x: 3, y: 0 },
2266            shape: SelectionShape::Rect,
2267        };
2268
2269        let clipboard = capture_selection(&canvas, selection);
2270
2271        assert_eq!(clipboard.width, 2);
2272        assert_eq!(clipboard.height, 1);
2273        assert_eq!(clipboard.get(0, 0), Some(CellValue::Wide('🌱')));
2274        assert_eq!(clipboard.get(1, 0), Some(CellValue::WideCont));
2275        assert_eq!(export_selection_as_text(&canvas, selection), "🌱");
2276    }
2277
2278    #[test]
2279    fn fill_selection_masks_ellipse_edges() {
2280        let mut canvas = Canvas::with_size(5, 5);
2281        let selection = Selection {
2282            anchor: Pos { x: 0, y: 0 },
2283            cursor: Pos { x: 4, y: 4 },
2284            shape: SelectionShape::Ellipse,
2285        };
2286        let bounds = selection.bounds();
2287
2288        fill_selection(&mut canvas, selection, bounds, 'x', RgbColor::new(1, 2, 3));
2289
2290        assert_eq!(canvas.cell(Pos { x: 0, y: 0 }), None);
2291        assert_eq!(
2292            canvas.cell(Pos { x: 2, y: 0 }),
2293            Some(CellValue::Narrow('x'))
2294        );
2295        assert_eq!(
2296            canvas.cell(Pos { x: 0, y: 2 }),
2297            Some(CellValue::Narrow('x'))
2298        );
2299        assert_eq!(
2300            canvas.cell(Pos { x: 2, y: 2 }),
2301            Some(CellValue::Narrow('x'))
2302        );
2303        assert_eq!(
2304            canvas.cell(Pos { x: 4, y: 2 }),
2305            Some(CellValue::Narrow('x'))
2306        );
2307        assert_eq!(
2308            canvas.cell(Pos { x: 2, y: 4 }),
2309            Some(CellValue::Narrow('x'))
2310        );
2311        assert_eq!(canvas.cell(Pos { x: 4, y: 4 }), None);
2312    }
2313
2314    #[test]
2315    fn draw_border_writes_ascii_frame_for_rect_selection() {
2316        let mut canvas = Canvas::with_size(6, 4);
2317        let selection = Selection {
2318            anchor: Pos { x: 1, y: 1 },
2319            cursor: Pos { x: 3, y: 2 },
2320            shape: SelectionShape::Rect,
2321        };
2322
2323        draw_border(&mut canvas, selection, RgbColor::new(7, 8, 9));
2324
2325        let captured = capture_bounds(
2326            &canvas,
2327            Bounds {
2328                min_x: 1,
2329                max_x: 3,
2330                min_y: 1,
2331                max_y: 2,
2332            },
2333        );
2334        assert_eq!(captured.get(0, 0), Some(CellValue::Narrow('.')));
2335        assert_eq!(captured.get(1, 0), Some(CellValue::Narrow('-')));
2336        assert_eq!(captured.get(2, 0), Some(CellValue::Narrow('.')));
2337        assert_eq!(captured.get(0, 1), Some(CellValue::Narrow('`')));
2338        assert_eq!(captured.get(1, 1), Some(CellValue::Narrow('-')));
2339        assert_eq!(captured.get(2, 1), Some(CellValue::Narrow('\'')));
2340    }
2341
2342    #[test]
2343    fn stamp_clipboard_honors_transparency() {
2344        let clipboard = Clipboard::new(
2345            2,
2346            2,
2347            vec![
2348                Some(CellValue::Narrow('A')),
2349                None,
2350                None,
2351                Some(CellValue::Narrow('B')),
2352            ],
2353        );
2354        let mut canvas = Canvas::with_size(4, 4);
2355        canvas.set(Pos { x: 2, y: 1 }, 'z');
2356        canvas.set(Pos { x: 1, y: 2 }, 'y');
2357
2358        stamp_clipboard(
2359            &mut canvas,
2360            &clipboard,
2361            Pos { x: 1, y: 1 },
2362            RgbColor::new(5, 6, 7),
2363            true,
2364        );
2365        assert_eq!(
2366            canvas.cell(Pos { x: 1, y: 1 }),
2367            Some(CellValue::Narrow('A'))
2368        );
2369        assert_eq!(
2370            canvas.cell(Pos { x: 2, y: 1 }),
2371            Some(CellValue::Narrow('z'))
2372        );
2373        assert_eq!(
2374            canvas.cell(Pos { x: 1, y: 2 }),
2375            Some(CellValue::Narrow('y'))
2376        );
2377        assert_eq!(
2378            canvas.cell(Pos { x: 2, y: 2 }),
2379            Some(CellValue::Narrow('B'))
2380        );
2381
2382        stamp_clipboard(
2383            &mut canvas,
2384            &clipboard,
2385            Pos { x: 1, y: 1 },
2386            RgbColor::new(5, 6, 7),
2387            false,
2388        );
2389        assert_eq!(canvas.cell(Pos { x: 2, y: 1 }), None);
2390        assert_eq!(canvas.cell(Pos { x: 1, y: 2 }), None);
2391    }
2392
2393    #[test]
2394    fn smart_fill_glyph_matches_bounds_shape() {
2395        assert_eq!(
2396            smart_fill_glyph(Bounds {
2397                min_x: 0,
2398                max_x: 0,
2399                min_y: 0,
2400                max_y: 2,
2401            }),
2402            '|'
2403        );
2404        assert_eq!(
2405            smart_fill_glyph(Bounds {
2406                min_x: 0,
2407                max_x: 2,
2408                min_y: 0,
2409                max_y: 0,
2410            }),
2411            '-'
2412        );
2413        assert_eq!(
2414            smart_fill_glyph(Bounds {
2415                min_x: 0,
2416                max_x: 1,
2417                min_y: 0,
2418                max_y: 1,
2419            }),
2420            '*'
2421        );
2422    }
2423
2424    #[test]
2425    fn copy_and_cut_commands_update_swatches_and_canvas() {
2426        let mut canvas = Canvas::with_size(4, 2);
2427        canvas.set(Pos { x: 1, y: 0 }, 'Q');
2428        let mut editor = EditorSession {
2429            cursor: Pos { x: 1, y: 0 },
2430            ..Default::default()
2431        };
2432
2433        assert!(copy_selection_or_cell(&mut editor, &canvas));
2434        assert_eq!(
2435            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2436            Some(CellValue::Narrow('Q'))
2437        );
2438
2439        assert!(cut_selection_or_cell(
2440            &mut editor,
2441            &mut canvas,
2442            RgbColor::new(1, 2, 3)
2443        ));
2444        assert_eq!(canvas.cell(Pos { x: 1, y: 0 }), None);
2445        assert_eq!(
2446            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2447            Some(CellValue::Narrow('Q'))
2448        );
2449    }
2450
2451    #[test]
2452    fn paste_and_fill_commands_use_editor_state() {
2453        let mut canvas = Canvas::with_size(6, 4);
2454        let mut editor = EditorSession {
2455            cursor: Pos { x: 2, y: 1 },
2456            ..Default::default()
2457        };
2458        editor.push_swatch(Clipboard::new(1, 1, vec![Some(CellValue::Narrow('P'))]));
2459
2460        assert!(paste_primary_swatch(
2461            &editor,
2462            &mut canvas,
2463            RgbColor::new(4, 5, 6)
2464        ));
2465        assert_eq!(
2466            canvas.cell(Pos { x: 2, y: 1 }),
2467            Some(CellValue::Narrow('P'))
2468        );
2469
2470        fill_selection_or_cell(&editor, &mut canvas, 'x', RgbColor::new(7, 8, 9));
2471        assert_eq!(
2472            canvas.cell(Pos { x: 2, y: 1 }),
2473            Some(CellValue::Narrow('x'))
2474        );
2475    }
2476
2477    #[test]
2478    fn smart_fill_border_and_export_commands_follow_selection() {
2479        let mut canvas = Canvas::with_size(6, 4);
2480        let mut editor = EditorSession {
2481            cursor: Pos { x: 1, y: 1 },
2482            ..Default::default()
2483        };
2484        editor.begin_selection();
2485        editor.cursor = Pos { x: 3, y: 2 };
2486
2487        smart_fill(&editor, &mut canvas, RgbColor::new(1, 2, 3));
2488        assert_eq!(export_system_clipboard_text(&editor, &canvas), "***\n***");
2489
2490        assert!(draw_selection_border(
2491            &editor,
2492            &mut canvas,
2493            RgbColor::new(9, 8, 7)
2494        ));
2495        assert_eq!(
2496            canvas.cell(Pos { x: 1, y: 1 }),
2497            Some(CellValue::Narrow('.'))
2498        );
2499        assert_eq!(
2500            canvas.cell(Pos { x: 3, y: 2 }),
2501            Some(CellValue::Narrow('\''))
2502        );
2503    }
2504
2505    #[test]
2506    fn floating_drag_updates_cursor_and_stroke_state() {
2507        let mut canvas = Canvas::with_size(8, 4);
2508        let mut editor = EditorSession {
2509            cursor: Pos { x: 1, y: 1 },
2510            floating: Some(FloatingSelection {
2511                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('F'))]),
2512                transparent: false,
2513                source_index: Some(0),
2514            }),
2515            ..Default::default()
2516        };
2517
2518        begin_paint_stroke(&mut editor);
2519        assert!(paint_floating_drag(
2520            &mut editor,
2521            &mut canvas,
2522            Pos { x: 1, y: 1 },
2523            RgbColor::new(3, 4, 5)
2524        ));
2525        assert_eq!(editor.paint_stroke_last, Some(Pos { x: 1, y: 1 }));
2526        assert_eq!(
2527            canvas.cell(Pos { x: 1, y: 1 }),
2528            Some(CellValue::Narrow('F'))
2529        );
2530
2531        assert!(paint_floating_drag(
2532            &mut editor,
2533            &mut canvas,
2534            Pos { x: 3, y: 1 },
2535            RgbColor::new(3, 4, 5)
2536        ));
2537        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2538        assert_eq!(editor.paint_stroke_last, Some(Pos { x: 3, y: 1 }));
2539        assert_eq!(
2540            canvas.cell(Pos { x: 3, y: 1 }),
2541            Some(CellValue::Narrow('F'))
2542        );
2543    }
2544
2545    #[test]
2546    fn dismiss_floating_clears_float_and_stroke_tracking() {
2547        let mut editor = EditorSession {
2548            cursor: Pos { x: 2, y: 2 },
2549            floating: Some(FloatingSelection {
2550                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('X'))]),
2551                transparent: true,
2552                source_index: None,
2553            }),
2554            paint_stroke_anchor: Some(Pos { x: 1, y: 1 }),
2555            paint_stroke_last: Some(Pos { x: 2, y: 2 }),
2556            ..Default::default()
2557        };
2558
2559        dismiss_floating(&mut editor);
2560
2561        assert!(editor.floating.is_none());
2562        assert!(editor.paint_stroke_anchor.is_none());
2563        assert!(editor.paint_stroke_last.is_none());
2564    }
2565
2566    #[test]
2567    fn insert_and_delete_commands_mutate_canvas_and_cursor() {
2568        let mut canvas = Canvas::with_size(8, 3);
2569        let mut editor = EditorSession::default();
2570
2571        assert!(insert_char(
2572            &mut editor,
2573            &mut canvas,
2574            'A',
2575            RgbColor::new(1, 2, 3)
2576        ));
2577        assert_eq!(
2578            canvas.cell(Pos { x: 0, y: 0 }),
2579            Some(CellValue::Narrow('A'))
2580        );
2581        assert_eq!(editor.cursor, Pos { x: 1, y: 0 });
2582
2583        assert!(backspace(&mut editor, &mut canvas));
2584        assert_eq!(canvas.cell(Pos { x: 0, y: 0 }), None);
2585        assert_eq!(editor.cursor, Pos { x: 0, y: 0 });
2586
2587        let _ = canvas.put_glyph_colored(Pos { x: 2, y: 1 }, 'Z', RgbColor::new(4, 5, 6));
2588        editor.cursor = Pos { x: 2, y: 1 };
2589        assert!(delete_at_cursor(&mut editor, &mut canvas));
2590        assert_eq!(canvas.cell(Pos { x: 2, y: 1 }), None);
2591    }
2592
2593    #[test]
2594    fn paste_text_block_uses_cursor_origin() {
2595        let mut canvas = Canvas::with_size(6, 4);
2596        let editor = EditorSession {
2597            cursor: Pos { x: 1, y: 1 },
2598            ..Default::default()
2599        };
2600
2601        assert!(paste_text_block(
2602            &editor,
2603            &mut canvas,
2604            "AB\nC",
2605            RgbColor::new(7, 8, 9)
2606        ));
2607        assert_eq!(
2608            canvas.cell(Pos { x: 1, y: 1 }),
2609            Some(CellValue::Narrow('A'))
2610        );
2611        assert_eq!(
2612            canvas.cell(Pos { x: 2, y: 1 }),
2613            Some(CellValue::Narrow('B'))
2614        );
2615        assert_eq!(
2616            canvas.cell(Pos { x: 1, y: 2 }),
2617            Some(CellValue::Narrow('C'))
2618        );
2619    }
2620
2621    #[test]
2622    fn transpose_selection_corner_swaps_anchor_and_cursor() {
2623        let mut editor = EditorSession {
2624            cursor: Pos { x: 4, y: 3 },
2625            selection_anchor: Some(Pos { x: 1, y: 2 }),
2626            mode: super::Mode::Select,
2627            ..Default::default()
2628        };
2629
2630        assert!(transpose_selection_corner(&mut editor));
2631        assert_eq!(editor.selection_anchor, Some(Pos { x: 4, y: 3 }));
2632        assert_eq!(editor.cursor, Pos { x: 1, y: 2 });
2633    }
2634
2635    #[test]
2636    fn handle_editor_key_press_returns_clipboard_effect_for_alt_c() {
2637        let mut canvas = Canvas::with_size(4, 2);
2638        canvas.set(Pos { x: 0, y: 0 }, 'A');
2639        let mut editor = EditorSession::default();
2640
2641        let dispatch = handle_editor_key_press(
2642            &mut editor,
2643            &mut canvas,
2644            AppKey {
2645                code: AppKeyCode::Char('c'),
2646                modifiers: AppModifiers {
2647                    alt: true,
2648                    ..Default::default()
2649                },
2650            },
2651            RgbColor::new(1, 2, 3),
2652        );
2653
2654        assert_eq!(
2655            dispatch,
2656            EditorKeyDispatch {
2657                handled: true,
2658                effects: vec![HostEffect::CopyToClipboard("A   \n    ".to_string())],
2659            }
2660        );
2661    }
2662
2663    #[test]
2664    fn handle_editor_key_press_handles_selection_fill_and_ctrl_commands() {
2665        let mut canvas = Canvas::with_size(6, 3);
2666        let mut editor = EditorSession {
2667            cursor: Pos { x: 1, y: 1 },
2668            ..Default::default()
2669        };
2670
2671        let fill_dispatch = handle_editor_key_press(
2672            &mut editor,
2673            &mut canvas,
2674            AppKey {
2675                code: AppKeyCode::Right,
2676                modifiers: AppModifiers {
2677                    shift: true,
2678                    ..Default::default()
2679                },
2680            },
2681            RgbColor::new(1, 2, 3),
2682        );
2683        assert!(fill_dispatch.handled);
2684        assert!(editor.mode.is_selecting());
2685
2686        let fill_dispatch = handle_editor_key_press(
2687            &mut editor,
2688            &mut canvas,
2689            AppKey {
2690                code: AppKeyCode::Char('x'),
2691                modifiers: AppModifiers::default(),
2692            },
2693            RgbColor::new(1, 2, 3),
2694        );
2695        assert!(fill_dispatch.handled);
2696        assert_eq!(
2697            canvas.cell(Pos { x: 1, y: 1 }),
2698            Some(CellValue::Narrow('x'))
2699        );
2700        assert_eq!(
2701            canvas.cell(Pos { x: 2, y: 1 }),
2702            Some(CellValue::Narrow('x'))
2703        );
2704
2705        let copy_dispatch = handle_editor_key_press(
2706            &mut editor,
2707            &mut canvas,
2708            AppKey {
2709                code: AppKeyCode::Char('c'),
2710                modifiers: AppModifiers {
2711                    ctrl: true,
2712                    ..Default::default()
2713                },
2714            },
2715            RgbColor::new(1, 2, 3),
2716        );
2717        assert!(copy_dispatch.handled);
2718        assert_eq!(
2719            editor.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2720            Some(CellValue::Narrow('x'))
2721        );
2722    }
2723
2724    #[test]
2725    fn handle_editor_action_move_extends_selection_when_requested() {
2726        let mut canvas = Canvas::with_size(6, 3);
2727        let mut editor = EditorSession {
2728            cursor: Pos { x: 1, y: 1 },
2729            ..Default::default()
2730        };
2731
2732        let dispatch = handle_editor_action(
2733            &mut editor,
2734            &mut canvas,
2735            EditorAction::Move {
2736                dir: MoveDir::Right,
2737                extend_selection: true,
2738            },
2739            RgbColor::new(0, 0, 0),
2740        );
2741
2742        assert!(dispatch.handled);
2743        assert!(dispatch.effects.is_empty());
2744        assert!(editor.mode.is_selecting());
2745        assert_eq!(editor.selection_anchor, Some(Pos { x: 1, y: 1 }));
2746        assert_eq!(editor.cursor, Pos { x: 2, y: 1 });
2747    }
2748
2749    #[test]
2750    fn handle_editor_action_move_clears_selection_when_not_extending() {
2751        let mut canvas = Canvas::with_size(6, 3);
2752        let mut editor = EditorSession {
2753            cursor: Pos { x: 2, y: 1 },
2754            selection_anchor: Some(Pos { x: 1, y: 1 }),
2755            mode: Mode::Select,
2756            ..Default::default()
2757        };
2758
2759        let dispatch = handle_editor_action(
2760            &mut editor,
2761            &mut canvas,
2762            EditorAction::Move {
2763                dir: MoveDir::Right,
2764                extend_selection: false,
2765            },
2766            RgbColor::new(0, 0, 0),
2767        );
2768
2769        assert!(dispatch.handled);
2770        assert!(editor.selection_anchor.is_none());
2771        assert!(!editor.mode.is_selecting());
2772        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2773    }
2774
2775    #[test]
2776    fn handle_editor_action_export_system_clipboard_emits_effect() {
2777        let mut canvas = Canvas::with_size(4, 2);
2778        canvas.set(Pos { x: 0, y: 0 }, 'A');
2779        let mut editor = EditorSession::default();
2780
2781        let dispatch = handle_editor_action(
2782            &mut editor,
2783            &mut canvas,
2784            EditorAction::ExportSystemClipboard,
2785            RgbColor::new(0, 0, 0),
2786        );
2787
2788        assert_eq!(
2789            dispatch,
2790            EditorKeyDispatch {
2791                handled: true,
2792                effects: vec![HostEffect::CopyToClipboard("A   \n    ".to_string())],
2793            }
2794        );
2795    }
2796
2797    #[test]
2798    fn handle_editor_action_insert_char_writes_cell() {
2799        let mut canvas = Canvas::with_size(4, 2);
2800        let mut editor = EditorSession {
2801            cursor: Pos { x: 1, y: 0 },
2802            ..Default::default()
2803        };
2804
2805        let dispatch = handle_editor_action(
2806            &mut editor,
2807            &mut canvas,
2808            EditorAction::InsertChar('Z'),
2809            RgbColor::new(9, 9, 9),
2810        );
2811
2812        assert!(dispatch.handled);
2813        assert_eq!(
2814            canvas.cell(Pos { x: 1, y: 0 }),
2815            Some(CellValue::Narrow('Z'))
2816        );
2817    }
2818
2819    #[test]
2820    fn handle_editor_action_transpose_reports_unhandled_without_anchor() {
2821        let mut canvas = Canvas::with_size(4, 2);
2822        let mut editor = EditorSession::default();
2823
2824        let dispatch = handle_editor_action(
2825            &mut editor,
2826            &mut canvas,
2827            EditorAction::TransposeSelectionCorner,
2828            RgbColor::new(0, 0, 0),
2829        );
2830
2831        assert!(!dispatch.handled);
2832    }
2833
2834    #[test]
2835    fn handle_editor_action_pan_shifts_viewport_origin() {
2836        let mut canvas = Canvas::with_size(40, 20);
2837        let mut editor = EditorSession::default();
2838        editor.set_viewport(
2839            Viewport {
2840                x: 0,
2841                y: 0,
2842                width: 10,
2843                height: 5,
2844            },
2845            &canvas,
2846        );
2847        editor.viewport_origin = Pos { x: 5, y: 5 };
2848        let origin_before = editor.viewport_origin;
2849
2850        let dispatch = handle_editor_action(
2851            &mut editor,
2852            &mut canvas,
2853            EditorAction::Pan { dx: 1, dy: -1 },
2854            RgbColor::new(0, 0, 0),
2855        );
2856
2857        assert!(dispatch.handled);
2858        assert_eq!(
2859            editor.viewport_origin,
2860            Pos {
2861                x: origin_before.x + 1,
2862                y: origin_before.y - 1,
2863            }
2864        );
2865    }
2866
2867    #[test]
2868    fn handle_editor_action_stroke_floating_stamps_current_and_destination() {
2869        let mut canvas = Canvas::with_size(6, 3);
2870        let mut editor = EditorSession {
2871            cursor: Pos { x: 2, y: 1 },
2872            floating: Some(FloatingSelection {
2873                clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]),
2874                transparent: false,
2875                source_index: None,
2876            }),
2877            ..Default::default()
2878        };
2879
2880        let dispatch = handle_editor_action(
2881            &mut editor,
2882            &mut canvas,
2883            EditorAction::StrokeFloating {
2884                dir: MoveDir::Right,
2885            },
2886            RgbColor::new(9, 8, 7),
2887        );
2888
2889        assert!(dispatch.handled);
2890        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
2891        assert_eq!(
2892            canvas.cell(Pos { x: 2, y: 1 }),
2893            Some(CellValue::Narrow('A'))
2894        );
2895        assert_eq!(
2896            canvas.cell(Pos { x: 3, y: 1 }),
2897            Some(CellValue::Narrow('A'))
2898        );
2899        assert!(editor.floating.is_some());
2900    }
2901
2902    fn pointer(col: u16, row: u16, kind: AppPointerKind) -> AppPointerEvent {
2903        AppPointerEvent {
2904            column: col,
2905            row,
2906            kind,
2907            modifiers: AppModifiers::default(),
2908        }
2909    }
2910
2911    fn viewport_editor(canvas: &Canvas) -> EditorSession {
2912        let mut editor = EditorSession::default();
2913        editor.set_viewport(
2914            Viewport {
2915                x: 0,
2916                y: 0,
2917                width: canvas.width as u16,
2918                height: canvas.height as u16,
2919            },
2920            canvas,
2921        );
2922        editor
2923    }
2924
2925    #[test]
2926    fn pointer_left_down_outside_viewport_passes_through() {
2927        let mut canvas = Canvas::with_size(4, 2);
2928        let mut editor = viewport_editor(&canvas);
2929
2930        let dispatch = handle_editor_pointer(
2931            &mut editor,
2932            &mut canvas,
2933            pointer(99, 99, AppPointerKind::Down(AppPointerButton::Left)),
2934            RgbColor::new(0, 0, 0),
2935        );
2936
2937        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
2938        assert_eq!(dispatch.stroke_hint, None);
2939        assert!(editor.drag_origin.is_none());
2940    }
2941
2942    #[test]
2943    fn pointer_non_floating_left_down_arms_selection_drag() {
2944        let mut canvas = Canvas::with_size(8, 4);
2945        let mut editor = viewport_editor(&canvas);
2946
2947        let dispatch = handle_editor_pointer(
2948            &mut editor,
2949            &mut canvas,
2950            pointer(3, 2, AppPointerKind::Down(AppPointerButton::Left)),
2951            RgbColor::new(0, 0, 0),
2952        );
2953
2954        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2955        assert_eq!(dispatch.stroke_hint, None);
2956        assert_eq!(editor.cursor, Pos { x: 3, y: 2 });
2957        assert_eq!(editor.drag_origin, Some(Pos { x: 3, y: 2 }));
2958    }
2959
2960    #[test]
2961    fn pointer_non_floating_right_down_begins_pan_inside_viewport() {
2962        let mut canvas = Canvas::with_size(8, 4);
2963        let mut editor = viewport_editor(&canvas);
2964
2965        let dispatch = handle_editor_pointer(
2966            &mut editor,
2967            &mut canvas,
2968            pointer(2, 1, AppPointerKind::Down(AppPointerButton::Right)),
2969            RgbColor::new(0, 0, 0),
2970        );
2971
2972        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
2973        assert!(editor.pan_drag.is_some());
2974    }
2975
2976    #[test]
2977    fn pointer_right_down_outside_viewport_passes_through() {
2978        let mut canvas = Canvas::with_size(4, 2);
2979        let mut editor = viewport_editor(&canvas);
2980
2981        let dispatch = handle_editor_pointer(
2982            &mut editor,
2983            &mut canvas,
2984            pointer(99, 99, AppPointerKind::Down(AppPointerButton::Right)),
2985            RgbColor::new(0, 0, 0),
2986        );
2987
2988        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
2989        assert!(editor.pan_drag.is_none());
2990    }
2991
2992    #[test]
2993    fn pointer_scroll_event_pans_viewport_inside_viewport() {
2994        let mut canvas = Canvas::with_size(20, 12);
2995        let mut editor = viewport_editor(&canvas);
2996        editor.set_viewport(
2997            Viewport {
2998                x: 2,
2999                y: 3,
3000                width: 8,
3001                height: 4,
3002            },
3003            &canvas,
3004        );
3005        editor.viewport_origin = Pos { x: 5, y: 5 };
3006
3007        let dispatch = handle_editor_pointer(
3008            &mut editor,
3009            &mut canvas,
3010            pointer(4, 5, AppPointerKind::ScrollUp),
3011            RgbColor::new(0, 0, 0),
3012        );
3013
3014        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3015        assert_eq!(editor.viewport_origin, Pos { x: 5, y: 4 });
3016    }
3017
3018    #[test]
3019    fn pointer_horizontal_scroll_event_pans_viewport_inside_viewport() {
3020        let mut canvas = Canvas::with_size(20, 12);
3021        let mut editor = viewport_editor(&canvas);
3022        editor.set_viewport(
3023            Viewport {
3024                x: 2,
3025                y: 3,
3026                width: 8,
3027                height: 4,
3028            },
3029            &canvas,
3030        );
3031        editor.viewport_origin = Pos { x: 5, y: 5 };
3032
3033        let dispatch = handle_editor_pointer(
3034            &mut editor,
3035            &mut canvas,
3036            pointer(4, 5, AppPointerKind::ScrollRight),
3037            RgbColor::new(0, 0, 0),
3038        );
3039
3040        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3041        assert_eq!(editor.viewport_origin, Pos { x: 6, y: 5 });
3042    }
3043
3044    #[test]
3045    fn pointer_scroll_event_outside_viewport_passes_through() {
3046        let mut canvas = Canvas::with_size(20, 12);
3047        let mut editor = viewport_editor(&canvas);
3048        editor.set_viewport(
3049            Viewport {
3050                x: 2,
3051                y: 3,
3052                width: 8,
3053                height: 4,
3054            },
3055            &canvas,
3056        );
3057        editor.viewport_origin = Pos { x: 5, y: 5 };
3058
3059        let dispatch = handle_editor_pointer(
3060            &mut editor,
3061            &mut canvas,
3062            pointer(0, 0, AppPointerKind::ScrollUp),
3063            RgbColor::new(0, 0, 0),
3064        );
3065
3066        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
3067        assert_eq!(editor.viewport_origin, Pos { x: 5, y: 5 });
3068    }
3069
3070    #[test]
3071    fn pointer_moved_without_floating_does_not_move_caret() {
3072        // Default host policy: passive hover over the canvas must not drag
3073        // the caret around when no floating preview is armed.
3074        let mut canvas = Canvas::with_size(8, 4);
3075        let mut editor = viewport_editor(&canvas);
3076        let initial_cursor = editor.cursor;
3077
3078        let dispatch = handle_editor_pointer(
3079            &mut editor,
3080            &mut canvas,
3081            pointer(3, 2, AppPointerKind::Moved),
3082            RgbColor::new(0, 0, 0),
3083        );
3084
3085        assert_eq!(dispatch.outcome, PointerOutcome::Passthrough);
3086        assert_eq!(editor.cursor, initial_cursor);
3087    }
3088
3089    #[test]
3090    fn pointer_floating_hover_tracks_cursor_by_default() {
3091        let mut canvas = Canvas::with_size(8, 4);
3092        let mut editor = viewport_editor(&canvas);
3093        editor.floating = Some(FloatingSelection {
3094            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3095            transparent: false,
3096            source_index: None,
3097        });
3098
3099        let dispatch = handle_editor_pointer(
3100            &mut editor,
3101            &mut canvas,
3102            pointer(4, 2, AppPointerKind::Moved),
3103            RgbColor::new(0, 0, 0),
3104        );
3105
3106        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3107        assert_eq!(editor.cursor, Pos { x: 4, y: 2 });
3108    }
3109
3110    #[test]
3111    fn pointer_non_floating_left_drag_establishes_selection() {
3112        let mut canvas = Canvas::with_size(8, 4);
3113        let mut editor = viewport_editor(&canvas);
3114
3115        handle_editor_pointer(
3116            &mut editor,
3117            &mut canvas,
3118            pointer(2, 1, AppPointerKind::Down(AppPointerButton::Left)),
3119            RgbColor::new(0, 0, 0),
3120        );
3121        handle_editor_pointer(
3122            &mut editor,
3123            &mut canvas,
3124            pointer(5, 2, AppPointerKind::Drag(AppPointerButton::Left)),
3125            RgbColor::new(0, 0, 0),
3126        );
3127
3128        assert_eq!(editor.selection_anchor, Some(Pos { x: 2, y: 1 }));
3129        assert_eq!(editor.cursor, Pos { x: 5, y: 2 });
3130        assert!(editor.mode.is_selecting());
3131    }
3132
3133    #[test]
3134    fn pointer_floating_left_down_begins_stroke_and_paints() {
3135        let mut canvas = Canvas::with_size(8, 4);
3136        let mut editor = viewport_editor(&canvas);
3137        editor.floating = Some(FloatingSelection {
3138            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3139            transparent: false,
3140            source_index: None,
3141        });
3142
3143        let dispatch = handle_editor_pointer(
3144            &mut editor,
3145            &mut canvas,
3146            pointer(3, 1, AppPointerKind::Down(AppPointerButton::Left)),
3147            RgbColor::new(1, 2, 3),
3148        );
3149
3150        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3151        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::Begin));
3152        assert_eq!(editor.cursor, Pos { x: 3, y: 1 });
3153        assert!(editor.paint_stroke_anchor.is_some());
3154        assert_eq!(
3155            canvas.cell(Pos { x: 3, y: 1 }),
3156            Some(CellValue::Narrow('x'))
3157        );
3158    }
3159
3160    #[test]
3161    fn pointer_floating_left_up_ends_stroke() {
3162        let mut canvas = Canvas::with_size(8, 4);
3163        let mut editor = viewport_editor(&canvas);
3164        editor.floating = Some(FloatingSelection {
3165            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3166            transparent: false,
3167            source_index: None,
3168        });
3169        editor.paint_stroke_anchor = Some(Pos { x: 0, y: 0 });
3170
3171        let dispatch = handle_editor_pointer(
3172            &mut editor,
3173            &mut canvas,
3174            pointer(3, 1, AppPointerKind::Up(AppPointerButton::Left)),
3175            RgbColor::new(0, 0, 0),
3176        );
3177
3178        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3179        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::End));
3180        assert!(editor.paint_stroke_anchor.is_none());
3181    }
3182
3183    #[test]
3184    fn pointer_floating_right_down_dismisses_and_ends_stroke() {
3185        let mut canvas = Canvas::with_size(8, 4);
3186        let mut editor = viewport_editor(&canvas);
3187        editor.floating = Some(FloatingSelection {
3188            clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('x'))]),
3189            transparent: false,
3190            source_index: None,
3191        });
3192
3193        let dispatch = handle_editor_pointer(
3194            &mut editor,
3195            &mut canvas,
3196            pointer(3, 1, AppPointerKind::Down(AppPointerButton::Right)),
3197            RgbColor::new(0, 0, 0),
3198        );
3199
3200        assert_eq!(dispatch.outcome, PointerOutcome::Consumed);
3201        assert_eq!(dispatch.stroke_hint, Some(PointerStrokeHint::End));
3202        assert!(editor.floating.is_none());
3203    }
3204}