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 => {
919 if self.emoji_picker_state.search_cursor > 0 {
920 let byte_pos = self
921 .emoji_picker_state
922 .search_query
923 .char_indices()
924 .nth(self.emoji_picker_state.search_cursor - 1)
925 .map(|(i, _)| i)
926 .unwrap_or(0);
927 self.emoji_picker_state.search_query.remove(byte_pos);
928 self.emoji_picker_state.search_cursor -= 1;
929 self.emoji_picker_state.selected_index = 0;
930 self.emoji_picker_state.scroll_offset = 0;
931 }
932 }
933 AppKeyCode::Left => {
934 self.emoji_picker_state.search_cursor =
935 self.emoji_picker_state.search_cursor.saturating_sub(1);
936 }
937 AppKeyCode::Right => {
938 let len = self.emoji_picker_state.search_query.chars().count();
939 if self.emoji_picker_state.search_cursor < len {
940 self.emoji_picker_state.search_cursor += 1;
941 }
942 }
943 AppKeyCode::Up => self.picker_move_selection(-1),
944 AppKeyCode::Down => self.picker_move_selection(1),
945 AppKeyCode::PageUp => {
946 let page = self.emoji_picker_state.visible_height.get().max(1) as isize;
947 self.picker_move_selection(-page);
948 }
949 AppKeyCode::PageDown => {
950 let page = self.emoji_picker_state.visible_height.get().max(1) as isize;
951 self.picker_move_selection(page);
952 }
953 AppKeyCode::Char(ch)
954 if !key.modifiers.ctrl && !key.modifiers.has_alt_like() && !ch.is_control() =>
955 {
956 let byte_pos = self
957 .emoji_picker_state
958 .search_query
959 .char_indices()
960 .nth(self.emoji_picker_state.search_cursor)
961 .map(|(i, _)| i)
962 .unwrap_or(self.emoji_picker_state.search_query.len());
963 self.emoji_picker_state.search_query.insert(byte_pos, ch);
964 self.emoji_picker_state.search_cursor += 1;
965 self.emoji_picker_state.selected_index = 0;
966 self.emoji_picker_state.scroll_offset = 0;
967 }
968 _ => {}
969 }
970 }
971
972 fn handle_picker_mouse(&mut self, mouse: AppPointerEvent) {
973 match mouse.kind {
974 AppPointerKind::Down(AppPointerButton::Left) => {
975 let row_0based = mouse.row;
976 let col_0based = mouse.column;
977
978 let tabs = self.emoji_picker_state.tabs_inner.get();
979 if tabs.height > 0 && row_0based >= tabs.y && row_0based < tabs.y + tabs.height {
980 if let Some(idx) = emoji::picker::tab_at_x(tabs, col_0based) {
981 self.emoji_picker_state.tab.set_index(idx);
982 self.emoji_picker_state.selected_index = 0;
983 self.emoji_picker_state.scroll_offset = 0;
984 self.emoji_picker_state.last_click = None;
985 return;
986 }
987 }
988
989 let list = self.emoji_picker_state.list_inner.get();
990 if list.height == 0 || row_0based < list.y || row_0based >= list.y + list.height {
991 return;
992 }
993 let offset_in_list = (row_0based - list.y) as usize;
994 let flat_idx = self.emoji_picker_state.scroll_offset + offset_in_list;
995
996 let Some(catalog) = self.icon_catalog.as_ref() else {
997 return;
998 };
999 let tab = *self.emoji_picker_state.tab.current();
1000 let sections = catalog.sections(tab.index(), &self.emoji_picker_state.search_query);
1001 let Some(selectable_idx) = emoji::picker::flat_to_selectable(§ions, flat_idx)
1002 else {
1003 return;
1004 };
1005
1006 let now = std::time::Instant::now();
1007 let is_double = match self.emoji_picker_state.last_click {
1008 Some((prev, prev_idx)) => {
1009 prev_idx == selectable_idx
1010 && now.duration_since(prev).as_millis() <= emoji::DOUBLE_CLICK_WINDOW_MS
1011 }
1012 None => false,
1013 };
1014
1015 self.emoji_picker_state.selected_index = selectable_idx;
1016 Self::adjust_picker_scroll(&mut self.emoji_picker_state, catalog);
1017
1018 if is_double {
1019 self.emoji_picker_state.last_click = None;
1020 self.picker_insert_selected(true);
1021 } else {
1022 self.emoji_picker_state.last_click = Some((now, selectable_idx));
1023 }
1024 }
1025 AppPointerKind::ScrollDown => self.picker_move_selection(3),
1026 AppPointerKind::ScrollUp => self.picker_move_selection(-3),
1027 _ => {}
1028 }
1029 }
1030
1031 fn paste_text_block(&mut self, text: &str) {
1032 let color = self.active_user_color();
1033 let before = self.canvas.clone();
1034 let editor = self.editor_session_snapshot();
1035 let changed = editor_paste_text_block(&editor, &mut self.canvas, text, color);
1036 if changed {
1037 self.finish_canvas_edit(before);
1038 }
1039 }
1040
1041 #[cfg(test)]
1042 fn backspace(&mut self) {
1043 let before = self.canvas.clone();
1044 let changed = self.with_editor_and_canvas_mut(editor_backspace);
1045 if changed {
1046 self.finish_canvas_edit(before);
1047 }
1048 }
1049
1050 fn is_open_picker_key(key: AppKey) -> bool {
1051 matches!(
1052 key.code,
1053 AppKeyCode::Char(']') if key.modifiers.ctrl
1054 ) || matches!(
1055 key.code,
1056 AppKeyCode::Char('5') if key.modifiers.ctrl
1057 ) || matches!(key.code, AppKeyCode::Char('\u{1d}'))
1058 }
1059
1060 pub fn tick(&mut self) {
1061 self.drain_server_events();
1062 }
1063
1064 pub fn handle_event(&mut self, event: Event) {
1065 if let Some(intent) = app_intent_from_crossterm(event) {
1066 let effects = self.handle_intent(intent);
1067 self.apply_host_effects(effects);
1068 } else {
1069 self.tick();
1070 }
1071 }
1072
1073 pub fn handle_intent(&mut self, intent: AppIntent) -> Vec<HostEffect> {
1074 let effects = self.handle_intent_inner(intent);
1075 self.clamp_cursor();
1076 self.tick();
1077 effects
1078 }
1079
1080 fn apply_host_effects(&mut self, effects: Vec<HostEffect>) {
1081 for effect in effects {
1082 match effect {
1083 HostEffect::RequestQuit => self.should_quit = true,
1084 HostEffect::CopyToClipboard(text) => {
1085 let _ = execute!(io::stdout(), CopyToClipboard::to_clipboard_from(text));
1086 }
1087 }
1088 }
1089 }
1090
1091 fn handle_intent_inner(&mut self, intent: AppIntent) -> Vec<HostEffect> {
1092 match intent {
1093 AppIntent::KeyPress(key) => self.handle_key_input(key),
1094 AppIntent::Pointer(mouse) => {
1095 self.handle_pointer_input(mouse);
1096 Vec::new()
1097 }
1098 AppIntent::Paste(data) => {
1099 if !self.show_help {
1100 self.paste_text_block(&data);
1101 }
1102 Vec::new()
1103 }
1104 }
1105 }
1106
1107 fn handle_key_input(&mut self, key: AppKey) -> Vec<HostEffect> {
1108 if Self::is_open_picker_key(key) {
1109 self.open_emoji_picker();
1110 return Vec::new();
1111 }
1112
1113 if self.emoji_picker_open {
1114 self.handle_picker_key(key);
1115 return Vec::new();
1116 }
1117
1118 if key.code == AppKeyCode::Char('q') && key.modifiers.ctrl {
1119 return vec![HostEffect::RequestQuit];
1120 }
1121
1122 if self.show_help {
1123 match key.code {
1124 AppKeyCode::Esc | AppKeyCode::F(1) => self.show_help = false,
1125 AppKeyCode::Char('p') if key.modifiers.ctrl => self.show_help = false,
1126 AppKeyCode::Tab | AppKeyCode::Right => {
1127 self.help_tab = self.help_tab.next();
1128 self.help_scroll = 0;
1129 }
1130 AppKeyCode::BackTab | AppKeyCode::Left => {
1131 self.help_tab = self.help_tab.prev();
1132 self.help_scroll = 0;
1133 }
1134 AppKeyCode::Down | AppKeyCode::Char('j') => {
1135 self.help_scroll = self.help_scroll.saturating_add(1);
1136 }
1137 AppKeyCode::Up | AppKeyCode::Char('k') => {
1138 self.help_scroll = self.help_scroll.saturating_sub(1);
1139 }
1140 AppKeyCode::PageDown => {
1141 self.help_scroll = self.help_scroll.saturating_add(5);
1142 }
1143 AppKeyCode::PageUp => {
1144 self.help_scroll = self.help_scroll.saturating_sub(5);
1145 }
1146 AppKeyCode::Home => self.help_scroll = 0,
1147 _ => {}
1148 }
1149 return Vec::new();
1150 }
1151
1152 if key.code == AppKeyCode::Tab && key.modifiers == AppModifiers::default() {
1153 self.switch_active_user(1);
1154 return Vec::new();
1155 }
1156
1157 if key.code == AppKeyCode::BackTab {
1158 self.switch_active_user(-1);
1159 return Vec::new();
1160 }
1161
1162 if (key.code == AppKeyCode::Char('p') && key.modifiers.ctrl) || key.code == AppKeyCode::F(1)
1163 {
1164 self.show_help = !self.show_help;
1165 return Vec::new();
1166 }
1167
1168 self.handle_key_press(key)
1169 }
1170
1171 fn handle_pointer_input(&mut self, mouse: AppPointerEvent) {
1172 if self.emoji_picker_open {
1173 self.handle_picker_mouse(mouse);
1174 return;
1175 }
1176
1177 if self.show_help {
1178 if matches!(mouse.kind, AppPointerKind::Down(AppPointerButton::Left)) {
1179 if let Some(tab) = self.help_tab_hit(mouse.column, mouse.row) {
1180 if self.help_tab != tab {
1181 self.help_scroll = 0;
1182 }
1183 self.help_tab = tab;
1184 }
1185 }
1186 return;
1187 }
1188
1189 if matches!(mouse.kind, AppPointerKind::Down(AppPointerButton::Left)) {
1190 if let Some((idx, zone)) = self.swatch_hit(mouse.column, mouse.row) {
1191 match zone {
1192 SwatchZone::Pin => self.toggle_pin(idx),
1193 SwatchZone::Body => self.activate_swatch(idx),
1194 }
1195 return;
1196 }
1197 }
1198
1199 let color = self.active_user_color();
1200 let before = self.canvas.clone();
1201 let dispatch = self.with_editor_and_canvas_mut(|editor, canvas| {
1202 editor_handle_pointer(editor, canvas, mouse, color)
1203 });
1204
1205 if matches!(dispatch.stroke_hint, Some(PointerStrokeHint::Begin)) {
1208 self.paint_canvas_before = Some(before.clone());
1209 }
1210
1211 if self.canvas != before {
1212 self.finish_canvas_edit(before);
1213 }
1214
1215 if matches!(dispatch.stroke_hint, Some(PointerStrokeHint::End)) {
1216 self.end_paint_stroke();
1217 }
1218 }
1219
1220 fn handle_key_press(&mut self, key: AppKey) -> Vec<HostEffect> {
1221 let ctx = EditorContext {
1222 mode: self.mode,
1223 has_selection_anchor: self.selection_anchor.is_some(),
1224 is_floating: self.floating.is_some(),
1225 };
1226 let action = KeyMap::default_standalone().resolve(key, ctx);
1227
1228 if self.floating.is_some() {
1229 match self.apply_floating_override(action) {
1230 FloatingOutcome::Consumed => return Vec::new(),
1231 FloatingOutcome::PassThrough | FloatingOutcome::DismissAndContinue => {}
1232 }
1233 }
1234
1235 if key.modifiers.ctrl && key.code == AppKeyCode::Char('r') {
1236 self.redo();
1237 return Vec::new();
1238 }
1239
1240 if key.modifiers.ctrl && key.code == AppKeyCode::Char('z') {
1241 self.undo();
1242 return Vec::new();
1243 }
1244
1245 let Some(action) = action else {
1246 return Vec::new();
1247 };
1248
1249 let color = self.active_user_color();
1250 let before = self.canvas.clone();
1251 let dispatch = self.with_editor_and_canvas_mut(|editor, canvas| {
1252 editor_handle_action(editor, canvas, action, color)
1253 });
1254 if self.canvas != before {
1255 self.finish_canvas_edit(before);
1256 }
1257 dispatch.effects
1258 }
1259
1260 fn apply_floating_override(&mut self, action: Option<EditorAction>) -> FloatingOutcome {
1261 match action {
1262 Some(EditorAction::ActivateSwatch(idx)) => {
1263 self.activate_swatch(idx);
1264 FloatingOutcome::Consumed
1265 }
1266 Some(EditorAction::PastePrimarySwatch) => {
1267 self.stamp_floating();
1268 FloatingOutcome::Consumed
1269 }
1270 Some(EditorAction::CopySelection) | Some(EditorAction::CutSelection) => {
1271 FloatingOutcome::Consumed
1272 }
1273 Some(EditorAction::ClearSelection) => {
1274 self.dismiss_floating();
1275 FloatingOutcome::Consumed
1276 }
1277 Some(EditorAction::Move {
1278 dir: MoveDir::Up, ..
1279 }) => {
1280 self.move_up();
1281 FloatingOutcome::Consumed
1282 }
1283 Some(EditorAction::Move {
1284 dir: MoveDir::Down, ..
1285 }) => {
1286 self.move_down();
1287 FloatingOutcome::Consumed
1288 }
1289 Some(EditorAction::Move {
1290 dir: MoveDir::Left, ..
1291 }) => {
1292 self.move_left();
1293 FloatingOutcome::Consumed
1294 }
1295 Some(EditorAction::Move {
1296 dir: MoveDir::Right,
1297 ..
1298 }) => {
1299 self.move_right();
1300 FloatingOutcome::Consumed
1301 }
1302 Some(EditorAction::StrokeFloating { .. }) => FloatingOutcome::PassThrough,
1303 Some(EditorAction::Pan { .. })
1304 | Some(EditorAction::ExportSystemClipboard)
1305 | Some(EditorAction::ToggleFloatingTransparency) => FloatingOutcome::PassThrough,
1306 _ => {
1307 self.dismiss_floating();
1308 FloatingOutcome::DismissAndContinue
1309 }
1310 }
1311 }
1312
1313 #[cfg(test)]
1314 fn handle_key(&mut self, key: KeyEvent) {
1315 let Some(key) = app_key_from_crossterm(key) else {
1316 return;
1317 };
1318 let _ = self.handle_key_press(key);
1319 self.clamp_cursor();
1320 }
1321
1322 #[cfg(test)]
1323 pub fn is_selected(&self, pos: Pos) -> bool {
1324 let Some(selection) = self.selection() else {
1325 return false;
1326 };
1327 selection.contains(pos)
1328 }
1329}
1330
1331fn rect_contains(rect: &Rect, col: u16, row: u16) -> bool {
1332 col >= rect.x && row >= rect.y && col < rect.x + rect.width && row < rect.y + rect.height
1333}
1334
1335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1336enum FloatingOutcome {
1337 Consumed,
1338 PassThrough,
1339 DismissAndContinue,
1340}
1341
1342fn diff_canvas_op(before: &Canvas, after: &Canvas) -> Option<CanvasOp> {
1343 editor_diff_canvas_op(before, after, theme::DEFAULT_GLYPH_FG)
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::{
1349 App, AppIntent, AppKey, AppKeyCode, AppModifiers, AppPointerEvent, AppPointerKind, HelpTab,
1350 HostEffect, Mode, SelectionShape, SWATCH_CAPACITY,
1351 };
1352 use crossterm::event::{
1353 Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
1354 };
1355 use dartboard_core::{Canvas, CellValue, Pos, RgbColor, DEFAULT_HEIGHT, DEFAULT_WIDTH};
1356 use dartboard_editor::{Clipboard, FloatingSelection};
1357 use ratatui::layout::Rect;
1358
1359 fn setup_floating_wide_brush() -> App {
1360 let mut app = App::new();
1361 app.set_viewport(Rect::new(0, 0, 64, 24));
1362 app.canvas.set(Pos { x: 0, y: 0 }, '🌱');
1363 app.selection_anchor = Some(Pos { x: 0, y: 0 });
1364 app.cursor = Pos { x: 0, y: 0 };
1365 app.mode = Mode::Select;
1366 app.copy_selection_or_cell();
1367 app.activate_swatch(0);
1368 app
1369 }
1370
1371 fn wide_origins_in_row(app: &App, y: usize, x_max: usize) -> Vec<usize> {
1372 (0..=x_max)
1373 .filter(|&x| matches!(app.canvas.cell(Pos { x, y }), Some(CellValue::Wide(_))))
1374 .collect()
1375 }
1376
1377 #[test]
1378 fn smart_fill_matches_selection_shape() {
1379 let mut app = App::new();
1380 app.selection_anchor = Some(Pos { x: 2, y: 1 });
1381 app.cursor = Pos { x: 2, y: 3 };
1382 app.mode = Mode::Select;
1383
1384 app.smart_fill();
1385
1386 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), '|');
1387 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), '|');
1388 assert_eq!(app.canvas.get(Pos { x: 2, y: 3 }), '|');
1389 }
1390
1391 #[test]
1392 fn border_draws_ascii_frame() {
1393 let mut app = App::new();
1394 app.selection_anchor = Some(Pos { x: 1, y: 1 });
1395 app.cursor = Pos { x: 4, y: 3 };
1396 app.mode = Mode::Select;
1397
1398 app.draw_border();
1399
1400 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), '.');
1401 assert_eq!(app.canvas.get(Pos { x: 4, y: 1 }), '.');
1402 assert_eq!(app.canvas.get(Pos { x: 1, y: 3 }), '`');
1403 assert_eq!(app.canvas.get(Pos { x: 4, y: 3 }), '\'');
1404 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), '-');
1405 assert_eq!(app.canvas.get(Pos { x: 1, y: 2 }), '|');
1406 }
1407
1408 #[test]
1409 fn cut_and_paste_work_for_selection() {
1410 let mut app = App::new();
1411 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
1412 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
1413 app.canvas.set(Pos { x: 1, y: 2 }, 'C');
1414 app.canvas.set(Pos { x: 2, y: 2 }, 'D');
1415 app.selection_anchor = Some(Pos { x: 1, y: 1 });
1416 app.cursor = Pos { x: 2, y: 2 };
1417 app.mode = Mode::Select;
1418
1419 app.cut_selection_or_cell();
1420
1421 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), ' ');
1422 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), ' ');
1423
1424 app.clear_selection();
1425 app.cursor = Pos { x: 5, y: 4 };
1426 app.paste_clipboard();
1427
1428 assert_eq!(app.canvas.get(Pos { x: 5, y: 4 }), 'A');
1429 assert_eq!(app.canvas.get(Pos { x: 6, y: 4 }), 'B');
1430 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), 'C');
1431 assert_eq!(app.canvas.get(Pos { x: 6, y: 5 }), 'D');
1432 }
1433
1434 #[test]
1435 fn undo_and_redo_restore_canvas_state() {
1436 let mut app = App::new();
1437
1438 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1439 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1440 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1441 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), 'B');
1442
1443 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1444 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1445 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
1446
1447 app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
1448 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1449 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), 'B');
1450 }
1451
1452 #[test]
1453 fn new_edit_clears_redo_history() {
1454 let mut app = App::new();
1455
1456 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1457 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1458 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1459 app.handle_key(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::NONE));
1460 app.handle_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
1461
1462 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1463 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
1464 assert_eq!(app.canvas.get(Pos { x: 2, y: 0 }), 'C');
1465 }
1466
1467 #[test]
1468 fn bracketed_paste_preserves_multiline_shape() {
1469 let mut app = App::new();
1470 app.cursor = Pos { x: 3, y: 4 };
1471
1472 app.handle_event(Event::Paste(".---.\n| |\n`---'".to_string()));
1473
1474 assert_eq!(app.canvas.get(Pos { x: 3, y: 4 }), '.');
1475 assert_eq!(app.canvas.get(Pos { x: 7, y: 4 }), '.');
1476 assert_eq!(app.canvas.get(Pos { x: 3, y: 5 }), '|');
1477 assert_eq!(app.canvas.get(Pos { x: 7, y: 5 }), '|');
1478 assert_eq!(app.canvas.get(Pos { x: 3, y: 6 }), '`');
1479 assert_eq!(app.canvas.get(Pos { x: 7, y: 6 }), '\'');
1480 }
1481
1482 #[test]
1483 fn alt_arrow_keys_pan_viewport() {
1484 let mut app = App::new();
1485 app.set_viewport(Rect::new(0, 0, 10, 5));
1486
1487 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT));
1488 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::ALT));
1489
1490 assert_eq!(app.viewport_origin, Pos { x: 1, y: 1 });
1491 }
1492
1493 #[test]
1494 fn ctrl_shift_arrow_keys_pan_viewport() {
1495 let mut app = App::new();
1496 app.set_viewport(Rect::new(0, 0, 10, 5));
1497 app.cursor = Pos { x: 5, y: 2 };
1498
1499 let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
1500 app.handle_key(KeyEvent::new(KeyCode::Right, mods));
1501 app.handle_key(KeyEvent::new(KeyCode::Down, mods));
1502
1503 assert_eq!(app.viewport_origin, Pos { x: 1, y: 1 });
1504 assert_eq!(app.cursor, Pos { x: 5, y: 2 });
1505 }
1506
1507 #[test]
1508 fn ctrl_shift_arrow_keys_stroke_floating_brush() {
1509 let mut app = App::new();
1510 app.canvas = Canvas::with_size(8, 4);
1511 app.cursor = Pos { x: 2, y: 1 };
1512 app.floating = Some(FloatingSelection {
1513 clipboard: Clipboard::new(1, 1, vec![Some(CellValue::Narrow('A'))]),
1514 transparent: false,
1515 source_index: None,
1516 });
1517
1518 let mods = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
1519 app.handle_key(KeyEvent::new(KeyCode::Right, mods));
1520
1521 assert_eq!(app.cursor, Pos { x: 3, y: 1 });
1522 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), 'A');
1523 assert_eq!(app.canvas.get(Pos { x: 3, y: 1 }), 'A');
1524 assert!(app.floating.is_some());
1525 }
1526
1527 #[test]
1528 fn right_drag_pans_viewport() {
1529 let mut app = App::new();
1530 app.set_viewport(Rect::new(0, 0, 10, 5));
1531
1532 app.handle_event(Event::Mouse(MouseEvent {
1533 kind: MouseEventKind::Down(MouseButton::Right),
1534 column: 5,
1535 row: 2,
1536 modifiers: KeyModifiers::NONE,
1537 }));
1538 app.handle_event(Event::Mouse(MouseEvent {
1539 kind: MouseEventKind::Drag(MouseButton::Right),
1540 column: 2,
1541 row: 1,
1542 modifiers: KeyModifiers::NONE,
1543 }));
1544
1545 assert_eq!(app.viewport_origin, Pos { x: 3, y: 1 });
1546 }
1547
1548 #[test]
1549 fn pointer_intent_scroll_pans_viewport() {
1550 let mut app = App::new();
1551 app.set_viewport(Rect::new(2, 3, 10, 5));
1552 app.viewport_origin = Pos { x: 5, y: 5 };
1553
1554 let _ = app.handle_intent(AppIntent::Pointer(AppPointerEvent {
1555 column: 4,
1556 row: 5,
1557 kind: AppPointerKind::ScrollRight,
1558 modifiers: AppModifiers::default(),
1559 }));
1560 let _ = app.handle_intent(AppIntent::Pointer(AppPointerEvent {
1561 column: 4,
1562 row: 5,
1563 kind: AppPointerKind::ScrollDown,
1564 modifiers: AppModifiers::default(),
1565 }));
1566
1567 assert_eq!(app.viewport_origin, Pos { x: 6, y: 6 });
1568 }
1569
1570 #[test]
1571 fn mouse_mapping_respects_viewport_origin() {
1572 let mut app = App::new();
1573 app.set_viewport(Rect::new(4, 3, 10, 5));
1574 app.viewport_origin = Pos { x: 12, y: 7 };
1575
1576 assert_eq!(app.mouse_to_canvas(6, 4), Some(Pos { x: 14, y: 8 }));
1577 }
1578
1579 #[test]
1580 fn cursor_is_clamped_into_viewport_after_pan() {
1581 let mut app = App::new();
1582 app.set_viewport(Rect::new(0, 0, 10, 5));
1583 app.cursor = Pos { x: 2, y: 2 };
1584
1585 app.pan_by(20, 10);
1586
1587 assert_eq!(app.viewport_origin, Pos { x: 20, y: 10 });
1588 assert_eq!(app.cursor, Pos { x: 20, y: 10 });
1589 }
1590
1591 #[test]
1592 fn resize_clamps_cursor_to_nearest_visible_position() {
1593 let mut app = App::new();
1594 app.viewport_origin = Pos { x: 10, y: 10 };
1595 app.cursor = Pos { x: 18, y: 14 };
1596
1597 app.set_viewport(Rect::new(0, 0, 4, 3));
1598
1599 assert_eq!(app.cursor, Pos { x: 13, y: 12 });
1600 }
1601
1602 #[test]
1603 fn cursor_movement_pans_viewport_at_edge() {
1604 let mut app = App::new();
1605 app.viewport_origin = Pos { x: 10, y: 20 };
1606 app.set_viewport(Rect::new(0, 0, 4, 3));
1607 app.cursor = Pos { x: 13, y: 20 };
1608
1609 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
1610 assert_eq!(app.cursor, Pos { x: 14, y: 20 });
1611 assert_eq!(app.viewport_origin, Pos { x: 11, y: 20 });
1612
1613 app.cursor = Pos { x: 14, y: 22 };
1614 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1615 assert_eq!(app.cursor, Pos { x: 14, y: 23 });
1616 assert_eq!(app.viewport_origin, Pos { x: 11, y: 21 });
1617
1618 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1619 assert_eq!(app.cursor, Pos { x: 13, y: 23 });
1620 assert_eq!(app.viewport_origin, Pos { x: 11, y: 21 });
1621
1622 app.cursor = Pos { x: 11, y: 23 };
1623 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1624 assert_eq!(app.cursor, Pos { x: 10, y: 23 });
1625 assert_eq!(app.viewport_origin, Pos { x: 10, y: 21 });
1626 }
1627
1628 #[test]
1629 fn cursor_stops_at_canvas_edges() {
1630 let mut app = App::new();
1631 app.set_viewport(Rect::new(0, 0, 10, 5));
1632
1633 app.cursor = Pos { x: 0, y: 3 };
1634 app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
1635 assert_eq!(app.cursor, Pos { x: 0, y: 3 });
1636 assert_eq!(app.viewport_origin, Pos { x: 0, y: 0 });
1637
1638 app.cursor = Pos { x: 3, y: 0 };
1639 app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
1640 assert_eq!(app.cursor, Pos { x: 3, y: 0 });
1641 assert_eq!(app.viewport_origin, Pos { x: 0, y: 0 });
1642
1643 let last_x = app.canvas.width - 1;
1644 let last_y = app.canvas.height - 1;
1645
1646 app.cursor = Pos { x: last_x, y: 3 };
1647 app.viewport_origin = Pos {
1648 x: last_x + 1 - app.viewport.width as usize,
1649 y: 0,
1650 };
1651 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
1652 assert_eq!(app.cursor, Pos { x: last_x, y: 3 });
1653
1654 app.cursor = Pos { x: 3, y: last_y };
1655 app.viewport_origin = Pos {
1656 x: 0,
1657 y: last_y + 1 - app.viewport.height as usize,
1658 };
1659 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
1660 assert_eq!(app.cursor, Pos { x: 3, y: last_y });
1661 }
1662
1663 #[test]
1664 fn ctrl_q_quits_even_when_help_is_open() {
1665 let mut app = App::new();
1666 app.show_help = true;
1667
1668 app.handle_event(Event::Key(KeyEvent::new(
1669 KeyCode::Char('q'),
1670 KeyModifiers::CONTROL,
1671 )));
1672
1673 assert!(app.should_quit);
1674 assert!(app.show_help);
1675 }
1676
1677 #[test]
1678 fn intent_api_emits_quit_effect_without_applying_it() {
1679 let mut app = App::new();
1680
1681 let effects = app.handle_intent(AppIntent::KeyPress(AppKey {
1682 code: AppKeyCode::Char('q'),
1683 modifiers: AppModifiers {
1684 ctrl: true,
1685 ..Default::default()
1686 },
1687 }));
1688
1689 assert_eq!(effects, vec![HostEffect::RequestQuit]);
1690 assert!(!app.should_quit);
1691 }
1692
1693 #[test]
1694 fn ctrl_right_bracket_opens_picker() {
1695 let mut app = App::new();
1696
1697 app.handle_event(Event::Key(KeyEvent::new(
1698 KeyCode::Char(']'),
1699 KeyModifiers::CONTROL,
1700 )));
1701
1702 assert!(app.emoji_picker_open);
1703 }
1704
1705 #[test]
1706 fn group_separator_opens_picker() {
1707 let mut app = App::new();
1708
1709 app.handle_event(Event::Key(KeyEvent::new(
1710 KeyCode::Char('\u{1d}'),
1711 KeyModifiers::NONE,
1712 )));
1713
1714 assert!(app.emoji_picker_open);
1715 }
1716
1717 #[test]
1718 fn ctrl_five_opens_picker() {
1719 let mut app = App::new();
1720
1721 app.handle_event(Event::Key(KeyEvent::new(
1722 KeyCode::Char('5'),
1723 KeyModifiers::CONTROL,
1724 )));
1725
1726 assert!(app.emoji_picker_open);
1727 }
1728
1729 #[test]
1730 fn tab_switches_active_local_user() {
1731 let mut app = App::new();
1732 app.cursor = Pos { x: 7, y: 4 };
1733 app.selection_anchor = Some(Pos { x: 3, y: 2 });
1734 app.mode = Mode::Select;
1735
1736 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1737
1738 assert_eq!(app.active_user_idx, 1);
1739 assert_eq!(app.cursor, Pos { x: 0, y: 0 });
1740 assert_eq!(app.selection_anchor, None);
1741 assert!(!app.mode.is_selecting());
1742
1743 app.handle_event(Event::Key(KeyEvent::new(
1744 KeyCode::BackTab,
1745 KeyModifiers::SHIFT,
1746 )));
1747
1748 assert_eq!(app.active_user_idx, 0);
1749 assert_eq!(app.cursor, Pos { x: 7, y: 4 });
1750 assert_eq!(app.selection_anchor, Some(Pos { x: 3, y: 2 }));
1751 assert!(app.mode.is_selecting());
1752 }
1753
1754 #[test]
1755 fn tab_cycles_help_tabs_when_help_open() {
1756 let mut app = App::new();
1757 app.show_help = true;
1758 assert_eq!(app.help_tab, HelpTab::Guide);
1759
1760 for expected in [
1761 HelpTab::Drawing,
1762 HelpTab::Selection,
1763 HelpTab::Clipboard,
1764 HelpTab::Transform,
1765 HelpTab::Session,
1766 HelpTab::Guide,
1767 ] {
1768 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1769 assert_eq!(app.help_tab, expected);
1770 }
1771 assert_eq!(app.active_user_idx, 0);
1772 assert!(app.show_help);
1773
1774 app.handle_event(Event::Key(KeyEvent::new(
1775 KeyCode::BackTab,
1776 KeyModifiers::SHIFT,
1777 )));
1778
1779 assert_eq!(app.help_tab, HelpTab::Session);
1780 assert_eq!(app.active_user_idx, 0);
1781 }
1782
1783 #[test]
1784 fn local_users_share_canvas_but_keep_separate_swatch_state() {
1785 let mut app = App::new();
1786 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1787 app.cursor = Pos { x: 5, y: 5 };
1788 app.copy_selection_or_cell();
1789 assert!(app.swatches[0].is_some());
1790
1791 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
1792
1793 assert_eq!(app.active_user_idx, 1);
1794 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1795 assert!(app.swatches[0].is_none());
1796
1797 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
1798 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
1799
1800 app.handle_event(Event::Key(KeyEvent::new(
1801 KeyCode::BackTab,
1802 KeyModifiers::SHIFT,
1803 )));
1804
1805 assert_eq!(app.active_user_idx, 0);
1806 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
1807 assert!(app.swatches[0].is_some());
1808 assert_eq!(app.cursor, Pos { x: 5, y: 5 });
1809 }
1810
1811 #[test]
1812 fn local_users_start_with_distinct_colors() {
1813 let app = App::new();
1814 let colors: Vec<_> = app.users().iter().map(|user| user.color).collect();
1815 for (idx, color) in colors.iter().enumerate() {
1816 assert!(
1817 colors[(idx + 1)..].iter().all(|other| other != color),
1818 "duplicate player color at index {idx}: {color:?}"
1819 );
1820 }
1821 }
1822
1823 #[test]
1824 fn paint_reaches_server_via_active_client() {
1825 let mut app = App::new();
1826 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1827 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
1828 let server_snap = app.server_snapshot_for_test();
1829 assert_eq!(server_snap.get(Pos { x: 0, y: 0 }), 'A');
1830 }
1831
1832 #[test]
1833 fn undo_propagates_to_server() {
1834 let mut app = App::new();
1835 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
1836 assert_eq!(app.server_snapshot_for_test().get(Pos { x: 0, y: 0 }), 'A');
1837
1838 app.handle_key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL));
1839 app.drain_server_events();
1840 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), ' ');
1841 assert_eq!(app.server_snapshot_for_test().get(Pos { x: 0, y: 0 }), ' ');
1842 }
1843
1844 #[test]
1845 fn single_cell_paint_emits_paint_cell_op() {
1846 use dartboard_core::CanvasOp;
1847 let before = Canvas::with_size(8, 4);
1848 let mut after = before.clone();
1849 after.set_colored(Pos { x: 1, y: 1 }, 'A', RgbColor::new(10, 20, 30));
1850 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1851 match op {
1852 CanvasOp::PaintCell { pos, ch, fg } => {
1853 assert_eq!(pos, Pos { x: 1, y: 1 });
1854 assert_eq!(ch, 'A');
1855 assert_eq!(fg, RgbColor::new(10, 20, 30));
1856 }
1857 other => panic!("expected PaintCell, got {:?}", other),
1858 }
1859 }
1860
1861 #[test]
1862 fn single_cell_clear_emits_clear_cell_op() {
1863 use dartboard_core::CanvasOp;
1864 let mut before = Canvas::with_size(8, 4);
1865 before.set(Pos { x: 3, y: 2 }, 'Q');
1866 let mut after = before.clone();
1867 after.clear_cell(Pos { x: 3, y: 2 });
1868 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1869 match op {
1870 CanvasOp::ClearCell { pos } => assert_eq!(pos, Pos { x: 3, y: 2 }),
1871 other => panic!("expected ClearCell, got {:?}", other),
1872 }
1873 }
1874
1875 #[test]
1876 fn multi_cell_edit_emits_paint_region() {
1877 use dartboard_core::CanvasOp;
1878 let before = Canvas::with_size(8, 4);
1879 let mut after = before.clone();
1880 after.set_colored(Pos { x: 0, y: 0 }, 'A', RgbColor::new(1, 2, 3));
1881 after.set_colored(Pos { x: 1, y: 0 }, 'B', RgbColor::new(1, 2, 3));
1882 let op = super::diff_canvas_op(&before, &after).expect("diff should emit");
1883 match op {
1884 CanvasOp::PaintRegion { cells } => assert_eq!(cells.len(), 2),
1885 other => panic!("expected PaintRegion, got {:?}", other),
1886 }
1887 }
1888
1889 #[test]
1890 fn concurrent_edits_from_two_clients_compose_server_side() {
1891 use dartboard_core::{Canvas, CanvasOp, Client, RgbColor};
1895 use dartboard_server::{Hello, InMemStore, ServerHandle};
1896
1897 let server = ServerHandle::spawn_local(InMemStore);
1898 let mut alice = server.connect_local(Hello {
1899 name: "alice".into(),
1900 color: RgbColor::new(255, 0, 0),
1901 });
1902 let mut bob = server.connect_local(Hello {
1903 name: "bob".into(),
1904 color: RgbColor::new(0, 0, 255),
1905 });
1906 while alice.try_recv().is_some() {}
1907 while bob.try_recv().is_some() {}
1908
1909 let empty = Canvas::with_size(DEFAULT_WIDTH, DEFAULT_HEIGHT);
1910
1911 let mut a_mirror = empty.clone();
1912 a_mirror.set_colored(Pos { x: 0, y: 0 }, 'X', RgbColor::new(255, 0, 0));
1913 let a_op = super::diff_canvas_op(&empty, &a_mirror).unwrap();
1914 assert!(
1915 matches!(a_op, CanvasOp::PaintCell { .. }),
1916 "expected PaintCell, got {:?}",
1917 a_op
1918 );
1919 alice.submit_op(a_op);
1920
1921 let mut b_mirror = empty.clone();
1922 b_mirror.set_colored(Pos { x: 1, y: 0 }, 'Y', RgbColor::new(0, 0, 255));
1923 let b_op = super::diff_canvas_op(&empty, &b_mirror).unwrap();
1924 bob.submit_op(b_op);
1925
1926 let snap = server.canvas_snapshot();
1927 assert_eq!(snap.get(Pos { x: 0, y: 0 }), 'X');
1928 assert_eq!(snap.get(Pos { x: 1, y: 0 }), 'Y');
1929 }
1930
1931 #[test]
1932 fn new_remote_blocks_until_welcome_applied() {
1933 use crate::app::Transport;
1937 use dartboard_client_ws::{Hello as WsHello, WebsocketClient};
1938 use dartboard_core::{CanvasOp, Client};
1939 use dartboard_server::{InMemStore, ServerHandle};
1940
1941 let server = ServerHandle::spawn_local(InMemStore);
1942 let addr = std::net::TcpListener::bind("127.0.0.1:0")
1943 .unwrap()
1944 .local_addr()
1945 .unwrap();
1946 server.bind_ws(addr).unwrap();
1947
1948 let mut seeder = server.connect_local(dartboard_server::Hello {
1951 name: "seeder".into(),
1952 color: RgbColor::new(1, 1, 1),
1953 });
1954 seeder.submit_op(CanvasOp::PaintCell {
1955 pos: Pos { x: 5, y: 5 },
1956 ch: 'Z',
1957 fg: RgbColor::new(1, 1, 1),
1958 });
1959 drop(seeder);
1960
1961 let url = format!("ws://{}", addr);
1962 let client = WebsocketClient::connect(
1963 &url,
1964 WsHello {
1965 name: "me".into(),
1966 color: RgbColor::new(255, 0, 0),
1967 },
1968 )
1969 .unwrap();
1970
1971 let app = App::new_remote(client, "me".into(), RgbColor::new(255, 0, 0));
1972 assert_eq!(
1974 app.canvas.get(Pos { x: 5, y: 5 }),
1975 'Z',
1976 "seeded cell should be visible immediately after new_remote"
1977 );
1978 match &app.transport {
1979 Transport::Remote { mirror, .. } => {
1980 assert!(mirror.my_user_id.is_some(), "my_user_id should be set")
1981 }
1982 _ => panic!("expected Remote transport"),
1983 }
1984 }
1985
1986 #[test]
1987 fn undo_is_enabled_in_embedded_mode() {
1988 let app = App::new();
1989 assert!(app.undo_enabled());
1990 }
1991
1992 #[test]
1993 fn undo_disabled_when_another_peer_is_connected_in_remote_mode() {
1994 use dartboard_client_ws::{Hello as WsHello, WebsocketClient};
1995 use dartboard_server::{Hello, InMemStore, ServerHandle};
1996
1997 let server = ServerHandle::spawn_local(InMemStore);
2000 let addr = std::net::TcpListener::bind("127.0.0.1:0")
2001 .unwrap()
2002 .local_addr()
2003 .unwrap();
2004 server.bind_ws(addr).unwrap();
2005 let _other = server.connect_local(Hello {
2007 name: "other".into(),
2008 color: RgbColor::new(10, 10, 10),
2009 });
2010
2011 let url = format!("ws://{}", addr);
2012 let client = WebsocketClient::connect(
2013 &url,
2014 WsHello {
2015 name: "me".into(),
2016 color: RgbColor::new(255, 0, 0),
2017 },
2018 )
2019 .unwrap();
2020
2021 let mut app = App::new_remote(client, "me".into(), RgbColor::new(255, 0, 0));
2022
2023 let start = std::time::Instant::now();
2025 while start.elapsed() < std::time::Duration::from_secs(2) && app.peer_count() <= 1 {
2026 app.drain_server_events();
2027 std::thread::sleep(std::time::Duration::from_millis(20));
2028 }
2029
2030 assert!(app.peer_count() >= 2, "expected to see the other peer");
2031 assert!(
2032 !app.undo_enabled(),
2033 "undo must be gated off while a remote peer is present"
2034 );
2035 }
2036
2037 #[test]
2038 fn websocket_connect_fails_fast_when_server_is_full() {
2039 use crate::theme;
2040 use dartboard_client_ws::{ConnectError, Hello as WsHello, WebsocketClient};
2041 use dartboard_server::{Hello, InMemStore, ServerHandle, MAX_PLAYERS};
2042
2043 let server = ServerHandle::spawn_local(InMemStore);
2044 let addr = std::net::TcpListener::bind("127.0.0.1:0")
2045 .unwrap()
2046 .local_addr()
2047 .unwrap();
2048 server.bind_ws(addr).unwrap();
2049
2050 let mut _peers = Vec::new();
2051 for i in 0..MAX_PLAYERS {
2052 _peers.push(server.connect_local(Hello {
2053 name: format!("peer{i}"),
2054 color: theme::PLAYER_PALETTE[i],
2055 }));
2056 }
2057
2058 let url = format!("ws://{}", addr);
2059 match WebsocketClient::connect(
2060 &url,
2061 WsHello {
2062 name: "overflow".into(),
2063 color: RgbColor::new(255, 0, 0),
2064 },
2065 ) {
2066 Err(ConnectError::Rejected(reason)) => {
2067 assert!(reason.to_lowercase().contains("full"), "reason: {reason}");
2068 }
2069 Err(other) => panic!("expected ConnectError::Rejected, got {other:?}"),
2070 Ok(_) => panic!("connect should have been rejected"),
2071 }
2072 }
2073
2074 #[test]
2075 fn each_local_user_has_its_own_client_user_id() {
2076 let app = App::new();
2077 let ids = app.client_user_ids_for_test();
2078 let mut unique = ids.clone();
2079 unique.sort();
2080 unique.dedup();
2081 assert_eq!(ids.len(), unique.len(), "user ids must be distinct");
2082 assert_eq!(ids.len(), app.users().len());
2083 }
2084
2085 #[test]
2086 fn authored_cells_take_the_active_user_color() {
2087 let mut app = App::new();
2088 let first_color = app.active_user_color();
2089
2090 app.handle_key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
2091 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'A');
2092 assert_eq!(app.canvas.fg(Pos { x: 0, y: 0 }), Some(first_color));
2093
2094 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
2095 let second_color = app.active_user_color();
2096 assert_ne!(second_color, first_color);
2097
2098 app.handle_key(KeyEvent::new(KeyCode::Char('B'), KeyModifiers::NONE));
2099 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), 'B');
2100 assert_eq!(app.canvas.fg(Pos { x: 0, y: 0 }), Some(second_color));
2101 }
2102
2103 #[test]
2104 fn keep_open_picker_insert_writes_adjacent_cells() {
2105 let mut app = App::new();
2106 app.open_emoji_picker();
2107
2108 let expected = {
2109 let catalog = app.icon_catalog.as_ref().unwrap();
2110 let tab = *app.emoji_picker_state.tab.current();
2111 let sections = catalog.sections(tab.index(), &app.emoji_picker_state.search_query);
2112 crate::emoji::picker::entry_at_selectable(
2113 §ions,
2114 app.emoji_picker_state.selected_index,
2115 )
2116 .unwrap()
2117 .icon
2118 .chars()
2119 .next()
2120 .unwrap()
2121 };
2122
2123 app.picker_insert_selected(true);
2124 app.picker_insert_selected(true);
2125
2126 assert!(app.emoji_picker_open);
2127 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), expected);
2128 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
2129 assert_eq!(app.canvas.get(Pos { x: 2, y: 0 }), expected);
2130 assert_eq!(app.cursor, Pos { x: 4, y: 0 });
2131 }
2132
2133 #[test]
2134 fn wide_glyph_insert_advances_two_cells() {
2135 let mut app = App::new();
2136
2137 app.insert_char('🌱');
2138
2139 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), '🌱');
2140 assert!(app.canvas.is_continuation(Pos { x: 1, y: 0 }));
2141 assert_eq!(app.cursor, Pos { x: 2, y: 0 });
2142 }
2143
2144 #[test]
2145 fn backspace_on_wide_glyph_clears_both_cells() {
2146 let mut app = App::new();
2147 app.insert_char('🌱');
2148
2149 app.backspace();
2150
2151 assert_eq!(app.canvas.get(Pos { x: 0, y: 0 }), ' ');
2152 assert_eq!(app.canvas.get(Pos { x: 1, y: 0 }), ' ');
2153 assert_eq!(app.cursor, Pos { x: 0, y: 0 });
2154 }
2155
2156 #[test]
2157 fn alt_click_extends_existing_selection() {
2158 let mut app = App::new();
2159 app.set_viewport(Rect::new(0, 0, 20, 10));
2160 app.selection_anchor = Some(Pos { x: 2, y: 3 });
2161 app.cursor = Pos { x: 5, y: 6 };
2162 app.mode = Mode::Select;
2163
2164 app.handle_event(Event::Mouse(MouseEvent {
2165 kind: MouseEventKind::Down(MouseButton::Left),
2166 column: 8,
2167 row: 7,
2168 modifiers: KeyModifiers::ALT,
2169 }));
2170 app.handle_event(Event::Mouse(MouseEvent {
2171 kind: MouseEventKind::Up(MouseButton::Left),
2172 column: 8,
2173 row: 7,
2174 modifiers: KeyModifiers::ALT,
2175 }));
2176
2177 assert_eq!(app.selection_anchor, Some(Pos { x: 2, y: 3 }));
2178 assert_eq!(app.cursor, Pos { x: 8, y: 7 });
2179 assert!(app.mode.is_selecting());
2180 }
2181
2182 #[test]
2183 fn ctrl_drag_creates_ellipse_selection_and_masks_fill() {
2184 let mut app = App::new();
2185 app.set_viewport(Rect::new(0, 0, 20, 10));
2186
2187 app.handle_event(Event::Mouse(MouseEvent {
2188 kind: MouseEventKind::Down(MouseButton::Left),
2189 column: 2,
2190 row: 2,
2191 modifiers: KeyModifiers::CONTROL,
2192 }));
2193 app.handle_event(Event::Mouse(MouseEvent {
2194 kind: MouseEventKind::Drag(MouseButton::Left),
2195 column: 8,
2196 row: 6,
2197 modifiers: KeyModifiers::CONTROL,
2198 }));
2199 app.handle_event(Event::Mouse(MouseEvent {
2200 kind: MouseEventKind::Up(MouseButton::Left),
2201 column: 8,
2202 row: 6,
2203 modifiers: KeyModifiers::CONTROL,
2204 }));
2205
2206 assert_eq!(app.selection_anchor, Some(Pos { x: 2, y: 2 }));
2207 assert_eq!(app.cursor, Pos { x: 8, y: 6 });
2208 assert_eq!(app.selection_shape, SelectionShape::Ellipse);
2209 assert!(app.mode.is_selecting());
2210 assert!(app.is_selected(Pos { x: 5, y: 4 }));
2211 assert!(!app.is_selected(Pos { x: 2, y: 2 }));
2212
2213 app.fill_selection_or_cell('x');
2214
2215 assert_eq!(app.canvas.get(Pos { x: 5, y: 4 }), 'x');
2216 assert_eq!(app.canvas.get(Pos { x: 2, y: 2 }), ' ');
2217 }
2218
2219 #[test]
2220 fn ellipse_selection_state_is_per_user() {
2221 let mut app = App::new();
2222 app.set_viewport(Rect::new(0, 0, 20, 10));
2223
2224 app.handle_event(Event::Mouse(MouseEvent {
2225 kind: MouseEventKind::Down(MouseButton::Left),
2226 column: 3,
2227 row: 2,
2228 modifiers: KeyModifiers::CONTROL,
2229 }));
2230 app.handle_event(Event::Mouse(MouseEvent {
2231 kind: MouseEventKind::Drag(MouseButton::Left),
2232 column: 9,
2233 row: 6,
2234 modifiers: KeyModifiers::CONTROL,
2235 }));
2236 app.handle_event(Event::Mouse(MouseEvent {
2237 kind: MouseEventKind::Up(MouseButton::Left),
2238 column: 9,
2239 row: 6,
2240 modifiers: KeyModifiers::CONTROL,
2241 }));
2242
2243 app.handle_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
2244 assert_eq!(app.active_user_idx, 1);
2245 assert_eq!(app.selection_anchor, None);
2246 assert!(!app.mode.is_selecting());
2247
2248 app.handle_event(Event::Key(KeyEvent::new(
2249 KeyCode::BackTab,
2250 KeyModifiers::SHIFT,
2251 )));
2252 assert_eq!(app.active_user_idx, 0);
2253 assert_eq!(app.selection_anchor, Some(Pos { x: 3, y: 2 }));
2254 assert_eq!(app.cursor, Pos { x: 9, y: 6 });
2255 assert_eq!(app.selection_shape, SelectionShape::Ellipse);
2256 assert!(app.mode.is_selecting());
2257 assert!(app.is_selected(Pos { x: 6, y: 4 }));
2258 }
2259
2260 #[test]
2261 fn ctrl_t_transposes_active_selection_corner() {
2262 let mut app = App::new();
2263 app.selection_anchor = Some(Pos { x: 2, y: 3 });
2264 app.cursor = Pos { x: 8, y: 7 };
2265 app.mode = Mode::Select;
2266
2267 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2268
2269 assert_eq!(app.selection_anchor, Some(Pos { x: 8, y: 7 }));
2270 assert_eq!(app.cursor, Pos { x: 2, y: 3 });
2271 assert!(app.mode.is_selecting());
2272 }
2273
2274 #[test]
2275 fn copy_pushes_swatch_without_entering_floating() {
2276 let mut app = App::new();
2277 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
2278 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
2279 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2280 app.cursor = Pos { x: 2, y: 1 };
2281 app.mode = Mode::Select;
2282
2283 app.copy_selection_or_cell();
2284 assert_eq!(app.populated_swatch_count(), 1);
2285 assert!(app.floating.is_none());
2286 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), 'A');
2287
2288 app.copy_selection_or_cell();
2290 assert_eq!(app.populated_swatch_count(), 2);
2291 assert!(app.floating.is_none());
2292 }
2293
2294 #[test]
2295 fn cut_pushes_swatch_and_clears_canvas() {
2296 let mut app = App::new();
2297 app.canvas.set(Pos { x: 1, y: 1 }, 'X');
2298 app.canvas.set(Pos { x: 2, y: 1 }, 'Y');
2299 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2300 app.cursor = Pos { x: 2, y: 1 };
2301 app.mode = Mode::Select;
2302
2303 app.cut_selection_or_cell();
2304 assert_eq!(app.populated_swatch_count(), 1);
2305 assert!(app.floating.is_none());
2306 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), ' ');
2307 assert_eq!(app.canvas.get(Pos { x: 2, y: 1 }), ' ');
2308 }
2309
2310 #[test]
2311 fn swatch_history_newest_first_and_capped() {
2312 let mut app = App::new();
2313 for (i, ch) in ['A', 'B', 'C', 'D', 'E', 'F'].iter().enumerate() {
2314 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2315 app.cursor = Pos { x: i, y: 0 };
2316 app.copy_selection_or_cell();
2317 }
2318
2319 assert_eq!(app.swatches.iter().filter(|s| s.is_some()).count(), 5);
2320 assert_eq!(
2322 app.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2323 Some(CellValue::Narrow('F'))
2324 );
2325 assert_eq!(
2327 app.swatches[4].as_ref().unwrap().clipboard.get(0, 0),
2328 Some(CellValue::Narrow('B'))
2329 );
2330 }
2331
2332 #[test]
2333 fn pinned_swatch_holds_slot_when_history_rotates() {
2334 let mut app = App::new();
2335 for (i, ch) in ['A', 'B', 'C'].iter().enumerate() {
2336 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2337 app.cursor = Pos { x: i, y: 0 };
2338 app.copy_selection_or_cell();
2339 }
2340 assert_eq!(
2342 app.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2343 Some(CellValue::Narrow('B'))
2344 );
2345 app.toggle_pin(1);
2346 assert!(app.swatches[1].as_ref().unwrap().pinned);
2347
2348 for (i, ch) in ['D', 'E', 'F'].iter().enumerate() {
2350 app.canvas.set(Pos { x: 10 + i, y: 0 }, *ch);
2351 app.cursor = Pos { x: 10 + i, y: 0 };
2352 app.copy_selection_or_cell();
2353 }
2354
2355 assert_eq!(
2357 app.swatches[1].as_ref().unwrap().clipboard.get(0, 0),
2358 Some(CellValue::Narrow('B'))
2359 );
2360 assert!(app.swatches[1].as_ref().unwrap().pinned);
2361 assert_eq!(
2363 app.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2364 Some(CellValue::Narrow('F'))
2365 );
2366 }
2367
2368 #[test]
2369 fn all_pinned_swatches_reject_new_push() {
2370 let mut app = App::new();
2371 for (i, ch) in ['A', 'B', 'C', 'D', 'E'].iter().enumerate() {
2372 app.canvas.set(Pos { x: i, y: 0 }, *ch);
2373 app.cursor = Pos { x: i, y: 0 };
2374 app.copy_selection_or_cell();
2375 }
2376 for i in 0..SWATCH_CAPACITY {
2377 app.toggle_pin(i);
2378 }
2379 let before: Vec<_> = app
2380 .swatches
2381 .iter()
2382 .map(|s| s.as_ref().unwrap().clipboard.get(0, 0))
2383 .collect();
2384
2385 app.canvas.set(Pos { x: 20, y: 0 }, 'Z');
2386 app.cursor = Pos { x: 20, y: 0 };
2387 app.copy_selection_or_cell();
2388
2389 let after: Vec<_> = app
2390 .swatches
2391 .iter()
2392 .map(|s| s.as_ref().unwrap().clipboard.get(0, 0))
2393 .collect();
2394 assert_eq!(before, after, "all-pinned strip should reject new copies");
2395 }
2396
2397 #[test]
2398 fn ctrl_home_row_activates_swatch() {
2399 let mut app = App::new();
2400 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2401 app.cursor = Pos { x: 0, y: 0 };
2402 app.copy_selection_or_cell();
2403
2404 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2405 assert!(app.floating.is_some());
2406 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2407 }
2408
2409 #[test]
2410 fn ctrl_home_row_while_floating_switches_or_cycles_swatch() {
2411 let mut app = App::new();
2412 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2413 app.cursor = Pos { x: 0, y: 0 };
2414 app.copy_selection_or_cell();
2415 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2416 app.cursor = Pos { x: 1, y: 0 };
2417 app.copy_selection_or_cell();
2418
2419 app.activate_swatch(1); assert_eq!(app.floating.as_ref().unwrap().source_index, Some(1));
2421
2422 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2424 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2425 assert!(!app.floating.as_ref().unwrap().transparent);
2426
2427 app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
2429 assert!(app.floating.as_ref().unwrap().transparent);
2430 }
2431
2432 #[test]
2433 fn bare_digit_draws_even_while_floating() {
2434 let mut app = App::new();
2435 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2436 app.cursor = Pos { x: 0, y: 0 };
2437 app.copy_selection_or_cell();
2438 app.activate_swatch(0);
2439 assert!(app.floating.is_some());
2440
2441 app.cursor = Pos { x: 5, y: 5 };
2443 app.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
2444 assert!(app.floating.is_none());
2445 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), '1');
2446 }
2447
2448 #[test]
2449 fn activate_swatch_enters_floating_from_history() {
2450 let mut app = App::new();
2451 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
2452 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
2453 app.selection_anchor = Some(Pos { x: 1, y: 1 });
2454 app.cursor = Pos { x: 2, y: 1 };
2455 app.mode = Mode::Select;
2456
2457 app.copy_selection_or_cell();
2458 app.activate_swatch(0);
2459
2460 assert!(app.floating.is_some());
2461 assert_eq!(app.floating.as_ref().unwrap().source_index, Some(0));
2462 assert!(!app.mode.is_selecting());
2463 assert_eq!(app.canvas.get(Pos { x: 1, y: 1 }), 'A');
2464 }
2465
2466 #[test]
2467 fn activate_same_swatch_again_toggles_transparency() {
2468 let mut app = App::new();
2469 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2470 app.cursor = Pos { x: 0, y: 0 };
2471 app.copy_selection_or_cell();
2472
2473 app.activate_swatch(0);
2474 assert!(!app.floating.as_ref().unwrap().transparent);
2475
2476 app.activate_swatch(0);
2477 assert!(app.floating.as_ref().unwrap().transparent);
2478
2479 app.activate_swatch(0);
2480 assert!(!app.floating.as_ref().unwrap().transparent);
2481 }
2482
2483 #[test]
2484 fn activate_different_swatch_switches_to_opaque() {
2485 let mut app = App::new();
2486 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2487 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2488
2489 app.cursor = Pos { x: 0, y: 0 };
2490 app.copy_selection_or_cell();
2491 app.cursor = Pos { x: 1, y: 0 };
2492 app.copy_selection_or_cell();
2493
2494 app.activate_swatch(0);
2495 app.activate_swatch(0); assert!(app.floating.as_ref().unwrap().transparent);
2497
2498 app.activate_swatch(1); assert_eq!(app.floating.as_ref().unwrap().source_index, Some(1));
2500 assert!(!app.floating.as_ref().unwrap().transparent);
2501 }
2502
2503 #[test]
2504 fn ctrl_t_toggles_transparency_while_floating() {
2505 let mut app = App::new();
2506 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2507 app.cursor = Pos { x: 0, y: 0 };
2508 app.copy_selection_or_cell();
2509 app.activate_swatch(0);
2510
2511 assert!(!app.floating.as_ref().unwrap().transparent);
2512
2513 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2514 assert!(app.floating.as_ref().unwrap().transparent);
2515
2516 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2517 assert!(!app.floating.as_ref().unwrap().transparent);
2518 }
2519
2520 #[test]
2521 fn stamp_floating_writes_to_canvas() {
2522 let mut app = App::new();
2523 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2524 app.canvas.set(Pos { x: 1, y: 0 }, 'B');
2525 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2526 app.cursor = Pos { x: 1, y: 0 };
2527 app.mode = Mode::Select;
2528
2529 app.copy_selection_or_cell();
2530 app.activate_swatch(0);
2531
2532 app.cursor = Pos { x: 5, y: 3 };
2533 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2534
2535 assert!(app.floating.is_some());
2536 assert_eq!(app.canvas.get(Pos { x: 5, y: 3 }), 'A');
2537 assert_eq!(app.canvas.get(Pos { x: 6, y: 3 }), 'B');
2538 }
2539
2540 #[test]
2541 fn esc_dismisses_float_without_stamping() {
2542 let mut app = App::new();
2543 app.canvas.set(Pos { x: 0, y: 0 }, 'Z');
2544 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2545 app.cursor = Pos { x: 0, y: 0 };
2546 app.mode = Mode::Select;
2547
2548 app.copy_selection_or_cell();
2549 app.activate_swatch(0);
2550
2551 app.cursor = Pos { x: 5, y: 5 };
2552 app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
2553
2554 assert!(app.floating.is_none());
2555 assert_eq!(app.populated_swatch_count(), 1);
2557 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), ' ');
2558 }
2559
2560 #[test]
2561 fn arrow_keys_nudge_floating_position() {
2562 let mut app = App::new();
2563 app.canvas.set(Pos { x: 3, y: 3 }, 'Q');
2564 app.selection_anchor = Some(Pos { x: 3, y: 3 });
2565 app.cursor = Pos { x: 3, y: 3 };
2566 app.mode = Mode::Select;
2567
2568 app.copy_selection_or_cell();
2569 app.activate_swatch(0);
2570
2571 app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
2572 app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
2573
2574 assert!(app.floating.is_some());
2575 assert_eq!(app.cursor, Pos { x: 4, y: 4 });
2576 }
2577
2578 #[test]
2579 fn mouse_click_stamps_floating() {
2580 let mut app = App::new();
2581 app.set_viewport(Rect::new(0, 0, 20, 10));
2582 app.canvas.set(Pos { x: 0, y: 0 }, 'M');
2583 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2584 app.cursor = Pos { x: 0, y: 0 };
2585 app.mode = Mode::Select;
2586
2587 app.copy_selection_or_cell();
2588 app.activate_swatch(0);
2589
2590 app.handle_event(Event::Mouse(MouseEvent {
2591 kind: MouseEventKind::Down(MouseButton::Left),
2592 column: 7,
2593 row: 4,
2594 modifiers: KeyModifiers::NONE,
2595 }));
2596 app.handle_event(Event::Mouse(MouseEvent {
2597 kind: MouseEventKind::Up(MouseButton::Left),
2598 column: 7,
2599 row: 4,
2600 modifiers: KeyModifiers::NONE,
2601 }));
2602
2603 assert!(app.floating.is_some());
2604 assert_eq!(app.canvas.get(Pos { x: 7, y: 4 }), 'M');
2605 }
2606
2607 #[test]
2608 fn transparent_stamp_preserves_underlying_content() {
2609 let mut app = App::new();
2610 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
2611 app.canvas.set(Pos { x: 2, y: 0 }, 'B');
2612 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2613 app.cursor = Pos { x: 2, y: 0 };
2614 app.mode = Mode::Select;
2615
2616 app.copy_selection_or_cell();
2617 app.activate_swatch(0);
2618
2619 app.handle_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
2620 assert!(app.floating.as_ref().unwrap().transparent);
2621
2622 app.canvas.set(Pos { x: 5, y: 5 }, 'Z');
2624
2625 app.cursor = Pos { x: 4, y: 5 };
2627 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2628
2629 assert_eq!(app.canvas.get(Pos { x: 4, y: 5 }), 'A');
2631 assert_eq!(app.canvas.get(Pos { x: 5, y: 5 }), 'Z');
2632 assert_eq!(app.canvas.get(Pos { x: 6, y: 5 }), 'B');
2633 }
2634
2635 #[test]
2636 fn drag_paints_like_brush_with_single_undo() {
2637 let mut app = App::new();
2638 app.set_viewport(Rect::new(0, 0, 20, 10));
2639 app.canvas.set(Pos { x: 0, y: 0 }, 'X');
2640 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2641 app.cursor = Pos { x: 0, y: 0 };
2642 app.mode = Mode::Select;
2643
2644 app.copy_selection_or_cell();
2645 app.activate_swatch(0);
2646
2647 app.handle_event(Event::Mouse(MouseEvent {
2649 kind: MouseEventKind::Down(MouseButton::Left),
2650 column: 3,
2651 row: 2,
2652 modifiers: KeyModifiers::NONE,
2653 }));
2654 app.handle_event(Event::Mouse(MouseEvent {
2655 kind: MouseEventKind::Drag(MouseButton::Left),
2656 column: 5,
2657 row: 2,
2658 modifiers: KeyModifiers::NONE,
2659 }));
2660 app.handle_event(Event::Mouse(MouseEvent {
2661 kind: MouseEventKind::Drag(MouseButton::Left),
2662 column: 7,
2663 row: 2,
2664 modifiers: KeyModifiers::NONE,
2665 }));
2666 app.handle_event(Event::Mouse(MouseEvent {
2667 kind: MouseEventKind::Up(MouseButton::Left),
2668 column: 7,
2669 row: 2,
2670 modifiers: KeyModifiers::NONE,
2671 }));
2672
2673 assert_eq!(app.canvas.get(Pos { x: 3, y: 2 }), 'X');
2675 assert_eq!(app.canvas.get(Pos { x: 5, y: 2 }), 'X');
2676 assert_eq!(app.canvas.get(Pos { x: 7, y: 2 }), 'X');
2677
2678 assert!(app.floating.is_some());
2680
2681 app.undo();
2683 assert_eq!(app.canvas.get(Pos { x: 3, y: 2 }), ' ');
2684 assert_eq!(app.canvas.get(Pos { x: 5, y: 2 }), ' ');
2685 assert_eq!(app.canvas.get(Pos { x: 7, y: 2 }), ' ');
2686 }
2687
2688 #[test]
2689 fn repeated_ctrl_v_stamps_create_separate_undos() {
2690 let mut app = App::new();
2691 app.canvas.set(Pos { x: 0, y: 0 }, 'Q');
2692 app.selection_anchor = Some(Pos { x: 0, y: 0 });
2693 app.cursor = Pos { x: 0, y: 0 };
2694 app.mode = Mode::Select;
2695
2696 app.copy_selection_or_cell();
2697 app.activate_swatch(0);
2698
2699 app.cursor = Pos { x: 3, y: 3 };
2701 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2702 app.cursor = Pos { x: 6, y: 6 };
2703 app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2704
2705 assert_eq!(app.canvas.get(Pos { x: 3, y: 3 }), 'Q');
2706 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), 'Q');
2707
2708 app.undo();
2710 assert_eq!(app.canvas.get(Pos { x: 3, y: 3 }), 'Q');
2711 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), ' ');
2712 }
2713
2714 #[test]
2715 fn horizontal_drag_with_wide_brush_skips_overlapping_cells() {
2716 let mut app = setup_floating_wide_brush();
2717
2718 app.handle_event(Event::Mouse(MouseEvent {
2719 kind: MouseEventKind::Down(MouseButton::Left),
2720 column: 3,
2721 row: 2,
2722 modifiers: KeyModifiers::NONE,
2723 }));
2724 app.handle_event(Event::Mouse(MouseEvent {
2725 kind: MouseEventKind::Drag(MouseButton::Left),
2726 column: 4,
2727 row: 2,
2728 modifiers: KeyModifiers::NONE,
2729 }));
2730 app.handle_event(Event::Mouse(MouseEvent {
2731 kind: MouseEventKind::Drag(MouseButton::Left),
2732 column: 5,
2733 row: 2,
2734 modifiers: KeyModifiers::NONE,
2735 }));
2736 app.handle_event(Event::Mouse(MouseEvent {
2737 kind: MouseEventKind::Up(MouseButton::Left),
2738 column: 5,
2739 row: 2,
2740 modifiers: KeyModifiers::NONE,
2741 }));
2742
2743 assert_eq!(
2744 app.canvas.cell(Pos { x: 3, y: 2 }),
2745 Some(CellValue::Wide('🌱'))
2746 );
2747 assert_eq!(
2748 app.canvas.cell(Pos { x: 4, y: 2 }),
2749 Some(CellValue::WideCont)
2750 );
2751 assert_eq!(
2752 app.canvas.cell(Pos { x: 5, y: 2 }),
2753 Some(CellValue::Wide('🌱'))
2754 );
2755 assert_eq!(
2756 app.canvas.cell(Pos { x: 6, y: 2 }),
2757 Some(CellValue::WideCont)
2758 );
2759 }
2760
2761 #[test]
2762 fn diagonal_drag_with_wide_brush_does_not_emit_horizontal_rays() {
2763 let mut app = setup_floating_wide_brush();
2764
2765 app.handle_event(Event::Mouse(MouseEvent {
2766 kind: MouseEventKind::Down(MouseButton::Left),
2767 column: 12,
2768 row: 6,
2769 modifiers: KeyModifiers::NONE,
2770 }));
2771 app.handle_event(Event::Mouse(MouseEvent {
2772 kind: MouseEventKind::Drag(MouseButton::Left),
2773 column: 16,
2774 row: 7,
2775 modifiers: KeyModifiers::NONE,
2776 }));
2777 app.handle_event(Event::Mouse(MouseEvent {
2778 kind: MouseEventKind::Drag(MouseButton::Left),
2779 column: 8,
2780 row: 7,
2781 modifiers: KeyModifiers::NONE,
2782 }));
2783 app.handle_event(Event::Mouse(MouseEvent {
2784 kind: MouseEventKind::Up(MouseButton::Left),
2785 column: 8,
2786 row: 7,
2787 modifiers: KeyModifiers::NONE,
2788 }));
2789
2790 assert_eq!(
2791 app.canvas.cell(Pos { x: 12, y: 6 }),
2792 Some(CellValue::Wide('🌱'))
2793 );
2794 assert_eq!(
2795 app.canvas.cell(Pos { x: 13, y: 6 }),
2796 Some(CellValue::WideCont)
2797 );
2798 assert_eq!(
2799 app.canvas.cell(Pos { x: 16, y: 7 }),
2800 Some(CellValue::Wide('🌱'))
2801 );
2802 assert_eq!(
2803 app.canvas.cell(Pos { x: 17, y: 7 }),
2804 Some(CellValue::WideCont)
2805 );
2806 assert_eq!(
2807 app.canvas.cell(Pos { x: 8, y: 7 }),
2808 Some(CellValue::Wide('🌱'))
2809 );
2810 assert_eq!(
2811 app.canvas.cell(Pos { x: 9, y: 7 }),
2812 Some(CellValue::WideCont)
2813 );
2814 assert_eq!(app.canvas.get(Pos { x: 10, y: 7 }), ' ');
2815 assert_eq!(app.canvas.get(Pos { x: 12, y: 7 }), ' ');
2816 }
2817
2818 #[test]
2819 fn wide_brush_same_row_jump_does_not_fill_intermediate_cells() {
2820 let mut app = setup_floating_wide_brush();
2821
2822 app.handle_event(Event::Mouse(MouseEvent {
2823 kind: MouseEventKind::Down(MouseButton::Left),
2824 column: 12,
2825 row: 6,
2826 modifiers: KeyModifiers::NONE,
2827 }));
2828 app.handle_event(Event::Mouse(MouseEvent {
2829 kind: MouseEventKind::Drag(MouseButton::Left),
2830 column: 4,
2831 row: 6,
2832 modifiers: KeyModifiers::NONE,
2833 }));
2834 app.handle_event(Event::Mouse(MouseEvent {
2835 kind: MouseEventKind::Up(MouseButton::Left),
2836 column: 4,
2837 row: 6,
2838 modifiers: KeyModifiers::NONE,
2839 }));
2840
2841 assert_eq!(
2842 app.canvas.cell(Pos { x: 12, y: 6 }),
2843 Some(CellValue::Wide('🌱'))
2844 );
2845 assert_eq!(
2846 app.canvas.cell(Pos { x: 13, y: 6 }),
2847 Some(CellValue::WideCont)
2848 );
2849 assert_eq!(
2850 app.canvas.cell(Pos { x: 4, y: 6 }),
2851 Some(CellValue::Wide('🌱'))
2852 );
2853 assert_eq!(
2854 app.canvas.cell(Pos { x: 5, y: 6 }),
2855 Some(CellValue::WideCont)
2856 );
2857 assert_eq!(app.canvas.get(Pos { x: 6, y: 6 }), ' ');
2858 assert_eq!(app.canvas.get(Pos { x: 10, y: 6 }), ' ');
2859 }
2860
2861 #[test]
2862 fn shallow_diagonal_drag_with_wide_brush_fills_more_evenly() {
2863 let mut app = setup_floating_wide_brush();
2864
2865 app.handle_event(Event::Mouse(MouseEvent {
2866 kind: MouseEventKind::Down(MouseButton::Left),
2867 column: 3,
2868 row: 2,
2869 modifiers: KeyModifiers::NONE,
2870 }));
2871 app.handle_event(Event::Mouse(MouseEvent {
2872 kind: MouseEventKind::Drag(MouseButton::Left),
2873 column: 9,
2874 row: 3,
2875 modifiers: KeyModifiers::NONE,
2876 }));
2877 app.handle_event(Event::Mouse(MouseEvent {
2878 kind: MouseEventKind::Up(MouseButton::Left),
2879 column: 9,
2880 row: 3,
2881 modifiers: KeyModifiers::NONE,
2882 }));
2883
2884 assert_eq!(
2885 app.canvas.cell(Pos { x: 3, y: 2 }),
2886 Some(CellValue::Wide('🌱'))
2887 );
2888 assert_eq!(
2889 app.canvas.cell(Pos { x: 5, y: 2 }),
2890 Some(CellValue::Wide('🌱'))
2891 );
2892 assert_eq!(
2893 app.canvas.cell(Pos { x: 6, y: 3 }),
2894 Some(CellValue::Wide('🌱'))
2895 );
2896 assert_eq!(
2897 app.canvas.cell(Pos { x: 8, y: 3 }),
2898 Some(CellValue::Wide('🌱'))
2899 );
2900 }
2901
2902 #[test]
2903 fn shallow_wide_brush_diagonal_sweep_keeps_row_gaps_within_brush_width() {
2904 for start_x in [2_u16, 3_u16] {
2905 for end_x in (start_x + 3)..=24 {
2906 let mut app = setup_floating_wide_brush();
2907
2908 app.handle_event(Event::Mouse(MouseEvent {
2909 kind: MouseEventKind::Down(MouseButton::Left),
2910 column: start_x,
2911 row: 2,
2912 modifiers: KeyModifiers::NONE,
2913 }));
2914 app.handle_event(Event::Mouse(MouseEvent {
2915 kind: MouseEventKind::Drag(MouseButton::Left),
2916 column: end_x,
2917 row: 3,
2918 modifiers: KeyModifiers::NONE,
2919 }));
2920 app.handle_event(Event::Mouse(MouseEvent {
2921 kind: MouseEventKind::Up(MouseButton::Left),
2922 column: end_x,
2923 row: 3,
2924 modifiers: KeyModifiers::NONE,
2925 }));
2926
2927 let row_two = wide_origins_in_row(&app, 2, end_x as usize + 2);
2928 let row_three = wide_origins_in_row(&app, 3, end_x as usize + 2);
2929
2930 assert!(
2931 !row_two.is_empty(),
2932 "row 2 empty for start_x={start_x}, end_x={end_x}"
2933 );
2934 assert!(
2935 !row_three.is_empty(),
2936 "row 3 empty for start_x={start_x}, end_x={end_x}"
2937 );
2938 assert!(
2939 row_two.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2940 "row 2 gap too large for start_x={start_x}, end_x={end_x}: {row_two:?}"
2941 );
2942 assert!(
2943 row_three.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2944 "row 3 gap too large for start_x={start_x}, end_x={end_x}: {row_three:?}"
2945 );
2946 }
2947 }
2948 }
2949
2950 #[test]
2951 fn shallow_diagonal_with_same_row_micro_steps_keeps_visible_progress() {
2952 for start_x in [3_u16, 4_u16] {
2953 let mut app = setup_floating_wide_brush();
2954
2955 app.handle_event(Event::Mouse(MouseEvent {
2956 kind: MouseEventKind::Down(MouseButton::Left),
2957 column: start_x,
2958 row: 2,
2959 modifiers: KeyModifiers::NONE,
2960 }));
2961 app.handle_event(Event::Mouse(MouseEvent {
2962 kind: MouseEventKind::Drag(MouseButton::Left),
2963 column: start_x + 4,
2964 row: 3,
2965 modifiers: KeyModifiers::NONE,
2966 }));
2967 for column in (start_x + 5)..=(start_x + 11) {
2968 app.handle_event(Event::Mouse(MouseEvent {
2969 kind: MouseEventKind::Drag(MouseButton::Left),
2970 column,
2971 row: 3,
2972 modifiers: KeyModifiers::NONE,
2973 }));
2974 }
2975 app.handle_event(Event::Mouse(MouseEvent {
2976 kind: MouseEventKind::Up(MouseButton::Left),
2977 column: start_x + 11,
2978 row: 3,
2979 modifiers: KeyModifiers::NONE,
2980 }));
2981
2982 let row_three = wide_origins_in_row(&app, 3, (start_x + 13) as usize);
2983 assert!(
2984 row_three.len() >= 4,
2985 "expected multiple visible stamps on shallow row for start_x={start_x}: {row_three:?}"
2986 );
2987 assert!(
2988 row_three.windows(2).all(|pair| pair[1] - pair[0] <= 2),
2989 "row 3 gap too large for start_x={start_x}: {row_three:?}"
2990 );
2991 }
2992 }
2993
2994 #[test]
2995 fn system_clipboard_export_uses_selection_when_present() {
2996 let mut app = App::new();
2997 app.canvas.width = 4;
2998 app.canvas.height = 3;
2999 app.canvas.set(Pos { x: 1, y: 1 }, 'A');
3000 app.canvas.set(Pos { x: 2, y: 1 }, 'B');
3001 app.canvas.set(Pos { x: 1, y: 2 }, 'C');
3002 app.canvas.set(Pos { x: 2, y: 2 }, 'D');
3003 app.selection_anchor = Some(Pos { x: 1, y: 1 });
3004 app.cursor = Pos { x: 2, y: 2 };
3005 app.mode = Mode::Select;
3006
3007 assert_eq!(app.export_system_clipboard_text(), "AB\nCD");
3008 }
3009
3010 #[test]
3011 fn system_clipboard_export_uses_full_canvas_without_selection() {
3012 let mut app = App::new();
3013 app.canvas.width = 3;
3014 app.canvas.height = 2;
3015 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
3016 app.canvas.set(Pos { x: 2, y: 1 }, 'Z');
3017
3018 assert_eq!(app.export_system_clipboard_text(), "A \n Z");
3019 }
3020
3021 #[test]
3022 fn intent_api_emits_copy_effect_for_alt_c() {
3023 let mut app = App::new();
3024 app.canvas.width = 1;
3025 app.canvas.height = 1;
3026 app.canvas.set(Pos { x: 0, y: 0 }, 'A');
3027
3028 let effects = app.handle_intent(AppIntent::KeyPress(AppKey {
3029 code: AppKeyCode::Char('c'),
3030 modifiers: AppModifiers {
3031 alt: true,
3032 ..Default::default()
3033 },
3034 }));
3035
3036 assert_eq!(effects, vec![HostEffect::CopyToClipboard("A".to_string())]);
3037 }
3038}