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