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