1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1532pub enum PointerOutcome {
1533 Consumed,
1535 #[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
1574pub 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 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(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 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}