Skip to main content

dartboard_editor/
lib.rs

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