1use std::io;
2
3use crossterm::event::Event;
4#[cfg(test)]
5use crossterm::event::KeyEvent;
6use crossterm::{clipboard::CopyToClipboard, execute};
7use ratatui::layout::Rect;
8
9use dartboard_client_ws::WebsocketClient;
10#[cfg(test)]
11use dartboard_core::UserId;
12use dartboard_core::{Canvas, CanvasOp, Client, ClientOpId, Pos, RgbColor, ServerMsg};
13#[cfg(test)]
14use dartboard_editor::{
15 backspace as editor_backspace, copy_selection_or_cell as editor_copy_selection_or_cell,
16 cut_selection_or_cell as editor_cut_selection_or_cell,
17 draw_selection_border as editor_draw_selection_border,
18 export_system_clipboard_text as editor_export_system_clipboard_text,
19 fill_selection_or_cell as editor_fill_selection_or_cell,
20 paste_primary_swatch as editor_paste_primary_swatch, smart_fill as editor_smart_fill,
21};
22use dartboard_editor::{
23 diff_canvas_op as editor_diff_canvas_op, dismiss_floating as editor_dismiss_floating,
24 end_paint_stroke as editor_end_paint_stroke, handle_editor_action as editor_handle_action,
25 handle_editor_pointer as editor_handle_pointer, insert_char as editor_insert_char,
26 paste_text_block as editor_paste_text_block, stamp_floating as editor_stamp_floating,
27 MirrorEvent, PointerStrokeHint, SessionMirror,
28};
29pub use dartboard_editor::{
30 Clipboard, ConnectState, EditorAction, EditorContext, EditorPointerDispatch, EditorSession,
31 FloatingSelection, HostEffect, KeyMap, Mode, MoveDir, PanDrag, Selection, SelectionShape,
32 Swatch, SwatchActivation, Viewport, SWATCH_CAPACITY,
33};
34use dartboard_picker_core::adjust_scroll_offset;
35use dartboard_server::{Hello, InMemStore, LocalClient, ServerHandle};
36
37use crate::emoji;
38use crate::input::app_intent_from_crossterm;
39#[cfg(test)]
40use crate::input::app_key_from_crossterm;
41pub use crate::input::{
42 AppIntent, AppKey, AppKeyCode, AppModifiers, AppPointerButton, AppPointerEvent, AppPointerKind,
43};
44use crate::theme;
45
46const UNDO_DEPTH_CAP: usize = 500;
47
48pub enum Transport {
52 Embedded {
53 server: ServerHandle,
54 clients: Vec<ClientBox>,
55 },
56 Remote {
57 client: ClientBox,
58 mirror: SessionMirror,
59 },
60}
61
62pub enum ClientBox {
64 Local(LocalClient),
65 Ws(WebsocketClient),
66}
67
68impl Client for ClientBox {
69 fn submit_op(&mut self, op: CanvasOp) -> ClientOpId {
70 match self {
71 Self::Local(c) => c.submit_op(op),
72 Self::Ws(c) => c.submit_op(op),
73 }
74 }
75 fn try_recv(&mut self) -> Option<ServerMsg> {
76 match self {
77 Self::Local(c) => c.try_recv(),
78 Self::Ws(c) => c.try_recv(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum SwatchZone {
85 Body,
86 Pin,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum HelpTab {
91 #[default]
92 Guide,
93 Drawing,
94 Selection,
95 Clipboard,
96 Transform,
97 Session,
98}
99
100impl HelpTab {
101 pub const ALL: [HelpTab; 6] = [
102 HelpTab::Guide,
103 HelpTab::Drawing,
104 HelpTab::Selection,
105 HelpTab::Clipboard,
106 HelpTab::Transform,
107 HelpTab::Session,
108 ];
109
110 pub fn label(self) -> &'static str {
111 match self {
112 HelpTab::Guide => "guide",
113 HelpTab::Drawing => "drawing",
114 HelpTab::Selection => "selection",
115 HelpTab::Clipboard => "clipboard",
116 HelpTab::Transform => "transform",
117 HelpTab::Session => "session",
118 }
119 }
120
121 fn index(self) -> usize {
122 Self::ALL.iter().position(|t| *t == self).unwrap_or(0)
123 }
124
125 pub fn next(self) -> Self {
126 let i = (self.index() + 1) % Self::ALL.len();
127 Self::ALL[i]
128 }
129
130 pub fn prev(self) -> Self {
131 let n = Self::ALL.len();
132 let i = (self.index() + n - 1) % n;
133 Self::ALL[i]
134 }
135}
136
137#[derive(Debug, Clone, Default)]
138struct UserSession {
139 editor: EditorSession,
140 show_help: bool,
141 help_tab: HelpTab,
142 emoji_picker_open: bool,
143 emoji_picker_state: emoji::EmojiPickerState,
144 paint_canvas_before: Option<Canvas>,
145}
146
147#[derive(Debug, Clone)]
148pub struct LocalUser {
149 pub name: String,
150 pub color: RgbColor,
151 session: UserSession,
152}
153
154pub struct App {
155 pub canvas: Canvas,
156 pub cursor: Pos,
157 pub mode: Mode,
158 pub should_quit: bool,
159 pub show_help: bool,
160 pub help_tab: HelpTab,
161 pub emoji_picker_open: bool,
162 pub viewport: Rect,
163 pub viewport_origin: Pos,
164 pub selection_anchor: Option<Pos>,
165 selection_shape: SelectionShape,
166 drag_origin: Option<Pos>,
167 pan_drag: Option<PanDrag>,
168 pub swatches: [Option<Swatch>; SWATCH_CAPACITY],
169 pub floating: Option<FloatingSelection>,
170 pub emoji_picker_state: emoji::EmojiPickerState,
171 pub icon_catalog: Option<emoji::catalog::IconCatalogData>,
172 pub swatch_body_hits: [Option<Rect>; SWATCH_CAPACITY],
173 pub swatch_pin_hits: [Option<Rect>; SWATCH_CAPACITY],
174 pub help_tab_hits: Vec<(HelpTab, Rect)>,
175 pub help_scroll: u16,
176 paint_canvas_before: Option<Canvas>,
177 paint_stroke_anchor: Option<Pos>,
178 paint_stroke_last: Option<Pos>,
179 undo_stack: Vec<Canvas>,
180 redo_stack: Vec<Canvas>,
181 users: Vec<LocalUser>,
182 active_user_idx: usize,
183 transport: Transport,
184}
185
186impl Default for App {
187 fn default() -> Self {
188 Self::new()
189 }
190}
191
192impl App {
193 fn viewport_to_editor(viewport: Rect) -> Viewport {
194 Viewport {
195 x: viewport.x,
196 y: viewport.y,
197 width: viewport.width,
198 height: viewport.height,
199 }
200 }
201
202 fn viewport_from_editor(viewport: Viewport) -> Rect {
203 Rect::new(viewport.x, viewport.y, viewport.width, viewport.height)
204 }
205
206 fn editor_session_snapshot(&self) -> EditorSession {
207 EditorSession {
208 cursor: self.cursor,
209 mode: self.mode,
210 viewport: Self::viewport_to_editor(self.viewport),
211 viewport_origin: self.viewport_origin,
212 selection_anchor: self.selection_anchor,
213 selection_shape: self.selection_shape,
214 drag_origin: self.drag_origin,
215 pan_drag: self.pan_drag,
216 swatches: self.swatches.clone(),
217 floating: self.floating.clone(),
218 paint_stroke_anchor: self.paint_stroke_anchor,
219 paint_stroke_last: self.paint_stroke_last,
220 }
221 }
222
223 fn load_editor_session(&mut self, editor: EditorSession) {
224 self.cursor = editor.cursor;
225 self.mode = editor.mode;
226 self.viewport = Self::viewport_from_editor(editor.viewport);
227 self.viewport_origin = editor.viewport_origin;
228 self.selection_anchor = editor.selection_anchor;
229 self.selection_shape = editor.selection_shape;
230 self.drag_origin = editor.drag_origin;
231 self.pan_drag = editor.pan_drag;
232 self.swatches = editor.swatches;
233 self.floating = editor.floating;
234 self.paint_stroke_anchor = editor.paint_stroke_anchor;
235 self.paint_stroke_last = editor.paint_stroke_last;
236 }
237
238 fn take_editor_session(&mut self) -> EditorSession {
239 EditorSession {
240 cursor: self.cursor,
241 mode: self.mode,
242 viewport: Self::viewport_to_editor(self.viewport),
243 viewport_origin: self.viewport_origin,
244 selection_anchor: self.selection_anchor,
245 selection_shape: self.selection_shape,
246 drag_origin: self.drag_origin,
247 pan_drag: self.pan_drag,
248 swatches: std::mem::take(&mut self.swatches),
249 floating: self.floating.take(),
250 paint_stroke_anchor: self.paint_stroke_anchor,
251 paint_stroke_last: self.paint_stroke_last,
252 }
253 }
254
255 fn with_editor_session_mut<R>(
256 &mut self,
257 f: impl FnOnce(&mut EditorSession, &Canvas) -> R,
258 ) -> R {
259 let mut editor = self.take_editor_session();
260 let result = f(&mut editor, &self.canvas);
261 self.load_editor_session(editor);
262 result
263 }
264
265 fn with_editor_and_canvas_mut<R>(
266 &mut self,
267 f: impl FnOnce(&mut EditorSession, &mut Canvas) -> R,
268 ) -> R {
269 let mut editor = self.take_editor_session();
270 let result = f(&mut editor, &mut self.canvas);
271 self.load_editor_session(editor);
272 result
273 }
274
275 pub fn new() -> Self {
276 let default_session = UserSession::default();
277 let users: Vec<LocalUser> = theme::PLAYER_PALETTE
278 .iter()
279 .zip(theme::PLAYER_COLOR_NAMES.iter())
280 .map(|(color, name)| LocalUser {
281 name: (*name).to_string(),
282 color: *color,
283 session: default_session.clone(),
284 })
285 .collect();
286
287 let server = ServerHandle::spawn_local(InMemStore);
288 let mut clients: Vec<ClientBox> = users
289 .iter()
290 .map(|u| {
291 ClientBox::Local(server.connect_local(Hello {
292 name: u.name.clone(),
293 color: u.color,
294 }))
295 })
296 .collect();
297 for client in &mut clients {
298 while client.try_recv().is_some() {}
299 }
300
301 let current_session = default_session;
302 Self {
303 canvas: Canvas::new(),
304 cursor: current_session.editor.cursor,
305 mode: current_session.editor.mode,
306 should_quit: false,
307 show_help: current_session.show_help,
308 help_tab: current_session.help_tab,
309 emoji_picker_open: current_session.emoji_picker_open,
310 viewport: Self::viewport_from_editor(current_session.editor.viewport),
311 viewport_origin: current_session.editor.viewport_origin,
312 selection_anchor: current_session.editor.selection_anchor,
313 selection_shape: current_session.editor.selection_shape,
314 drag_origin: current_session.editor.drag_origin,
315 pan_drag: current_session.editor.pan_drag,
316 swatches: current_session.editor.swatches,
317 floating: current_session.editor.floating,
318 emoji_picker_state: current_session.emoji_picker_state,
319 icon_catalog: None,
320 swatch_body_hits: [None; SWATCH_CAPACITY],
321 swatch_pin_hits: [None; SWATCH_CAPACITY],
322 help_tab_hits: Vec::new(),
323 help_scroll: 0,
324 paint_canvas_before: current_session.paint_canvas_before,
325 paint_stroke_anchor: current_session.editor.paint_stroke_anchor,
326 paint_stroke_last: current_session.editor.paint_stroke_last,
327 undo_stack: Vec::new(),
328 redo_stack: Vec::new(),
329 users,
330 active_user_idx: 0,
331 transport: Transport::Embedded { server, clients },
332 }
333 }
334
335 pub fn new_remote(client: WebsocketClient, name: String, color: RgbColor) -> Self {
344 let default_session = UserSession::default();
345 let users = vec![LocalUser {
346 name,
347 color,
348 session: default_session.clone(),
349 }];
350 let current_session = default_session;
351 let mut app = Self {
352 canvas: Canvas::new(),
353 cursor: current_session.editor.cursor,
354 mode: current_session.editor.mode,
355 should_quit: false,
356 show_help: current_session.show_help,
357 help_tab: current_session.help_tab,
358 emoji_picker_open: current_session.emoji_picker_open,
359 viewport: Self::viewport_from_editor(current_session.editor.viewport),
360 viewport_origin: current_session.editor.viewport_origin,
361 selection_anchor: current_session.editor.selection_anchor,
362 selection_shape: current_session.editor.selection_shape,
363 drag_origin: current_session.editor.drag_origin,
364 pan_drag: current_session.editor.pan_drag,
365 swatches: current_session.editor.swatches,
366 floating: current_session.editor.floating,
367 emoji_picker_state: current_session.emoji_picker_state,
368 icon_catalog: None,
369 swatch_body_hits: [None; SWATCH_CAPACITY],
370 swatch_pin_hits: [None; SWATCH_CAPACITY],
371 help_tab_hits: Vec::new(),
372 help_scroll: 0,
373 paint_canvas_before: current_session.paint_canvas_before,
374 paint_stroke_anchor: current_session.editor.paint_stroke_anchor,
375 paint_stroke_last: current_session.editor.paint_stroke_last,
376 undo_stack: Vec::new(),
377 redo_stack: Vec::new(),
378 users,
379 active_user_idx: 0,
380 transport: Transport::Remote {
381 client: ClientBox::Ws(client),
382 mirror: SessionMirror::new(),
383 },
384 };
385 let start = std::time::Instant::now();
386 let timeout = std::time::Duration::from_secs(3);
387 loop {
388 app.drain_server_events();
389 if let Transport::Remote { mirror, .. } = &app.transport {
390 if mirror.my_user_id.is_some() {
391 break;
392 }
393 }
394 if start.elapsed() >= timeout {
395 break;
396 }
397 std::thread::sleep(std::time::Duration::from_millis(10));
398 }
399 app
400 }
401
402 fn current_session(&self) -> UserSession {
403 UserSession {
404 editor: self.editor_session_snapshot(),
405 show_help: self.show_help,
406 help_tab: self.help_tab,
407 emoji_picker_open: self.emoji_picker_open,
408 emoji_picker_state: self.emoji_picker_state.clone(),
409 paint_canvas_before: self.paint_canvas_before.clone(),
410 }
411 }
412
413 fn load_session(&mut self, session: UserSession) {
414 self.load_editor_session(session.editor);
415 self.show_help = session.show_help;
416 self.help_tab = session.help_tab;
417 self.emoji_picker_open = session.emoji_picker_open;
418 self.emoji_picker_state = session.emoji_picker_state;
419 self.paint_canvas_before = session.paint_canvas_before;
420 self.swatch_body_hits = [None; SWATCH_CAPACITY];
421 self.swatch_pin_hits = [None; SWATCH_CAPACITY];
422 }
423
424 pub(crate) fn sync_active_user_slot(&mut self) {
425 let session = self.current_session();
426 if let Some(user) = self.users.get_mut(self.active_user_idx) {
427 user.session = session;
428 }
429 }
430
431 fn switch_active_user(&mut self, delta: isize) {
432 if self.users.is_empty() {
433 return;
434 }
435 if matches!(self.transport, Transport::Remote { .. }) {
438 return;
439 }
440
441 self.sync_active_user_slot();
442 let len = self.users.len() as isize;
443 self.active_user_idx = (self.active_user_idx as isize + delta).rem_euclid(len) as usize;
444 let next_session = self.users[self.active_user_idx].session.clone();
445 self.load_session(next_session);
446 self.clamp_cursor();
447 }
448
449 pub fn users(&self) -> &[LocalUser] {
450 &self.users
451 }
452
453 pub fn active_user_index(&self) -> usize {
454 self.active_user_idx
455 }
456
457 pub fn active_user_color(&self) -> RgbColor {
458 self.users[self.active_user_idx].color
459 }
460
461 pub fn is_embedded(&self) -> bool {
462 matches!(self.transport, Transport::Embedded { .. })
463 }
464
465 #[cfg(test)]
466 fn server_snapshot_for_test(&self) -> Canvas {
467 match &self.transport {
468 Transport::Embedded { server, .. } => server.canvas_snapshot(),
469 Transport::Remote { .. } => self.canvas.clone(),
470 }
471 }
472
473 #[cfg(test)]
474 fn client_user_ids_for_test(&self) -> Vec<UserId> {
475 match &self.transport {
476 Transport::Embedded { clients, .. } => clients
477 .iter()
478 .filter_map(|c| match c {
479 ClientBox::Local(c) => Some(c.user_id()),
480 ClientBox::Ws(_) => None,
481 })
482 .collect(),
483 Transport::Remote { .. } => Vec::new(),
484 }
485 }
486
487 #[cfg(test)]
488 fn apply_canvas_edit(&mut self, edit: impl FnOnce(&mut Canvas)) {
489 let before = self.canvas.clone();
490 edit(&mut self.canvas);
491 self.finish_canvas_edit(before);
492 }
493
494 fn finish_canvas_edit(&mut self, before: Canvas) {
495 if self.canvas != before {
496 let op = diff_canvas_op(&before, &self.canvas);
497 self.undo_stack.push(before);
498 if self.undo_stack.len() > UNDO_DEPTH_CAP {
499 self.undo_stack.remove(0);
500 }
501 self.redo_stack.clear();
502 if let Some(op) = op {
503 self.submit_via_active(op);
504 }
505 }
506 }
507
508 fn submit_via_active(&mut self, op: CanvasOp) {
509 match &mut self.transport {
510 Transport::Embedded { clients, .. } => {
511 if let Some(c) = clients.get_mut(self.active_user_idx) {
512 c.submit_op(op);
513 }
514 }
515 Transport::Remote { client, .. } => {
516 client.submit_op(op);
517 }
518 }
519 }
520
521 pub fn peer_count(&self) -> usize {
524 match &self.transport {
525 Transport::Embedded { server, .. } => server.peer_count(),
526 Transport::Remote { mirror, .. } => mirror.peers.len() + 1,
527 }
528 }
529
530 fn undo_enabled(&self) -> bool {
536 match &self.transport {
537 Transport::Embedded { .. } => true,
538 Transport::Remote { mirror, .. } => mirror.peers.is_empty(),
539 }
540 }
541
542 fn drain_server_events(&mut self) {
543 match &mut self.transport {
544 Transport::Embedded { clients, .. } => {
545 for client in clients.iter_mut() {
546 while let Some(msg) = client.try_recv() {
547 if let ServerMsg::OpBroadcast { op, .. } = msg {
548 self.canvas.apply(&op);
549 }
550 }
551 }
552 }
553 Transport::Remote { client, mirror } => {
554 while let Some(msg) = client.try_recv() {
555 let Some(event) = mirror.apply(msg) else {
556 continue;
557 };
558 match event {
559 MirrorEvent::Welcomed {
560 my_color,
561 peers,
562 snapshot,
563 ..
564 } => {
565 self.canvas = snapshot;
566 self.users.truncate(1);
567 self.users[0].color = my_color;
568 for p in peers {
569 self.users.push(LocalUser {
570 name: p.name,
571 color: p.color,
572 session: UserSession::default(),
573 });
574 }
575 }
576 MirrorEvent::RemoteOp { op, .. } => {
577 self.canvas.apply(&op);
578 }
579 MirrorEvent::PeerJoined(peer) => {
580 self.users.push(LocalUser {
581 name: peer.name,
582 color: peer.color,
583 session: UserSession::default(),
584 });
585 }
586 MirrorEvent::PeerLeft { index, .. } => {
587 let user_idx = index + 1;
589 if user_idx < self.users.len() {
590 self.users.remove(user_idx);
591 }
592 }
593 MirrorEvent::ConnectRejected { .. } => {}
594 }
595 }
596 }
597 }
598 }
599
600 fn undo(&mut self) {
601 if !self.undo_enabled() {
602 return;
603 }
604 let Some(previous) = self.undo_stack.pop() else {
605 return;
606 };
607 let current = std::mem::replace(&mut self.canvas, previous);
608 let op = diff_canvas_op(¤t, &self.canvas);
609 self.redo_stack.push(current);
610 if let Some(op) = op {
611 self.submit_via_active(op);
612 }
613 }
614
615 fn redo(&mut self) {
616 if !self.undo_enabled() {
617 return;
618 }
619 let Some(next) = self.redo_stack.pop() else {
620 return;
621 };
622 let current = std::mem::replace(&mut self.canvas, next);
623 let op = diff_canvas_op(¤t, &self.canvas);
624 self.undo_stack.push(current);
625 if let Some(op) = op {
626 self.submit_via_active(op);
627 }
628 }
629
630 fn move_left(&mut self) {
631 self.with_editor_session_mut(|editor, canvas| editor.move_left(canvas));
632 }
633
634 fn move_right(&mut self) {
635 self.with_editor_session_mut(|editor, canvas| editor.move_right(canvas));
636 }
637
638 fn move_up(&mut self) {
639 self.with_editor_session_mut(|editor, canvas| editor.move_up(canvas));
640 }
641
642 fn move_down(&mut self) {
643 self.with_editor_session_mut(|editor, canvas| editor.move_down(canvas));
644 }
645
646 #[cfg(test)]
647 fn mouse_to_canvas(&self, col: u16, row: u16) -> Option<Pos> {
648 self.editor_session_snapshot()
649 .canvas_pos_for_pointer(col, row, &self.canvas)
650 }
651
652 fn swatch_hit(&self, col: u16, row: u16) -> Option<(usize, SwatchZone)> {
653 for (idx, maybe_rect) in self.swatch_pin_hits.iter().enumerate() {
654 let Some(rect) = maybe_rect else { continue };
655 if rect_contains(rect, col, row) {
656 return Some((idx, SwatchZone::Pin));
657 }
658 }
659 for (idx, maybe_rect) in self.swatch_body_hits.iter().enumerate() {
660 let Some(rect) = maybe_rect else { continue };
661 if rect_contains(rect, col, row) {
662 return Some((idx, SwatchZone::Body));
663 }
664 }
665 None
666 }
667
668 fn help_tab_hit(&self, col: u16, row: u16) -> Option<HelpTab> {
669 self.help_tab_hits
670 .iter()
671 .find(|(_, rect)| rect_contains(rect, col, row))
672 .map(|(tab, _)| *tab)
673 }
674
675 pub fn set_viewport(&mut self, viewport: Rect) {
676 let viewport = Self::viewport_to_editor(viewport);
677 self.with_editor_session_mut(|editor, canvas| editor.set_viewport(viewport, canvas));
678 }
679
680 #[cfg(test)]
681 fn pan_by(&mut self, dx: isize, dy: isize) {
682 self.with_editor_session_mut(|editor, canvas| editor.pan_by(canvas, dx, dy));
683 }
684
685 fn clamp_cursor(&mut self) {
686 self.with_editor_session_mut(|editor, canvas| editor.clamp_cursor(canvas));
687 }
688
689 #[cfg(test)]
690 fn clear_selection(&mut self) {
691 self.with_editor_session_mut(|editor, _| editor.clear_selection());
692 }
693
694 pub fn selection(&self) -> Option<Selection> {
695 self.editor_session_snapshot().selection()
696 }
697
698 #[cfg(test)]
699 fn copy_selection_or_cell(&mut self) {
700 self.with_editor_session_mut(|editor, canvas| {
701 let _ = editor_copy_selection_or_cell(editor, canvas);
702 });
703 }
704
705 #[cfg(test)]
706 fn export_system_clipboard_text(&self) -> String {
707 editor_export_system_clipboard_text(&self.editor_session_snapshot(), &self.canvas)
708 }
709
710 #[cfg(test)]
711 fn cut_selection_or_cell(&mut self) {
712 let color = self.active_user_color();
713 let before = self.canvas.clone();
714 let changed = self.with_editor_and_canvas_mut(|editor, canvas| {
715 editor_cut_selection_or_cell(editor, canvas, color)
716 });
717 if changed {
718 self.finish_canvas_edit(before);
719 }
720 }
721
722 #[cfg(test)]
723 fn populated_swatch_count(&self) -> usize {
724 self.swatches.iter().filter(|s| s.is_some()).count()
725 }
726
727 pub fn toggle_pin(&mut self, idx: usize) {
728 self.with_editor_session_mut(|editor, _| editor.toggle_pin(idx));
729 }
730
731 pub fn clear_swatch(&mut self, idx: usize) {
732 self.with_editor_session_mut(|editor, _| editor.clear_swatch(idx));
733 }
734
735 pub fn activate_swatch(&mut self, idx: usize) {
736 let activation = self.with_editor_session_mut(|editor, _| editor.activate_swatch(idx));
737 if activation == SwatchActivation::ActivatedFloating {
738 self.end_paint_stroke();
739 }
740 }
741
742 fn stamp_floating(&mut self) {
743 let color = self.active_user_color();
744 let before = self.canvas.clone();
745 let changed = self.with_editor_and_canvas_mut(|editor, canvas| {
746 editor_stamp_floating(editor, canvas, color)
747 });
748 if changed {
749 self.finish_canvas_edit(before);
750 }
751 }
752
753 fn end_paint_stroke(&mut self) {
754 if let Some(before) = self.paint_canvas_before.take() {
755 if self.canvas != before {
756 self.undo_stack.push(before);
757 if self.undo_stack.len() > UNDO_DEPTH_CAP {
758 self.undo_stack.remove(0);
759 }
760 self.redo_stack.clear();
761 }
762 }
763 self.with_editor_session_mut(|editor, _| editor_end_paint_stroke(editor));
764 }
765
766 fn dismiss_floating(&mut self) {
767 self.end_paint_stroke();
768 self.with_editor_session_mut(|editor, _| editor_dismiss_floating(editor));
769 }
770
771 #[cfg(test)]
772 fn paste_clipboard(&mut self) {
773 let color = self.active_user_color();
774 let before = self.canvas.clone();
775 let changed = self.with_editor_and_canvas_mut(|editor, canvas| {
776 editor_paste_primary_swatch(editor, canvas, color)
777 });
778 if changed {
779 self.finish_canvas_edit(before);
780 }
781 }
782
783 #[cfg(test)]
784 fn smart_fill(&mut self) {
785 let color = self.active_user_color();
786 let editor = self.editor_session_snapshot();
787 self.apply_canvas_edit(|canvas| editor_smart_fill(&editor, canvas, color));
788 }
789
790 #[cfg(test)]
791 fn draw_border(&mut self) {
792 let color = self.active_user_color();
793 let before = self.canvas.clone();
794 let changed = self.with_editor_and_canvas_mut(|editor, canvas| {
795 editor_draw_selection_border(editor, canvas, color)
796 });
797 if changed {
798 self.finish_canvas_edit(before);
799 }
800 }
801
802 #[cfg(test)]
803 fn fill_selection_or_cell(&mut self, ch: char) {
804 let color = self.active_user_color();
805 let editor = self.editor_session_snapshot();
806 self.apply_canvas_edit(|canvas| editor_fill_selection_or_cell(&editor, canvas, ch, color));
807 }
808
809 fn insert_char(&mut self, ch: char) {
810 let color = self.active_user_color();
811 let before = self.canvas.clone();
812 let _ = self.with_editor_and_canvas_mut(|editor, canvas| {
813 editor_insert_char(editor, canvas, ch, color)
814 });
815 self.finish_canvas_edit(before);
816 }
817
818 fn open_emoji_picker(&mut self) {
819 if self.icon_catalog.is_none() {
820 self.icon_catalog = Some(emoji::catalog::load_catalog());
821 }
822 self.emoji_picker_state = emoji::EmojiPickerState::default();
823 self.emoji_picker_open = true;
824 }
825
826 fn picker_selectable_count(&self) -> usize {
827 let Some(catalog) = self.icon_catalog.as_ref() else {
828 return 0;
829 };
830 let tab = *self.emoji_picker_state.tab.current();
831 let sections = catalog.sections(tab.index(), &self.emoji_picker_state.search_query);
832 emoji::picker::selectable_count(§ions)
833 }
834
835 fn picker_move_selection(&mut self, delta: isize) {
836 let max = self.picker_selectable_count();
837 if max == 0 {
838 return;
839 }
840
841 let cur = self.emoji_picker_state.selected_index as isize;
842 let next = cur.saturating_add(delta).clamp(0, (max - 1) as isize) as usize;
843 self.emoji_picker_state.selected_index = next;
844
845 if let Some(catalog) = self.icon_catalog.as_ref() {
846 Self::adjust_picker_scroll(&mut self.emoji_picker_state, catalog);
847 }
848 }
849
850 fn adjust_picker_scroll(
851 state: &mut emoji::EmojiPickerState,
852 catalog: &emoji::catalog::IconCatalogData,
853 ) {
854 let tab = *state.tab.current();
855 let sections = catalog.sections(tab.index(), &state.search_query);
856 let flat_idx =
857 emoji::picker::selectable_to_flat(§ions, state.selected_index).unwrap_or(0);
858
859 let visible = state.visible_height.get().max(1);
860 state.scroll_offset = adjust_scroll_offset(state.scroll_offset, visible, flat_idx);
861 }
862
863 fn picker_insert_selected(&mut self, keep_open: bool) {
864 let tab = *self.emoji_picker_state.tab.current();
865 let selected = self.emoji_picker_state.selected_index;
866 let query = self.emoji_picker_state.search_query.clone();
867
868 let icon = {
869 let Some(catalog) = self.icon_catalog.as_ref() else {
870 self.emoji_picker_open = false;
871 return;
872 };
873 let sections = catalog.sections(tab.index(), &query);
874 match emoji::picker::entry_at_selectable(§ions, selected) {
875 Some(entry) => entry.icon.clone(),
876 None => {
877 if !keep_open {
878 self.emoji_picker_open = false;
879 }
880 return;
881 }
882 }
883 };
884
885 if !keep_open {
886 self.emoji_picker_open = false;
887 }
888
889 if let Some(ch) = icon.chars().next() {
890 self.dismiss_floating();
891 self.insert_char(ch);
892 }
893 }
894
895 fn handle_picker_key(&mut self, key: AppKey) {
896 if key.modifiers.has_alt_like() && key.code == AppKeyCode::Enter {
897 self.picker_insert_selected(true);
898 return;
899 }
900
901 match key.code {
902 AppKeyCode::Esc => {
903 self.emoji_picker_open = false;
904 }
905 AppKeyCode::Enter => self.picker_insert_selected(false),
906 AppKeyCode::Tab => {
907 self.emoji_picker_state.tab.move_next();
908 self.emoji_picker_state.selected_index = 0;
909 self.emoji_picker_state.scroll_offset = 0;
910 self.emoji_picker_state.last_click = None;
911 }
912 AppKeyCode::BackTab => {
913 self.emoji_picker_state.tab.move_prev();
914 self.emoji_picker_state.selected_index = 0;
915 self.emoji_picker_state.scroll_offset = 0;
916 self.emoji_picker_state.last_click = None;
917 }
918 AppKeyCode::Backspace if self.emoji_picker_state.search_cursor > 0 => {
919 let byte_pos = self
920 .emoji_picker_state
921 .search_query
922 .char_indices()
923 .nth(self.emoji_picker_state.search_cursor - 1)
924 .map(|(i, _)| i)
925 .unwrap_or(0);
926 self.emoji_picker_state.search_query.remove(byte_pos);
927 self.emoji_picker_state.search_cursor -= 1;
928 self.emoji_picker_state.selected_index = 0;
929 self.emoji_picker_state.scroll_offset = 0;
930 }
931 AppKeyCode::Left => {
932 self.emoji_picker_state.search_cursor =
933 self.emoji_picker_state.search_cursor.saturating_sub(1);
934 }
935 AppKeyCode::Right => {
936 let len = self.emoji_picker_state.search_query.chars().count();
937 if self.emoji_picker_state.search_cursor < len {
938 self.emoji_picker_state.search_cursor += 1;
939 }
940 }
941 AppKeyCode::Up => self.picker_move_selection(-1),
942 AppKeyCode::Down => self.picker_move_selection(1),
943 AppKeyCode::PageUp => {
944 let page = self.emoji_picker_state.visible_height.get().max(1) as isize;
945 self.picker_move_selection(-page);
946 }
947 AppKeyCode::PageDown => {
948 let page = self.emoji_picker_state.visible_height.get().max(1) as isize;
949 self.picker_move_selection(page);
950 }
951 AppKeyCode::Char(ch)
952 if !key.modifiers.ctrl && !key.modifiers.has_alt_like() && !ch.is_control() =>
953 {
954 let byte_pos = self
955 .emoji_picker_state
956 .search_query
957 .char_indices()
958 .nth(self.emoji_picker_state.search_cursor)
959 .map(|(i, _)| i)
960 .unwrap_or(self.emoji_picker_state.search_query.len());
961 self.emoji_picker_state.search_query.insert(byte_pos, ch);
962 self.emoji_picker_state.search_cursor += 1;
963 self.emoji_picker_state.selected_index = 0;
964 self.emoji_picker_state.scroll_offset = 0;
965 }
966 _ => {}
967 }
968 }
969
970 fn handle_picker_mouse(&mut self, mouse: AppPointerEvent) {
971 match mouse.kind {
972 AppPointerKind::Down(AppPointerButton::Left) => {
973 let row_0based = mouse.row;
974 let col_0based = mouse.column;
975
976 let tabs = self.emoji_picker_state.tabs_inner.get();
977 if tabs.height > 0 && row_0based >= tabs.y && row_0based < tabs.y + tabs.height {
978 if let Some(idx) = emoji::picker::tab_at_x(tabs, col_0based) {
979 self.emoji_picker_state.tab.set_index(idx);
980 self.emoji_picker_state.selected_index = 0;
981 self.emoji_picker_state.scroll_offset = 0;
982 self.emoji_picker_state.last_click = None;
983 return;
984 }
985 }
986
987 let list = self.emoji_picker_state.list_inner.get();
988 if list.height == 0 || row_0based < list.y || row_0based >= list.y + list.height {
989 return;
990 }
991 let offset_in_list = (row_0based - list.y) as usize;
992 let flat_idx = self.emoji_picker_state.scroll_offset + offset_in_list;
993
994 let Some(catalog) = self.icon_catalog.as_ref() else {
995 return;
996 };
997 let tab = *self.emoji_picker_state.tab.current();
998 let sections = catalog.sections(tab.index(), &self.emoji_picker_state.search_query);
999 let Some(selectable_idx) = emoji::picker::flat_to_selectable(§ions, flat_idx)
1000 else {
1001 return;
1002 };
1003
1004 let now = std::time::Instant::now();
1005 let is_double = match self.emoji_picker_state.last_click {
1006 Some((prev, prev_idx)) => {
1007 prev_idx == selectable_idx
1008 && now.duration_since(prev).as_millis() <= emoji::DOUBLE_CLICK_WINDOW_MS
1009 }
1010 None => false,
1011 };
1012
1013 self.emoji_picker_state.selected_index = selectable_idx;
1014 Self::adjust_picker_scroll(&mut self.emoji_picker_state, catalog);
1015
1016 if is_double {
1017 self.emoji_picker_state.last_click = None;
1018 self.picker_insert_selected(true);
1019 } else {
1020 self.emoji_picker_state.last_click = Some((now, selectable_idx));
1021 }
1022 }
1023 AppPointerKind::ScrollDown => self.picker_move_selection(3),
1024 AppPointerKind::ScrollUp => self.picker_move_selection(-3),
1025 _ => {}
1026 }
1027 }
1028
1029 fn paste_text_block(&mut self, text: &str) {
1030 let color = self.active_user_color();
1031 let before = self.canvas.clone();
1032 let editor = self.editor_session_snapshot();
1033 let changed = editor_paste_text_block(&editor, &mut self.canvas, text, color);
1034 if changed {
1035 self.finish_canvas_edit(before);
1036 }
1037 }
1038
1039 #[cfg(test)]
1040 fn backspace(&mut self) {
1041 let before = self.canvas.clone();
1042 let changed = self.with_editor_and_canvas_mut(editor_backspace);
1043 if changed {
1044 self.finish_canvas_edit(before);
1045 }
1046 }
1047
1048 fn is_open_picker_key(key: AppKey) -> bool {
1049 matches!(
1050 key.code,
1051 AppKeyCode::Char(']') if key.modifiers.ctrl
1052 ) || matches!(
1053 key.code,
1054 AppKeyCode::Char('5') if key.modifiers.ctrl
1055 ) || matches!(key.code, AppKeyCode::Char('\u{1d}'))
1056 }
1057
1058 pub fn tick(&mut self) {
1059 self.drain_server_events();
1060 }
1061
1062 pub fn handle_event(&mut self, event: Event) {
1063 if let Some(intent) = app_intent_from_crossterm(event) {
1064 let effects = self.handle_intent(intent);
1065 self.apply_host_effects(effects);
1066 } else {
1067 self.tick();
1068 }
1069 }
1070
1071 pub fn handle_intent(&mut self, intent: AppIntent) -> Vec<HostEffect> {
1072 let effects = self.handle_intent_inner(intent);
1073 self.clamp_cursor();
1074 self.tick();
1075 effects
1076 }
1077
1078 fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1079 for effect in effects {
1080 match effect {
1081 HostEffect::RequestQuit => self.should_quit = true,
1082 HostEffect::CopyToClipboard(text) => {
1083 let _ = execute!(io::stdout(), CopyToClipboard::to_clipboard_from(text));
1084 }
1085 }
1086 }
1087 }
1088
1089 fn handle_intent_inner(&mut self, intent: AppIntent) -> Vec<HostEffect> {
1090 match intent {
1091 AppIntent::KeyPress(key) => self.handle_key_input(key),
1092 AppIntent::Pointer(mouse) => {
1093 self.handle_pointer_input(mouse);
1094 Vec::new()
1095 }
1096 AppIntent::Paste(data) => {
1097 if !self.show_help {
1098 self.paste_text_block(&data);
1099 }
1100 Vec::new()
1101 }
1102 }
1103 }
1104
1105 fn handle_key_input(&mut self, key: AppKey) -> Vec<HostEffect> {
1106 if Self::is_open_picker_key(key) {
1107 self.open_emoji_picker();
1108 return Vec::new();
1109 }
1110
1111 if self.emoji_picker_open {
1112 self.handle_picker_key(key);
1113 return Vec::new();
1114 }
1115
1116 if key.code == AppKeyCode::Char('q') && key.modifiers.ctrl {
1117 return vec![HostEffect::RequestQuit];
1118 }
1119
1120 if self.show_help {
1121 match key.code {
1122 AppKeyCode::Esc | AppKeyCode::F(1) => self.show_help = false,
1123 AppKeyCode::Char('p') if key.modifiers.ctrl => self.show_help = false,
1124 AppKeyCode::Tab | AppKeyCode::Right => {
1125 self.help_tab = self.help_tab.next();
1126 self.help_scroll = 0;
1127 }
1128 AppKeyCode::BackTab | AppKeyCode::Left => {
1129 self.help_tab = self.help_tab.prev();
1130 self.help_scroll = 0;
1131 }
1132 AppKeyCode::Down | AppKeyCode::Char('j') => {
1133 self.help_scroll = self.help_scroll.saturating_add(1);
1134 }
1135 AppKeyCode::Up | AppKeyCode::Char('k') => {
1136 self.help_scroll = self.help_scroll.saturating_sub(1);
1137 }
1138 AppKeyCode::PageDown => {
1139 self.help_scroll = self.help_scroll.saturating_add(5);
1140 }
1141 AppKeyCode::PageUp => {
1142 self.help_scroll = self.help_scroll.saturating_sub(5);
1143 }
1144 AppKeyCode::Home => self.help_scroll = 0,
1145 _ => {}
1146 }
1147 return Vec::new();
1148 }
1149
1150 if key.code == AppKeyCode::Tab && key.modifiers == AppModifiers::default() {
1151 self.switch_active_user(1);
1152 return Vec::new();
1153 }
1154
1155 if key.code == AppKeyCode::BackTab {
1156 self.switch_active_user(-1);
1157 return Vec::new();
1158 }
1159
1160 if (key.code == AppKeyCode::Char('p') && key.modifiers.ctrl) || key.code == AppKeyCode::F(1)
1161 {
1162 self.show_help = !self.show_help;
1163 return Vec::new();
1164 }
1165
1166 self.handle_key_press(key)
1167 }
1168
1169 fn handle_pointer_input(&mut self, mouse: AppPointerEvent) {
1170 if self.emoji_picker_open {
1171 self.handle_picker_mouse(mouse);
1172 return;
1173 }
1174
1175 if self.show_help {
1176 if matches!(mouse.kind, AppPointerKind::Down(AppPointerButton::Left)) {
1177 if let Some(tab) = self.help_tab_hit(mouse.column, mouse.row) {
1178 if self.help_tab != tab {
1179 self.help_scroll = 0;
1180 }
1181 self.help_tab = tab;
1182 }
1183 }
1184 return;
1185 }
1186
1187 if matches!(mouse.kind, AppPointerKind::Down(AppPointerButton::Left)) {
1188 if let Some((idx, zone)) = self.swatch_hit(mouse.column, mouse.row) {
1189 match zone {
1190 SwatchZone::Pin => self.toggle_pin(idx),
1191 SwatchZone::Body => self.activate_swatch(idx),
1192 }
1193 return;
1194 }
1195 }
1196
1197 let color = self.active_user_color();
1198 let before = self.canvas.clone();
1199 let dispatch = self.with_editor_and_canvas_mut(|editor, canvas| {
1200 editor_handle_pointer(editor, canvas, mouse, color)
1201 });
1202
1203 if matches!(dispatch.stroke_hint, Some(PointerStrokeHint::Begin)) {
1206 self.paint_canvas_before = Some(before.clone());
1207 }
1208
1209 if self.canvas != before {
1210 self.finish_canvas_edit(before);
1211 }
1212
1213 if matches!(dispatch.stroke_hint, Some(PointerStrokeHint::End)) {
1214 self.end_paint_stroke();
1215 }
1216 }
1217
1218 fn handle_key_press(&mut self, key: AppKey) -> Vec<HostEffect> {
1219 let ctx = EditorContext {
1220 mode: self.mode,
1221 has_selection_anchor: self.selection_anchor.is_some(),
1222 is_floating: self.floating.is_some(),
1223 };
1224 let action = KeyMap::default_standalone().resolve(key, ctx);
1225
1226 if self.floating.is_some() {
1227 match self.apply_floating_override(action) {
1228 FloatingOutcome::Consumed => return Vec::new(),
1229 FloatingOutcome::PassThrough | FloatingOutcome::DismissAndContinue => {}
1230 }
1231 }
1232
1233 if key.modifiers.ctrl && key.code == AppKeyCode::Char('r') {
1234 self.redo();
1235 return Vec::new();
1236 }
1237
1238 if key.modifiers.ctrl && key.code == AppKeyCode::Char('z') {
1239 self.undo();
1240 return Vec::new();
1241 }
1242
1243 let Some(action) = action else {
1244 return Vec::new();
1245 };
1246
1247 let color = self.active_user_color();
1248 let before = self.canvas.clone();
1249 let dispatch = self.with_editor_and_canvas_mut(|editor, canvas| {
1250 editor_handle_action(editor, canvas, action, color)
1251 });
1252 if self.canvas != before {
1253 self.finish_canvas_edit(before);
1254 }
1255 dispatch.effects
1256 }
1257
1258 fn apply_floating_override(&mut self, action: Option<EditorAction>) -> FloatingOutcome {
1259 match action {
1260 Some(EditorAction::ActivateSwatch(idx)) => {
1261 self.activate_swatch(idx);
1262 FloatingOutcome::Consumed
1263 }
1264 Some(EditorAction::PastePrimarySwatch) => {
1265 self.stamp_floating();
1266 FloatingOutcome::Consumed
1267 }
1268 Some(EditorAction::CopySelection) | Some(EditorAction::CutSelection) => {
1269 FloatingOutcome::Consumed
1270 }
1271 Some(EditorAction::ClearSelection) => {
1272 self.dismiss_floating();
1273 FloatingOutcome::Consumed
1274 }
1275 Some(EditorAction::Move {
1276 dir: MoveDir::Up, ..
1277 }) => {
1278 self.move_up();
1279 FloatingOutcome::Consumed
1280 }
1281 Some(EditorAction::Move {
1282 dir: MoveDir::Down, ..
1283 }) => {
1284 self.move_down();
1285 FloatingOutcome::Consumed
1286 }
1287 Some(EditorAction::Move {
1288 dir: MoveDir::Left, ..
1289 }) => {
1290 self.move_left();
1291 FloatingOutcome::Consumed
1292 }
1293 Some(EditorAction::Move {
1294 dir: MoveDir::Right,
1295 ..
1296 }) => {
1297 self.move_right();
1298 FloatingOutcome::Consumed
1299 }
1300 Some(EditorAction::StrokeFloating { .. }) => FloatingOutcome::PassThrough,
1301 Some(EditorAction::Pan { .. })
1302 | Some(EditorAction::ExportSystemClipboard)
1303 | Some(EditorAction::ToggleFloatingTransparency) => FloatingOutcome::PassThrough,
1304 _ => {
1305 self.dismiss_floating();
1306 FloatingOutcome::DismissAndContinue
1307 }
1308 }
1309 }
1310
1311 #[cfg(test)]
1312 fn handle_key(&mut self, key: KeyEvent) {
1313 let Some(key) = app_key_from_crossterm(key) else {
1314 return;
1315 };
1316 let _ = self.handle_key_press(key);
1317 self.clamp_cursor();
1318 }
1319
1320 #[cfg(test)]
1321 pub fn is_selected(&self, pos: Pos) -> bool {
1322 let Some(selection) = self.selection() else {
1323 return false;
1324 };
1325 selection.contains(pos)
1326 }
1327}
1328
1329fn rect_contains(rect: &Rect, col: u16, row: u16) -> bool {
1330 col >= rect.x && row >= rect.y && col < rect.x + rect.width && row < rect.y + rect.height
1331}
1332
1333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1334enum FloatingOutcome {
1335 Consumed,
1336 PassThrough,
1337 DismissAndContinue,
1338}
1339
1340fn diff_canvas_op(before: &Canvas, after: &Canvas) -> Option<CanvasOp> {
1341 editor_diff_canvas_op(before, after, theme::DEFAULT_GLYPH_FG)
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346 use super::{
1347 App, AppIntent, AppKey, AppKeyCode, AppModifiers, AppPointerEvent, AppPointerKind, HelpTab,
1348 HostEffect, Mode, SelectionShape, SWATCH_CAPACITY,
1349 };
1350 use crossterm::event::{
1351 Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
1352 };
1353 use dartboard_core::{Canvas, CellValue, Pos, RgbColor, DEFAULT_HEIGHT, DEFAULT_WIDTH};
1354 use dartboard_editor::{Clipboard, FloatingSelection};
1355 use ratatui::layout::Rect;
1356
1357 fn setup_floating_wide_brush() -> App {
1358 let mut app = App::new();
1359 app.set_viewport(Rect::new(0, 0, 64, 24));
1360 app.canvas.set(Pos { x: 0, y: 0 }, '🌱');
1361 app.selection_anchor = Some(Pos { x: 0, y: 0 });
1362 app.cursor = Pos { x: 0, y: 0 };
1363 app.mode = Mode::Select;
1364 app.copy_selection_or_cell();
1365 app.activate_swatch(0);
1366 app
1367 }
1368
1369 fn wide_origins_in_row(app: &App, y: usize, x_max: usize) -> Vec<usize> {
1370 (0..=x_max)
1371 .filter(|&x| matches!(app.canvas.cell(Pos { x, y }), Some(CellValue::Wide(_))))
1372 .collect()
1373 }
1374
1375 #[test]
1376 fn smart_fill_matches_selection_shape() {
1377 let mut app = App::new();
1378 app.selection_anchor = Some(Pos { x: 2, y: 1 });
1379 app.cursor = Pos { x: 2, y: 3 };
1380 app.mode = Mode::Select;
1381
1382 app.smart_fill();
1383
1384 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), '|');
1385 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), '|');
1386 assert_eq!(app.canvas.get(Pos { x: 2, y: 3 }), '|');
1387 }
1388
1389 #[test]
1390 fn border_draws_ascii_frame() {
1391 let mut app = App::new();
1392 app.selection_anchor = Some(Pos { x: 1, y: 1 });
1393 app.cursor = Pos { x: 4, y: 3 };
1394 app.mode = Mode::Select;
1395
1396 app.draw_border();
1397
1398 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), '.');
1399 assert_eq!(app.canvas.get(Pos { x: 4, y: 1 }), '.');
1400 assert_eq!(app.canvas.get(Pos { x: 1, y: 3 }), '`');
1401 assert_eq!(app.canvas.get(Pos { x: 4, y: 3 }), '\'');
1402 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), '-');
1403 assert_eq!(app.canvas.get(Pos { x: 1, y: 2 }), '|');
1404 }
1405
1406 #[test]
1407 fn cut_and_paste_work_for_selection() {
1408 let mut app = App::new();
1409 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
1410 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
1411 app.canvas.set(Pos { x: 1, y: 2 }, 'C');
1412 app.canvas.set(Pos { x: 2, y: 2 }, 'D');
1413 app.selection_anchor = Some(Pos { x: 1, y: 1 });
1414 app.cursor = Pos { x: 2, y: 2 };
1415 app.mode = Mode::Select;
1416
1417 app.cut_selection_or_cell();
1418
1419 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), ' ');
1420 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), ' ');
1421
1422 app.clear_selection();
1423 app.cursor = Pos { x: 5, y: 4 };
1424 app.paste_clipboard();
1425
1426 assert_eq!(app.canvas.get(Pos { x: 5, y: 4 }), 'A');
1427 assert_eq!(app.canvas.get(Pos { x: 6, y: 4 }), 'B');
1428 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), 'C');
1429 assert_eq!(app.canvas.get(Pos { x: 6, y: 5 }), 'D');
1430 }
1431
1432 #[test]
1433 fn undo_and_redo_restore_canvas_state() {
1434 let mut app = App::new();
1435
1436 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1437 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1438 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1439 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), 'B');
1440
1441 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1442 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1443 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
1444
1445 app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
1446 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1447 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), 'B');
1448 }
1449
1450 #[test]
1451 fn new_edit_clears_redo_history() {
1452 let mut app = App::new();
1453
1454 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1455 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1456 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1457 app.handle_key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
1458 app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
1459
1460 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1461 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
1462 assert_eq!(app.canvas.get(Pos { x: 2, y: 0 }), 'C');
1463 }
1464
1465 #[test]
1466 fn bracketed_paste_preserves_multiline_shape() {
1467 let mut app = App::new();
1468 app.cursor = Pos { x: 3, y: 4 };
1469
1470 app.handle_event(Event::Paste(".---.\n| |\n`---'".to_string()));
1471
1472 assert_eq!(app.canvas.get(Pos { x: 3, y: 4 }), '.');
1473 assert_eq!(app.canvas.get(Pos { x: 7, y: 4 }), '.');
1474 assert_eq!(app.canvas.get(Pos { x: 3, y: 5 }), '|');
1475 assert_eq!(app.canvas.get(Pos { x: 7, y: 5 }), '|');
1476 assert_eq!(app.canvas.get(Pos { x: 3, y: 6 }), '`');
1477 assert_eq!(app.canvas.get(Pos { x: 7, y: 6 }), '\'');
1478 }
1479
1480 #[test]
1481 fn alt_arrow_keys_pan_viewport() {
1482 let mut app = App::new();
1483 app.set_viewport(Rect::new(0, 0, 10, 5));
1484
1485 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
1486 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
1487
1488 assert_eq!(app.viewport_origin, Pos { x: 1, y: 1 });
1489 }
1490
1491 #[test]
1492 fn ctrl_shift_arrow_keys_pan_viewport() {
1493 let mut app = App::new();
1494 app.set_viewport(Rect::new(0, 0, 10, 5));
1495 app.cursor = Pos { x: 5, y: 2 };
1496
1497 let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
1498 app.handle_key(KeyEvent::new(KeyCode::Right, mods));
1499 app.handle_key(KeyEvent::new(KeyCode::Down, mods));
1500
1501 assert_eq!(app.viewport_origin, Pos { x: 1, y: 1 });
1502 assert_eq!(app.cursor, Pos { x: 5, y: 2 });
1503 }
1504
1505 #[test]
1506 fn ctrl_shift_arrow_keys_stroke_floating_brush() {
1507 let mut app = App::new();
1508 app.canvas = Canvas::with_size(8, 4);
1509 app.cursor = Pos { x: 2, y: 1 };
1510 app.floating = Some(FloatingSelection {
1511 clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]),
1512 transparent: false,
1513 source_index: None,
1514 });
1515
1516 let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
1517 app.handle_key(KeyEvent::new(KeyCode::Right, mods));
1518
1519 assert_eq!(app.cursor, Pos { x: 3, y: 1 });
1520 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), 'A');
1521 assert_eq!(app.canvas.get(Pos { x: 3, y: 1 }), 'A');
1522 assert!(app.floating.is_some());
1523 }
1524
1525 #[test]
1526 fn right_drag_pans_viewport() {
1527 let mut app = App::new();
1528 app.set_viewport(Rect::new(0, 0, 10, 5));
1529
1530 app.handle_event(Event::Mouse(MouseEvent {
1531 kind: MouseEventKind::Down(MouseButton::Right),
1532 column: 5,
1533 row: 2,
1534 modifiers: KeyModifiers::NONE,
1535 }));
1536 app.handle_event(Event::Mouse(MouseEvent {
1537 kind: MouseEventKind::Drag(MouseButton::Right),
1538 column: 2,
1539 row: 1,
1540 modifiers: KeyModifiers::NONE,
1541 }));
1542
1543 assert_eq!(app.viewport_origin, Pos { x: 3, y: 1 });
1544 }
1545
1546 #[test]
1547 fn pointer_intent_scroll_pans_viewport() {
1548 let mut app = App::new();
1549 app.set_viewport(Rect::new(2, 3, 10, 5));
1550 app.viewport_origin = Pos { x: 5, y: 5 };
1551
1552 let _ = app.handle_intent(AppIntent::Pointer(AppPointerEvent {
1553 column: 4,
1554 row: 5,
1555 kind: AppPointerKind::ScrollRight,
1556 modifiers: AppModifiers::default(),
1557 }));
1558 let _ = app.handle_intent(AppIntent::Pointer(AppPointerEvent {
1559 column: 4,
1560 row: 5,
1561 kind: AppPointerKind::ScrollDown,
1562 modifiers: AppModifiers::default(),
1563 }));
1564
1565 assert_eq!(app.viewport_origin, Pos { x: 6, y: 6 });
1566 }
1567
1568 #[test]
1569 fn mouse_mapping_respects_viewport_origin() {
1570 let mut app = App::new();
1571 app.set_viewport(Rect::new(4, 3, 10, 5));
1572 app.viewport_origin = Pos { x: 12, y: 7 };
1573
1574 assert_eq!(app.mouse_to_canvas(6, 4), Some(Pos { x: 14, y: 8 }));
1575 }
1576
1577 #[test]
1578 fn cursor_is_clamped_into_viewport_after_pan() {
1579 let mut app = App::new();
1580 app.set_viewport(Rect::new(0, 0, 10, 5));
1581 app.cursor = Pos { x: 2, y: 2 };
1582
1583 app.pan_by(20, 10);
1584
1585 assert_eq!(app.viewport_origin, Pos { x: 20, y: 10 });
1586 assert_eq!(app.cursor, Pos { x: 20, y: 10 });
1587 }
1588
1589 #[test]
1590 fn resize_clamps_cursor_to_nearest_visible_position() {
1591 let mut app = App::new();
1592 app.viewport_origin = Pos { x: 10, y: 10 };
1593 app.cursor = Pos { x: 18, y: 14 };
1594
1595 app.set_viewport(Rect::new(0, 0, 4, 3));
1596
1597 assert_eq!(app.cursor, Pos { x: 13, y: 12 });
1598 }
1599
1600 #[test]
1601 fn cursor_movement_pans_viewport_at_edge() {
1602 let mut app = App::new();
1603 app.viewport_origin = Pos { x: 10, y: 20 };
1604 app.set_viewport(Rect::new(0, 0, 4, 3));
1605 app.cursor = Pos { x: 13, y: 20 };
1606
1607 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
1608 assert_eq!(app.cursor, Pos { x: 14, y: 20 });
1609 assert_eq!(app.viewport_origin, Pos { x: 11, y: 20 });
1610
1611 app.cursor = Pos { x: 14, y: 22 };
1612 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1613 assert_eq!(app.cursor, Pos { x: 14, y: 23 });
1614 assert_eq!(app.viewport_origin, Pos { x: 11, y: 21 });
1615
1616 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1617 assert_eq!(app.cursor, Pos { x: 13, y: 23 });
1618 assert_eq!(app.viewport_origin, Pos { x: 11, y: 21 });
1619
1620 app.cursor = Pos { x: 11, y: 23 };
1621 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1622 assert_eq!(app.cursor, Pos { x: 10, y: 23 });
1623 assert_eq!(app.viewport_origin, Pos { x: 10, y: 21 });
1624 }
1625
1626 #[test]
1627 fn cursor_stops_at_canvas_edges() {
1628 let mut app = App::new();
1629 app.set_viewport(Rect::new(0, 0, 10, 5));
1630
1631 app.cursor = Pos { x: 0, y: 3 };
1632 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1633 assert_eq!(app.cursor, Pos { x: 0, y: 3 });
1634 assert_eq!(app.viewport_origin, Pos { x: 0, y: 0 });
1635
1636 app.cursor = Pos { x: 3, y: 0 };
1637 app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
1638 assert_eq!(app.cursor, Pos { x: 3, y: 0 });
1639 assert_eq!(app.viewport_origin, Pos { x: 0, y: 0 });
1640
1641 let last_x = app.canvas.width - 1;
1642 let last_y = app.canvas.height - 1;
1643
1644 app.cursor = Pos { x: last_x, y: 3 };
1645 app.viewport_origin = Pos {
1646 x: last_x + 1 - app.viewport.width as usize,
1647 y: 0,
1648 };
1649 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
1650 assert_eq!(app.cursor, Pos { x: last_x, y: 3 });
1651
1652 app.cursor = Pos { x: 3, y: last_y };
1653 app.viewport_origin = Pos {
1654 x: 0,
1655 y: last_y + 1 - app.viewport.height as usize,
1656 };
1657 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1658 assert_eq!(app.cursor, Pos { x: 3, y: last_y });
1659 }
1660
1661 #[test]
1662 fn ctrl_q_quits_even_when_help_is_open() {
1663 let mut app = App::new();
1664 app.show_help = true;
1665
1666 app.handle_event(Event::Key(KeyEvent::new(
1667 KeyCode::Char('q'),
1668 KeyModifiers::CONTROL,
1669 )));
1670
1671 assert!(app.should_quit);
1672 assert!(app.show_help);
1673 }
1674
1675 #[test]
1676 fn intent_api_emits_quit_effect_without_applying_it() {
1677 let mut app = App::new();
1678
1679 let effects = app.handle_intent(AppIntent::KeyPress(AppKey {
1680 code: AppKeyCode::Char('q'),
1681 modifiers: AppModifiers {
1682 ctrl: true,
1683 ..Default::default()
1684 },
1685 }));
1686
1687 assert_eq!(effects, vec![HostEffect::RequestQuit]);
1688 assert!(!app.should_quit);
1689 }
1690
1691 #[test]
1692 fn ctrl_right_bracket_opens_picker() {
1693 let mut app = App::new();
1694
1695 app.handle_event(Event::Key(KeyEvent::new(
1696 KeyCode::Char(']'),
1697 KeyModifiers::CONTROL,
1698 )));
1699
1700 assert!(app.emoji_picker_open);
1701 }
1702
1703 #[test]
1704 fn group_separator_opens_picker() {
1705 let mut app = App::new();
1706
1707 app.handle_event(Event::Key(KeyEvent::new(
1708 KeyCode::Char('\u{1d}'),
1709 KeyModifiers::NONE,
1710 )));
1711
1712 assert!(app.emoji_picker_open);
1713 }
1714
1715 #[test]
1716 fn ctrl_five_opens_picker() {
1717 let mut app = App::new();
1718
1719 app.handle_event(Event::Key(KeyEvent::new(
1720 KeyCode::Char('5'),
1721 KeyModifiers::CONTROL,
1722 )));
1723
1724 assert!(app.emoji_picker_open);
1725 }
1726
1727 #[test]
1728 fn tab_switches_active_local_user() {
1729 let mut app = App::new();
1730 app.cursor = Pos { x: 7, y: 4 };
1731 app.selection_anchor = Some(Pos { x: 3, y: 2 });
1732 app.mode = Mode::Select;
1733
1734 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1735
1736 assert_eq!(app.active_user_idx, 1);
1737 assert_eq!(app.cursor, Pos { x: 0, y: 0 });
1738 assert_eq!(app.selection_anchor, None);
1739 assert!(!app.mode.is_selecting());
1740
1741 app.handle_event(Event::Key(KeyEvent::new(
1742 KeyCode::BackTab,
1743 KeyModifiers::SHIFT,
1744 )));
1745
1746 assert_eq!(app.active_user_idx, 0);
1747 assert_eq!(app.cursor, Pos { x: 7, y: 4 });
1748 assert_eq!(app.selection_anchor, Some(Pos { x: 3, y: 2 }));
1749 assert!(app.mode.is_selecting());
1750 }
1751
1752 #[test]
1753 fn tab_cycles_help_tabs_when_help_open() {
1754 let mut app = App::new();
1755 app.show_help = true;
1756 assert_eq!(app.help_tab, HelpTab::Guide);
1757
1758 for expected in [
1759 HelpTab::Drawing,
1760 HelpTab::Selection,
1761 HelpTab::Clipboard,
1762 HelpTab::Transform,
1763 HelpTab::Session,
1764 HelpTab::Guide,
1765 ] {
1766 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1767 assert_eq!(app.help_tab, expected);
1768 }
1769 assert_eq!(app.active_user_idx, 0);
1770 assert!(app.show_help);
1771
1772 app.handle_event(Event::Key(KeyEvent::new(
1773 KeyCode::BackTab,
1774 KeyModifiers::SHIFT,
1775 )));
1776
1777 assert_eq!(app.help_tab, HelpTab::Session);
1778 assert_eq!(app.active_user_idx, 0);
1779 }
1780
1781 #[test]
1782 fn local_users_share_canvas_but_keep_separate_swatch_state() {
1783 let mut app = App::new();
1784 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1785 app.cursor = Pos { x: 5, y: 5 };
1786 app.copy_selection_or_cell();
1787 assert!(app.swatches[0].is_some());
1788
1789 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1790
1791 assert_eq!(app.active_user_idx, 1);
1792 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1793 assert!(app.swatches[0].is_none());
1794
1795 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1796 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
1797
1798 app.handle_event(Event::Key(KeyEvent::new(
1799 KeyCode::BackTab,
1800 KeyModifiers::SHIFT,
1801 )));
1802
1803 assert_eq!(app.active_user_idx, 0);
1804 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
1805 assert!(app.swatches[0].is_some());
1806 assert_eq!(app.cursor, Pos { x: 5, y: 5 });
1807 }
1808
1809 #[test]
1810 fn local_users_start_with_distinct_colors() {
1811 let app = App::new();
1812 let colors: Vec<_> = app.users().iter().map(|user| user.color).collect();
1813 for (idx, color) in colors.iter().enumerate() {
1814 assert!(
1815 colors[(idx + 1)..].iter().all(|other| other != color),
1816 "duplicate player color at index {idx}: {color:?}"
1817 );
1818 }
1819 }
1820
1821 #[test]
1822 fn paint_reaches_server_via_active_client() {
1823 let mut app = App::new();
1824 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1825 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1826 let server_snap = app.server_snapshot_for_test();
1827 assert_eq!(server_snap.get(Pos { x: 0, y: 0 }), 'A');
1828 }
1829
1830 #[test]
1831 fn undo_propagates_to_server() {
1832 let mut app = App::new();
1833 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1834 assert_eq!(app.server_snapshot_for_test().get(Pos { x: 0, y: 0 }), 'A');
1835
1836 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1837 app.drain_server_events();
1838 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), ' ');
1839 assert_eq!(app.server_snapshot_for_test().get(Pos { x: 0, y: 0 }), ' ');
1840 }
1841
1842 #[test]
1843 fn single_cell_paint_emits_paint_cell_op() {
1844 use dartboard_core::CanvasOp;
1845 let before = Canvas::with_size(8, 4);
1846 let mut after = before.clone();
1847 after.set_colored(Pos { x: 1, y: 1 }, 'A', RgbColor::new(10, 20, 30));
1848 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1849 match op {
1850 CanvasOp::PaintCell { pos, ch, fg } => {
1851 assert_eq!(pos, Pos { x: 1, y: 1 });
1852 assert_eq!(ch, 'A');
1853 assert_eq!(fg, RgbColor::new(10, 20, 30));
1854 }
1855 other => panic!("expected PaintCell, got {:?}", other),
1856 }
1857 }
1858
1859 #[test]
1860 fn single_cell_clear_emits_clear_cell_op() {
1861 use dartboard_core::CanvasOp;
1862 let mut before = Canvas::with_size(8, 4);
1863 before.set(Pos { x: 3, y: 2 }, 'Q');
1864 let mut after = before.clone();
1865 after.clear_cell(Pos { x: 3, y: 2 });
1866 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1867 match op {
1868 CanvasOp::ClearCell { pos } => assert_eq!(pos, Pos { x: 3, y: 2 }),
1869 other => panic!("expected ClearCell, got {:?}", other),
1870 }
1871 }
1872
1873 #[test]
1874 fn multi_cell_edit_emits_paint_region() {
1875 use dartboard_core::CanvasOp;
1876 let before = Canvas::with_size(8, 4);
1877 let mut after = before.clone();
1878 after.set_colored(Pos { x: 0, y: 0 }, 'A', RgbColor::new(1, 2, 3));
1879 after.set_colored(Pos { x: 1, y: 0 }, 'B', RgbColor::new(1, 2, 3));
1880 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1881 match op {
1882 CanvasOp::PaintRegion { cells } => assert_eq!(cells.len(), 2),
1883 other => panic!("expected PaintRegion, got {:?}", other),
1884 }
1885 }
1886
1887 #[test]
1888 fn concurrent_edits_from_two_clients_compose_server_side() {
1889 use dartboard_core::{Canvas, CanvasOp, Client, RgbColor};
1893 use dartboard_server::{Hello, InMemStore, ServerHandle};
1894
1895 let server = ServerHandle::spawn_local(InMemStore);
1896 let mut alice = server.connect_local(Hello {
1897 name: "alice".into(),
1898 color: RgbColor::new(255, 0, 0),
1899 });
1900 let mut bob = server.connect_local(Hello {
1901 name: "bob".into(),
1902 color: RgbColor::new(0, 0, 255),
1903 });
1904 while alice.try_recv().is_some() {}
1905 while bob.try_recv().is_some() {}
1906
1907 let empty = Canvas::with_size(DEFAULT_WIDTH, DEFAULT_HEIGHT);
1908
1909 let mut a_mirror = empty.clone();
1910 a_mirror.set_colored(Pos { x: 0, y: 0 }, 'X', RgbColor::new(255, 0, 0));
1911 let a_op = super::diff_canvas_op(&empty, &a_mirror).unwrap();
1912 assert!(
1913 matches!(a_op, CanvasOp::PaintCell { .. }),
1914 "expected PaintCell, got {:?}",
1915 a_op
1916 );
1917 alice.submit_op(a_op);
1918
1919 let mut b_mirror = empty.clone();
1920 b_mirror.set_colored(Pos { x: 1, y: 0 }, 'Y', RgbColor::new(0, 0, 255));
1921 let b_op = super::diff_canvas_op(&empty, &b_mirror).unwrap();
1922 bob.submit_op(b_op);
1923
1924 let snap = server.canvas_snapshot();
1925 assert_eq!(snap.get(Pos { x: 0, y: 0 }), 'X');
1926 assert_eq!(snap.get(Pos { x: 1, y: 0 }), 'Y');
1927 }
1928
1929 #[test]
1930 fn new_remote_blocks_until_welcome_applied() {
1931 use crate::app::Transport;
1935 use dartboard_client_ws::{Hello as WsHello, WebsocketClient};
1936 use dartboard_core::{CanvasOp, Client};
1937 use dartboard_server::{InMemStore, ServerHandle};
1938
1939 let server = ServerHandle::spawn_local(InMemStore);
1940 let addr = std::net::TcpListener::bind("127.0.0.1:0")
1941 .unwrap()
1942 .local_addr()
1943 .unwrap();
1944 server.bind_ws(addr).unwrap();
1945
1946 let mut seeder = server.connect_local(dartboard_server::Hello {
1949 name: "seeder".into(),
1950 color: RgbColor::new(1, 1, 1),
1951 });
1952 seeder.submit_op(CanvasOp::PaintCell {
1953 pos: Pos { x: 5, y: 5 },
1954 ch: 'Z',
1955 fg: RgbColor::new(1, 1, 1),
1956 });
1957 drop(seeder);
1958
1959 let url = format!("ws://{}", addr);
1960 let client = WebsocketClient::connect(
1961 &url,
1962 WsHello {
1963 name: "me".into(),
1964 color: RgbColor::new(255, 0, 0),
1965 },
1966 )
1967 .unwrap();
1968
1969 let app = App::new_remote(client, "me".into(), RgbColor::new(255, 0, 0));
1970 assert_eq!(
1972 app.canvas.get(Pos { x: 5, y: 5 }),
1973 'Z',
1974 "seeded cell should be visible immediately after new_remote"
1975 );
1976 match &app.transport {
1977 Transport::Remote { mirror, .. } => {
1978 assert!(mirror.my_user_id.is_some(), "my_user_id should be set")
1979 }
1980 _ => panic!("expected Remote transport"),
1981 }
1982 }
1983
1984 #[test]
1985 fn undo_is_enabled_in_embedded_mode() {
1986 let app = App::new();
1987 assert!(app.undo_enabled());
1988 }
1989
1990 #[test]
1991 fn undo_disabled_when_another_peer_is_connected_in_remote_mode() {
1992 use dartboard_client_ws::{Hello as WsHello, WebsocketClient};
1993 use dartboard_server::{Hello, InMemStore, ServerHandle};
1994
1995 let server = ServerHandle::spawn_local(InMemStore);
1998 let addr = std::net::TcpListener::bind("127.0.0.1:0")
1999 .unwrap()
2000 .local_addr()
2001 .unwrap();
2002 server.bind_ws(addr).unwrap();
2003 let _other = server.connect_local(Hello {
2005 name: "other".into(),
2006 color: RgbColor::new(10, 10, 10),
2007 });
2008
2009 let url = format!("ws://{}", addr);
2010 let client = WebsocketClient::connect(
2011 &url,
2012 WsHello {
2013 name: "me".into(),
2014 color: RgbColor::new(255, 0, 0),
2015 },
2016 )
2017 .unwrap();
2018
2019 let mut app = App::new_remote(client, "me".into(), RgbColor::new(255, 0, 0));
2020
2021 let start = std::time::Instant::now();
2023 while start.elapsed() < std::time::Duration::from_secs(2) && app.peer_count() <= 1 {
2024 app.drain_server_events();
2025 std::thread::sleep(std::time::Duration::from_millis(20));
2026 }
2027
2028 assert!(app.peer_count() >= 2, "expected to see the other peer");
2029 assert!(
2030 !app.undo_enabled(),
2031 "undo must be gated off while a remote peer is present"
2032 );
2033 }
2034
2035 #[test]
2036 fn websocket_connect_fails_fast_when_server_is_full() {
2037 use crate::theme;
2038 use dartboard_client_ws::{ConnectError, Hello as WsHello, WebsocketClient};
2039 use dartboard_server::{Hello, InMemStore, ServerHandle, MAX_PLAYERS};
2040
2041 let server = ServerHandle::spawn_local(InMemStore);
2042 let addr = std::net::TcpListener::bind("127.0.0.1:0")
2043 .unwrap()
2044 .local_addr()
2045 .unwrap();
2046 server.bind_ws(addr).unwrap();
2047
2048 let mut _peers = Vec::new();
2049 for i in 0..MAX_PLAYERS {
2050 _peers.push(server.connect_local(Hello {
2051 name: format!("peer{i}"),
2052 color: theme::PLAYER_PALETTE[i],
2053 }));
2054 }
2055
2056 let url = format!("ws://{}", addr);
2057 match WebsocketClient::connect(
2058 &url,
2059 WsHello {
2060 name: "overflow".into(),
2061 color: RgbColor::new(255, 0, 0),
2062 },
2063 ) {
2064 Err(ConnectError::Rejected(reason)) => {
2065 assert!(reason.to_lowercase().contains("full"), "reason: {reason}");
2066 }
2067 Err(other) => panic!("expected ConnectError::Rejected, got {other:?}"),
2068 Ok(_) => panic!("connect should have been rejected"),
2069 }
2070 }
2071
2072 #[test]
2073 fn each_local_user_has_its_own_client_user_id() {
2074 let app = App::new();
2075 let ids = app.client_user_ids_for_test();
2076 let mut unique = ids.clone();
2077 unique.sort();
2078 unique.dedup();
2079 assert_eq!(ids.len(), unique.len(), "user ids must be distinct");
2080 assert_eq!(ids.len(), app.users().len());
2081 }
2082
2083 #[test]
2084 fn authored_cells_take_the_active_user_color() {
2085 let mut app = App::new();
2086 let first_color = app.active_user_color();
2087
2088 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
2089 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
2090 assert_eq!(app.canvas.fg(Pos { x: 0, y: 0 }), Some(first_color));
2091
2092 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
2093 let second_color = app.active_user_color();
2094 assert_ne!(second_color, first_color);
2095
2096 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
2097 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
2098 assert_eq!(app.canvas.fg(Pos { x: 0, y: 0 }), Some(second_color));
2099 }
2100
2101 #[test]
2102 fn keep_open_picker_insert_writes_adjacent_cells() {
2103 let mut app = App::new();
2104 app.open_emoji_picker();
2105
2106 let expected = {
2107 let catalog = app.icon_catalog.as_ref().unwrap();
2108 let tab = *app.emoji_picker_state.tab.current();
2109 let sections = catalog.sections(tab.index(), &app.emoji_picker_state.search_query);
2110 crate::emoji::picker::entry_at_selectable(
2111 §ions,
2112 app.emoji_picker_state.selected_index,
2113 )
2114 .unwrap()
2115 .icon
2116 .chars()
2117 .next()
2118 .unwrap()
2119 };
2120
2121 app.picker_insert_selected(true);
2122 app.picker_insert_selected(true);
2123
2124 assert!(app.emoji_picker_open);
2125 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), expected);
2126 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
2127 assert_eq!(app.canvas.get(Pos { x: 2, y: 0 }), expected);
2128 assert_eq!(app.cursor, Pos { x: 4, y: 0 });
2129 }
2130
2131 #[test]
2132 fn wide_glyph_insert_advances_two_cells() {
2133 let mut app = App::new();
2134
2135 app.insert_char('🌱');
2136
2137 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), '🌱');
2138 assert!(app.canvas.is_continuation(Pos { x: 1, y: 0 }));
2139 assert_eq!(app.cursor, Pos { x: 2, y: 0 });
2140 }
2141
2142 #[test]
2143 fn backspace_on_wide_glyph_clears_both_cells() {
2144 let mut app = App::new();
2145 app.insert_char('🌱');
2146
2147 app.backspace();
2148
2149 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), ' ');
2150 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
2151 assert_eq!(app.cursor, Pos { x: 0, y: 0 });
2152 }
2153
2154 #[test]
2155 fn alt_click_extends_existing_selection() {
2156 let mut app = App::new();
2157 app.set_viewport(Rect::new(0, 0, 20, 10));
2158 app.selection_anchor = Some(Pos { x: 2, y: 3 });
2159 app.cursor = Pos { x: 5, y: 6 };
2160 app.mode = Mode::Select;
2161
2162 app.handle_event(Event::Mouse(MouseEvent {
2163 kind: MouseEventKind::Down(MouseButton::Left),
2164 column: 8,
2165 row: 7,
2166 modifiers: KeyModifiers::ALT,
2167 }));
2168 app.handle_event(Event::Mouse(MouseEvent {
2169 kind: MouseEventKind::Up(MouseButton::Left),
2170 column: 8,
2171 row: 7,
2172 modifiers: KeyModifiers::ALT,
2173 }));
2174
2175 assert_eq!(app.selection_anchor, Some(Pos { x: 2, y: 3 }));
2176 assert_eq!(app.cursor, Pos { x: 8, y: 7 });
2177 assert!(app.mode.is_selecting());
2178 }
2179
2180 #[test]
2181 fn ctrl_drag_creates_ellipse_selection_and_masks_fill() {
2182 let mut app = App::new();
2183 app.set_viewport(Rect::new(0, 0, 20, 10));
2184
2185 app.handle_event(Event::Mouse(MouseEvent {
2186 kind: MouseEventKind::Down(MouseButton::Left),
2187 column: 2,
2188 row: 2,
2189 modifiers: KeyModifiers::CONTROL,
2190 }));
2191 app.handle_event(Event::Mouse(MouseEvent {
2192 kind: MouseEventKind::Drag(MouseButton::Left),
2193 column: 8,
2194 row: 6,
2195 modifiers: KeyModifiers::CONTROL,
2196 }));
2197 app.handle_event(Event::Mouse(MouseEvent {
2198 kind: MouseEventKind::Up(MouseButton::Left),
2199 column: 8,
2200 row: 6,
2201 modifiers: KeyModifiers::CONTROL,
2202 }));
2203
2204 assert_eq!(app.selection_anchor, Some(Pos { x: 2, y: 2 }));
2205 assert_eq!(app.cursor, Pos { x: 8, y: 6 });
2206 assert_eq!(app.selection_shape, SelectionShape::Ellipse);
2207 assert!(app.mode.is_selecting());
2208 assert!(app.is_selected(Pos { x: 5, y: 4 }));
2209 assert!(!app.is_selected(Pos { x: 2, y: 2 }));
2210
2211 app.fill_selection_or_cell('x');
2212
2213 assert_eq!(app.canvas.get(Pos { x: 5, y: 4 }), 'x');
2214 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), ' ');
2215 }
2216
2217 #[test]
2218 fn ellipse_selection_state_is_per_user() {
2219 let mut app = App::new();
2220 app.set_viewport(Rect::new(0, 0, 20, 10));
2221
2222 app.handle_event(Event::Mouse(MouseEvent {
2223 kind: MouseEventKind::Down(MouseButton::Left),
2224 column: 3,
2225 row: 2,
2226 modifiers: KeyModifiers::CONTROL,
2227 }));
2228 app.handle_event(Event::Mouse(MouseEvent {
2229 kind: MouseEventKind::Drag(MouseButton::Left),
2230 column: 9,
2231 row: 6,
2232 modifiers: KeyModifiers::CONTROL,
2233 }));
2234 app.handle_event(Event::Mouse(MouseEvent {
2235 kind: MouseEventKind::Up(MouseButton::Left),
2236 column: 9,
2237 row: 6,
2238 modifiers: KeyModifiers::CONTROL,
2239 }));
2240
2241 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
2242 assert_eq!(app.active_user_idx, 1);
2243 assert_eq!(app.selection_anchor, None);
2244 assert!(!app.mode.is_selecting());
2245
2246 app.handle_event(Event::Key(KeyEvent::new(
2247 KeyCode::BackTab,
2248 KeyModifiers::SHIFT,
2249 )));
2250 assert_eq!(app.active_user_idx, 0);
2251 assert_eq!(app.selection_anchor, Some(Pos { x: 3, y: 2 }));
2252 assert_eq!(app.cursor, Pos { x: 9, y: 6 });
2253 assert_eq!(app.selection_shape, SelectionShape::Ellipse);
2254 assert!(app.mode.is_selecting());
2255 assert!(app.is_selected(Pos { x: 6, y: 4 }));
2256 }
2257
2258 #[test]
2259 fn ctrl_t_transposes_active_selection_corner() {
2260 let mut app = App::new();
2261 app.selection_anchor = Some(Pos { x: 2, y: 3 });
2262 app.cursor = Pos { x: 8, y: 7 };
2263 app.mode = Mode::Select;
2264
2265 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2266
2267 assert_eq!(app.selection_anchor, Some(Pos { x: 8, y: 7 }));
2268 assert_eq!(app.cursor, Pos { x: 2, y: 3 });
2269 assert!(app.mode.is_selecting());
2270 }
2271
2272 #[test]
2273 fn copy_pushes_swatch_without_entering_floating() {
2274 let mut app = App::new();
2275 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
2276 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
2277 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2278 app.cursor = Pos { x: 2, y: 1 };
2279 app.mode = Mode::Select;
2280
2281 app.copy_selection_or_cell();
2282 assert_eq!(app.populated_swatch_count(), 1);
2283 assert!(app.floating.is_none());
2284 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), 'A');
2285
2286 app.copy_selection_or_cell();
2288 assert_eq!(app.populated_swatch_count(), 2);
2289 assert!(app.floating.is_none());
2290 }
2291
2292 #[test]
2293 fn cut_pushes_swatch_and_clears_canvas() {
2294 let mut app = App::new();
2295 app.canvas.set(Pos { x: 1, y: 1 }, 'X');
2296 app.canvas.set(Pos { x: 2, y: 1 }, 'Y');
2297 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2298 app.cursor = Pos { x: 2, y: 1 };
2299 app.mode = Mode::Select;
2300
2301 app.cut_selection_or_cell();
2302 assert_eq!(app.populated_swatch_count(), 1);
2303 assert!(app.floating.is_none());
2304 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), ' ');
2305 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), ' ');
2306 }
2307
2308 #[test]
2309 fn swatch_history_newest_first_and_capped() {
2310 let mut app = App::new();
2311 for (i, ch) in ['A', 'B', 'C', 'D', 'E', 'F'].iter().enumerate() {
2312 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2313 app.cursor = Pos { x: i, y: 0 };
2314 app.copy_selection_or_cell();
2315 }
2316
2317 assert_eq!(app.swatches.iter().filter(|s| s.is_some()).count(), 5);
2318 assert_eq!(
2320 app.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2321 Some(CellValue::Narrow('F'))
2322 );
2323 assert_eq!(
2325 app.swatches[4].as_ref().unwrap().clipboard.get(0, 0),
2326 Some(CellValue::Narrow('B'))
2327 );
2328 }
2329
2330 #[test]
2331 fn pinned_swatch_holds_slot_when_history_rotates() {
2332 let mut app = App::new();
2333 for (i, ch) in ['A', 'B', 'C'].iter().enumerate() {
2334 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2335 app.cursor = Pos { x: i, y: 0 };
2336 app.copy_selection_or_cell();
2337 }
2338 assert_eq!(
2340 app.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2341 Some(CellValue::Narrow('B'))
2342 );
2343 app.toggle_pin(1);
2344 assert!(app.swatches[1].as_ref().unwrap().pinned);
2345
2346 for (i, ch) in ['D', 'E', 'F'].iter().enumerate() {
2348 app.canvas.set(Pos { x: 10 + i, y: 0 }, *ch);
2349 app.cursor = Pos { x: 10 + i, y: 0 };
2350 app.copy_selection_or_cell();
2351 }
2352
2353 assert_eq!(
2355 app.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2356 Some(CellValue::Narrow('B'))
2357 );
2358 assert!(app.swatches[1].as_ref().unwrap().pinned);
2359 assert_eq!(
2361 app.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2362 Some(CellValue::Narrow('F'))
2363 );
2364 }
2365
2366 #[test]
2367 fn all_pinned_swatches_reject_new_push() {
2368 let mut app = App::new();
2369 for (i, ch) in ['A', 'B', 'C', 'D', 'E'].iter().enumerate() {
2370 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2371 app.cursor = Pos { x: i, y: 0 };
2372 app.copy_selection_or_cell();
2373 }
2374 for i in 0..SWATCH_CAPACITY {
2375 app.toggle_pin(i);
2376 }
2377 let before: Vec<_> = app
2378 .swatches
2379 .iter()
2380 .map(|s| s.as_ref().unwrap().clipboard.get(0, 0))
2381 .collect();
2382
2383 app.canvas.set(Pos { x: 20, y: 0 }, 'Z');
2384 app.cursor = Pos { x: 20, y: 0 };
2385 app.copy_selection_or_cell();
2386
2387 let after: Vec<_> = app
2388 .swatches
2389 .iter()
2390 .map(|s| s.as_ref().unwrap().clipboard.get(0, 0))
2391 .collect();
2392 assert_eq!(before, after, "all-pinned strip should reject new copies");
2393 }
2394
2395 #[test]
2396 fn ctrl_home_row_activates_swatch() {
2397 let mut app = App::new();
2398 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2399 app.cursor = Pos { x: 0, y: 0 };
2400 app.copy_selection_or_cell();
2401
2402 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2403 assert!(app.floating.is_some());
2404 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2405 }
2406
2407 #[test]
2408 fn ctrl_home_row_while_floating_switches_or_cycles_swatch() {
2409 let mut app = App::new();
2410 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2411 app.cursor = Pos { x: 0, y: 0 };
2412 app.copy_selection_or_cell();
2413 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2414 app.cursor = Pos { x: 1, y: 0 };
2415 app.copy_selection_or_cell();
2416
2417 app.activate_swatch(1); assert_eq!(app.floating.as_ref().unwrap().source_index, Some(1));
2419
2420 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2422 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2423 assert!(!app.floating.as_ref().unwrap().transparent);
2424
2425 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2427 assert!(app.floating.as_ref().unwrap().transparent);
2428 }
2429
2430 #[test]
2431 fn bare_digit_draws_even_while_floating() {
2432 let mut app = App::new();
2433 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2434 app.cursor = Pos { x: 0, y: 0 };
2435 app.copy_selection_or_cell();
2436 app.activate_swatch(0);
2437 assert!(app.floating.is_some());
2438
2439 app.cursor = Pos { x: 5, y: 5 };
2441 app.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
2442 assert!(app.floating.is_none());
2443 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), '1');
2444 }
2445
2446 #[test]
2447 fn activate_swatch_enters_floating_from_history() {
2448 let mut app = App::new();
2449 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
2450 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
2451 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2452 app.cursor = Pos { x: 2, y: 1 };
2453 app.mode = Mode::Select;
2454
2455 app.copy_selection_or_cell();
2456 app.activate_swatch(0);
2457
2458 assert!(app.floating.is_some());
2459 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2460 assert!(!app.mode.is_selecting());
2461 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), 'A');
2462 }
2463
2464 #[test]
2465 fn activate_same_swatch_again_toggles_transparency() {
2466 let mut app = App::new();
2467 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2468 app.cursor = Pos { x: 0, y: 0 };
2469 app.copy_selection_or_cell();
2470
2471 app.activate_swatch(0);
2472 assert!(!app.floating.as_ref().unwrap().transparent);
2473
2474 app.activate_swatch(0);
2475 assert!(app.floating.as_ref().unwrap().transparent);
2476
2477 app.activate_swatch(0);
2478 assert!(!app.floating.as_ref().unwrap().transparent);
2479 }
2480
2481 #[test]
2482 fn activate_different_swatch_switches_to_opaque() {
2483 let mut app = App::new();
2484 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2485 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2486
2487 app.cursor = Pos { x: 0, y: 0 };
2488 app.copy_selection_or_cell();
2489 app.cursor = Pos { x: 1, y: 0 };
2490 app.copy_selection_or_cell();
2491
2492 app.activate_swatch(0);
2493 app.activate_swatch(0); assert!(app.floating.as_ref().unwrap().transparent);
2495
2496 app.activate_swatch(1); assert_eq!(app.floating.as_ref().unwrap().source_index, Some(1));
2498 assert!(!app.floating.as_ref().unwrap().transparent);
2499 }
2500
2501 #[test]
2502 fn ctrl_t_toggles_transparency_while_floating() {
2503 let mut app = App::new();
2504 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2505 app.cursor = Pos { x: 0, y: 0 };
2506 app.copy_selection_or_cell();
2507 app.activate_swatch(0);
2508
2509 assert!(!app.floating.as_ref().unwrap().transparent);
2510
2511 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2512 assert!(app.floating.as_ref().unwrap().transparent);
2513
2514 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2515 assert!(!app.floating.as_ref().unwrap().transparent);
2516 }
2517
2518 #[test]
2519 fn stamp_floating_writes_to_canvas() {
2520 let mut app = App::new();
2521 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2522 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2523 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2524 app.cursor = Pos { x: 1, y: 0 };
2525 app.mode = Mode::Select;
2526
2527 app.copy_selection_or_cell();
2528 app.activate_swatch(0);
2529
2530 app.cursor = Pos { x: 5, y: 3 };
2531 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2532
2533 assert!(app.floating.is_some());
2534 assert_eq!(app.canvas.get(Pos { x: 5, y: 3 }), 'A');
2535 assert_eq!(app.canvas.get(Pos { x: 6, y: 3 }), 'B');
2536 }
2537
2538 #[test]
2539 fn esc_dismisses_float_without_stamping() {
2540 let mut app = App::new();
2541 app.canvas.set(Pos { x: 0, y: 0 }, 'Z');
2542 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2543 app.cursor = Pos { x: 0, y: 0 };
2544 app.mode = Mode::Select;
2545
2546 app.copy_selection_or_cell();
2547 app.activate_swatch(0);
2548
2549 app.cursor = Pos { x: 5, y: 5 };
2550 app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2551
2552 assert!(app.floating.is_none());
2553 assert_eq!(app.populated_swatch_count(), 1);
2555 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), ' ');
2556 }
2557
2558 #[test]
2559 fn arrow_keys_nudge_floating_position() {
2560 let mut app = App::new();
2561 app.canvas.set(Pos { x: 3, y: 3 }, 'Q');
2562 app.selection_anchor = Some(Pos { x: 3, y: 3 });
2563 app.cursor = Pos { x: 3, y: 3 };
2564 app.mode = Mode::Select;
2565
2566 app.copy_selection_or_cell();
2567 app.activate_swatch(0);
2568
2569 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
2570 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
2571
2572 assert!(app.floating.is_some());
2573 assert_eq!(app.cursor, Pos { x: 4, y: 4 });
2574 }
2575
2576 #[test]
2577 fn mouse_click_stamps_floating() {
2578 let mut app = App::new();
2579 app.set_viewport(Rect::new(0, 0, 20, 10));
2580 app.canvas.set(Pos { x: 0, y: 0 }, 'M');
2581 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2582 app.cursor = Pos { x: 0, y: 0 };
2583 app.mode = Mode::Select;
2584
2585 app.copy_selection_or_cell();
2586 app.activate_swatch(0);
2587
2588 app.handle_event(Event::Mouse(MouseEvent {
2589 kind: MouseEventKind::Down(MouseButton::Left),
2590 column: 7,
2591 row: 4,
2592 modifiers: KeyModifiers::NONE,
2593 }));
2594 app.handle_event(Event::Mouse(MouseEvent {
2595 kind: MouseEventKind::Up(MouseButton::Left),
2596 column: 7,
2597 row: 4,
2598 modifiers: KeyModifiers::NONE,
2599 }));
2600
2601 assert!(app.floating.is_some());
2602 assert_eq!(app.canvas.get(Pos { x: 7, y: 4 }), 'M');
2603 }
2604
2605 #[test]
2606 fn transparent_stamp_preserves_underlying_content() {
2607 let mut app = App::new();
2608 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2609 app.canvas.set(Pos { x: 2, y: 0 }, 'B');
2610 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2611 app.cursor = Pos { x: 2, y: 0 };
2612 app.mode = Mode::Select;
2613
2614 app.copy_selection_or_cell();
2615 app.activate_swatch(0);
2616
2617 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2618 assert!(app.floating.as_ref().unwrap().transparent);
2619
2620 app.canvas.set(Pos { x: 5, y: 5 }, 'Z');
2622
2623 app.cursor = Pos { x: 4, y: 5 };
2625 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2626
2627 assert_eq!(app.canvas.get(Pos { x: 4, y: 5 }), 'A');
2629 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), 'Z');
2630 assert_eq!(app.canvas.get(Pos { x: 6, y: 5 }), 'B');
2631 }
2632
2633 #[test]
2634 fn drag_paints_like_brush_with_single_undo() {
2635 let mut app = App::new();
2636 app.set_viewport(Rect::new(0, 0, 20, 10));
2637 app.canvas.set(Pos { x: 0, y: 0 }, 'X');
2638 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2639 app.cursor = Pos { x: 0, y: 0 };
2640 app.mode = Mode::Select;
2641
2642 app.copy_selection_or_cell();
2643 app.activate_swatch(0);
2644
2645 app.handle_event(Event::Mouse(MouseEvent {
2647 kind: MouseEventKind::Down(MouseButton::Left),
2648 column: 3,
2649 row: 2,
2650 modifiers: KeyModifiers::NONE,
2651 }));
2652 app.handle_event(Event::Mouse(MouseEvent {
2653 kind: MouseEventKind::Drag(MouseButton::Left),
2654 column: 5,
2655 row: 2,
2656 modifiers: KeyModifiers::NONE,
2657 }));
2658 app.handle_event(Event::Mouse(MouseEvent {
2659 kind: MouseEventKind::Drag(MouseButton::Left),
2660 column: 7,
2661 row: 2,
2662 modifiers: KeyModifiers::NONE,
2663 }));
2664 app.handle_event(Event::Mouse(MouseEvent {
2665 kind: MouseEventKind::Up(MouseButton::Left),
2666 column: 7,
2667 row: 2,
2668 modifiers: KeyModifiers::NONE,
2669 }));
2670
2671 assert_eq!(app.canvas.get(Pos { x: 3, y: 2 }), 'X');
2673 assert_eq!(app.canvas.get(Pos { x: 5, y: 2 }), 'X');
2674 assert_eq!(app.canvas.get(Pos { x: 7, y: 2 }), 'X');
2675
2676 assert!(app.floating.is_some());
2678
2679 app.undo();
2681 assert_eq!(app.canvas.get(Pos { x: 3, y: 2 }), ' ');
2682 assert_eq!(app.canvas.get(Pos { x: 5, y: 2 }), ' ');
2683 assert_eq!(app.canvas.get(Pos { x: 7, y: 2 }), ' ');
2684 }
2685
2686 #[test]
2687 fn repeated_ctrl_v_stamps_create_separate_undos() {
2688 let mut app = App::new();
2689 app.canvas.set(Pos { x: 0, y: 0 }, 'Q');
2690 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2691 app.cursor = Pos { x: 0, y: 0 };
2692 app.mode = Mode::Select;
2693
2694 app.copy_selection_or_cell();
2695 app.activate_swatch(0);
2696
2697 app.cursor = Pos { x: 3, y: 3 };
2699 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2700 app.cursor = Pos { x: 6, y: 6 };
2701 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2702
2703 assert_eq!(app.canvas.get(Pos { x: 3, y: 3 }), 'Q');
2704 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), 'Q');
2705
2706 app.undo();
2708 assert_eq!(app.canvas.get(Pos { x: 3, y: 3 }), 'Q');
2709 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), ' ');
2710 }
2711
2712 #[test]
2713 fn horizontal_drag_with_wide_brush_skips_overlapping_cells() {
2714 let mut app = setup_floating_wide_brush();
2715
2716 app.handle_event(Event::Mouse(MouseEvent {
2717 kind: MouseEventKind::Down(MouseButton::Left),
2718 column: 3,
2719 row: 2,
2720 modifiers: KeyModifiers::NONE,
2721 }));
2722 app.handle_event(Event::Mouse(MouseEvent {
2723 kind: MouseEventKind::Drag(MouseButton::Left),
2724 column: 4,
2725 row: 2,
2726 modifiers: KeyModifiers::NONE,
2727 }));
2728 app.handle_event(Event::Mouse(MouseEvent {
2729 kind: MouseEventKind::Drag(MouseButton::Left),
2730 column: 5,
2731 row: 2,
2732 modifiers: KeyModifiers::NONE,
2733 }));
2734 app.handle_event(Event::Mouse(MouseEvent {
2735 kind: MouseEventKind::Up(MouseButton::Left),
2736 column: 5,
2737 row: 2,
2738 modifiers: KeyModifiers::NONE,
2739 }));
2740
2741 assert_eq!(
2742 app.canvas.cell(Pos { x: 3, y: 2 }),
2743 Some(CellValue::Wide('🌱'))
2744 );
2745 assert_eq!(
2746 app.canvas.cell(Pos { x: 4, y: 2 }),
2747 Some(CellValue::WideCont)
2748 );
2749 assert_eq!(
2750 app.canvas.cell(Pos { x: 5, y: 2 }),
2751 Some(CellValue::Wide('🌱'))
2752 );
2753 assert_eq!(
2754 app.canvas.cell(Pos { x: 6, y: 2 }),
2755 Some(CellValue::WideCont)
2756 );
2757 }
2758
2759 #[test]
2760 fn diagonal_drag_with_wide_brush_does_not_emit_horizontal_rays() {
2761 let mut app = setup_floating_wide_brush();
2762
2763 app.handle_event(Event::Mouse(MouseEvent {
2764 kind: MouseEventKind::Down(MouseButton::Left),
2765 column: 12,
2766 row: 6,
2767 modifiers: KeyModifiers::NONE,
2768 }));
2769 app.handle_event(Event::Mouse(MouseEvent {
2770 kind: MouseEventKind::Drag(MouseButton::Left),
2771 column: 16,
2772 row: 7,
2773 modifiers: KeyModifiers::NONE,
2774 }));
2775 app.handle_event(Event::Mouse(MouseEvent {
2776 kind: MouseEventKind::Drag(MouseButton::Left),
2777 column: 8,
2778 row: 7,
2779 modifiers: KeyModifiers::NONE,
2780 }));
2781 app.handle_event(Event::Mouse(MouseEvent {
2782 kind: MouseEventKind::Up(MouseButton::Left),
2783 column: 8,
2784 row: 7,
2785 modifiers: KeyModifiers::NONE,
2786 }));
2787
2788 assert_eq!(
2789 app.canvas.cell(Pos { x: 12, y: 6 }),
2790 Some(CellValue::Wide('🌱'))
2791 );
2792 assert_eq!(
2793 app.canvas.cell(Pos { x: 13, y: 6 }),
2794 Some(CellValue::WideCont)
2795 );
2796 assert_eq!(
2797 app.canvas.cell(Pos { x: 16, y: 7 }),
2798 Some(CellValue::Wide('🌱'))
2799 );
2800 assert_eq!(
2801 app.canvas.cell(Pos { x: 17, y: 7 }),
2802 Some(CellValue::WideCont)
2803 );
2804 assert_eq!(
2805 app.canvas.cell(Pos { x: 8, y: 7 }),
2806 Some(CellValue::Wide('🌱'))
2807 );
2808 assert_eq!(
2809 app.canvas.cell(Pos { x: 9, y: 7 }),
2810 Some(CellValue::WideCont)
2811 );
2812 assert_eq!(app.canvas.get(Pos { x: 10, y: 7 }), ' ');
2813 assert_eq!(app.canvas.get(Pos { x: 12, y: 7 }), ' ');
2814 }
2815
2816 #[test]
2817 fn wide_brush_same_row_jump_does_not_fill_intermediate_cells() {
2818 let mut app = setup_floating_wide_brush();
2819
2820 app.handle_event(Event::Mouse(MouseEvent {
2821 kind: MouseEventKind::Down(MouseButton::Left),
2822 column: 12,
2823 row: 6,
2824 modifiers: KeyModifiers::NONE,
2825 }));
2826 app.handle_event(Event::Mouse(MouseEvent {
2827 kind: MouseEventKind::Drag(MouseButton::Left),
2828 column: 4,
2829 row: 6,
2830 modifiers: KeyModifiers::NONE,
2831 }));
2832 app.handle_event(Event::Mouse(MouseEvent {
2833 kind: MouseEventKind::Up(MouseButton::Left),
2834 column: 4,
2835 row: 6,
2836 modifiers: KeyModifiers::NONE,
2837 }));
2838
2839 assert_eq!(
2840 app.canvas.cell(Pos { x: 12, y: 6 }),
2841 Some(CellValue::Wide('🌱'))
2842 );
2843 assert_eq!(
2844 app.canvas.cell(Pos { x: 13, y: 6 }),
2845 Some(CellValue::WideCont)
2846 );
2847 assert_eq!(
2848 app.canvas.cell(Pos { x: 4, y: 6 }),
2849 Some(CellValue::Wide('🌱'))
2850 );
2851 assert_eq!(
2852 app.canvas.cell(Pos { x: 5, y: 6 }),
2853 Some(CellValue::WideCont)
2854 );
2855 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), ' ');
2856 assert_eq!(app.canvas.get(Pos { x: 10, y: 6 }), ' ');
2857 }
2858
2859 #[test]
2860 fn shallow_diagonal_drag_with_wide_brush_fills_more_evenly() {
2861 let mut app = setup_floating_wide_brush();
2862
2863 app.handle_event(Event::Mouse(MouseEvent {
2864 kind: MouseEventKind::Down(MouseButton::Left),
2865 column: 3,
2866 row: 2,
2867 modifiers: KeyModifiers::NONE,
2868 }));
2869 app.handle_event(Event::Mouse(MouseEvent {
2870 kind: MouseEventKind::Drag(MouseButton::Left),
2871 column: 9,
2872 row: 3,
2873 modifiers: KeyModifiers::NONE,
2874 }));
2875 app.handle_event(Event::Mouse(MouseEvent {
2876 kind: MouseEventKind::Up(MouseButton::Left),
2877 column: 9,
2878 row: 3,
2879 modifiers: KeyModifiers::NONE,
2880 }));
2881
2882 assert_eq!(
2883 app.canvas.cell(Pos { x: 3, y: 2 }),
2884 Some(CellValue::Wide('🌱'))
2885 );
2886 assert_eq!(
2887 app.canvas.cell(Pos { x: 5, y: 2 }),
2888 Some(CellValue::Wide('🌱'))
2889 );
2890 assert_eq!(
2891 app.canvas.cell(Pos { x: 6, y: 3 }),
2892 Some(CellValue::Wide('🌱'))
2893 );
2894 assert_eq!(
2895 app.canvas.cell(Pos { x: 8, y: 3 }),
2896 Some(CellValue::Wide('🌱'))
2897 );
2898 }
2899
2900 #[test]
2901 fn shallow_wide_brush_diagonal_sweep_keeps_row_gaps_within_brush_width() {
2902 for start_x in [2_u16, 3_u16] {
2903 for end_x in (start_x + 3)..=24 {
2904 let mut app = setup_floating_wide_brush();
2905
2906 app.handle_event(Event::Mouse(MouseEvent {
2907 kind: MouseEventKind::Down(MouseButton::Left),
2908 column: start_x,
2909 row: 2,
2910 modifiers: KeyModifiers::NONE,
2911 }));
2912 app.handle_event(Event::Mouse(MouseEvent {
2913 kind: MouseEventKind::Drag(MouseButton::Left),
2914 column: end_x,
2915 row: 3,
2916 modifiers: KeyModifiers::NONE,
2917 }));
2918 app.handle_event(Event::Mouse(MouseEvent {
2919 kind: MouseEventKind::Up(MouseButton::Left),
2920 column: end_x,
2921 row: 3,
2922 modifiers: KeyModifiers::NONE,
2923 }));
2924
2925 let row_two = wide_origins_in_row(&app, 2, end_x as usize + 2);
2926 let row_three = wide_origins_in_row(&app, 3, end_x as usize + 2);
2927
2928 assert!(
2929 !row_two.is_empty(),
2930 "row 2 empty for start_x={start_x}, end_x={end_x}"
2931 );
2932 assert!(
2933 !row_three.is_empty(),
2934 "row 3 empty for start_x={start_x}, end_x={end_x}"
2935 );
2936 assert!(
2937 row_two.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2938 "row 2 gap too large for start_x={start_x}, end_x={end_x}: {row_two:?}"
2939 );
2940 assert!(
2941 row_three.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2942 "row 3 gap too large for start_x={start_x}, end_x={end_x}: {row_three:?}"
2943 );
2944 }
2945 }
2946 }
2947
2948 #[test]
2949 fn shallow_diagonal_with_same_row_micro_steps_keeps_visible_progress() {
2950 for start_x in [3_u16, 4_u16] {
2951 let mut app = setup_floating_wide_brush();
2952
2953 app.handle_event(Event::Mouse(MouseEvent {
2954 kind: MouseEventKind::Down(MouseButton::Left),
2955 column: start_x,
2956 row: 2,
2957 modifiers: KeyModifiers::NONE,
2958 }));
2959 app.handle_event(Event::Mouse(MouseEvent {
2960 kind: MouseEventKind::Drag(MouseButton::Left),
2961 column: start_x + 4,
2962 row: 3,
2963 modifiers: KeyModifiers::NONE,
2964 }));
2965 for column in (start_x + 5)..=(start_x + 11) {
2966 app.handle_event(Event::Mouse(MouseEvent {
2967 kind: MouseEventKind::Drag(MouseButton::Left),
2968 column,
2969 row: 3,
2970 modifiers: KeyModifiers::NONE,
2971 }));
2972 }
2973 app.handle_event(Event::Mouse(MouseEvent {
2974 kind: MouseEventKind::Up(MouseButton::Left),
2975 column: start_x + 11,
2976 row: 3,
2977 modifiers: KeyModifiers::NONE,
2978 }));
2979
2980 let row_three = wide_origins_in_row(&app, 3, (start_x + 13) as usize);
2981 assert!(
2982 row_three.len() >= 4,
2983 "expected multiple visible stamps on shallow row for start_x={start_x}: {row_three:?}"
2984 );
2985 assert!(
2986 row_three.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2987 "row 3 gap too large for start_x={start_x}: {row_three:?}"
2988 );
2989 }
2990 }
2991
2992 #[test]
2993 fn system_clipboard_export_uses_selection_when_present() {
2994 let mut app = App::new();
2995 app.canvas.width = 4;
2996 app.canvas.height = 3;
2997 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
2998 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
2999 app.canvas.set(Pos { x: 1, y: 2 }, 'C');
3000 app.canvas.set(Pos { x: 2, y: 2 }, 'D');
3001 app.selection_anchor = Some(Pos { x: 1, y: 1 });
3002 app.cursor = Pos { x: 2, y: 2 };
3003 app.mode = Mode::Select;
3004
3005 assert_eq!(app.export_system_clipboard_text(), "AB\nCD");
3006 }
3007
3008 #[test]
3009 fn system_clipboard_export_uses_full_canvas_without_selection() {
3010 let mut app = App::new();
3011 app.canvas.width = 3;
3012 app.canvas.height = 2;
3013 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
3014 app.canvas.set(Pos { x: 2, y: 1 }, 'Z');
3015
3016 assert_eq!(app.export_system_clipboard_text(), "A \n Z");
3017 }
3018
3019 #[test]
3020 fn intent_api_emits_copy_effect_for_alt_c() {
3021 let mut app = App::new();
3022 app.canvas.width = 1;
3023 app.canvas.height = 1;
3024 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
3025
3026 let effects = app.handle_intent(AppIntent::KeyPress(AppKey {
3027 code: AppKeyCode::Char('c'),
3028 modifiers: AppModifiers {
3029 alt: true,
3030 ..Default::default()
3031 },
3032 }));
3033
3034 assert_eq!(effects, vec![HostEffect::CopyToClipboard("A".to_string())]);
3035 }
3036}