Skip to main content

dartboard_cli/
app.rs

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
48/// The transport backing a single dartboard session. Embedded runs a
49/// ServerHandle in-process with one LocalClient per local user; Remote
50/// connects to a dartboard `--listen` peer over ws with a single client.
51pub enum Transport {
52    Embedded {
53        server: ServerHandle,
54        clients: Vec<ClientBox>,
55    },
56    Remote {
57        client: ClientBox,
58        mirror: SessionMirror,
59    },
60}
61
62/// Concrete enum wrapping the two Client impls so App doesn't need dyn Client.
63pub 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    /// Construct an App that talks to a remote dartboard server over ws
336    /// instead of an in-proc ServerHandle. There is exactly one local user
337    /// (the connected user); peer presence is tracked from server events.
338    ///
339    /// Drains the server until Welcome is received (my_user_id set). This
340    /// avoids a race where the first keystroke submits an op before the
341    /// Welcome snapshot is applied — otherwise Welcome's pre-join empty
342    /// snapshot would stomp the user's first paint.
343    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        // In Remote mode, index > 0 are read-only peer views — don't swap to
436        // them as if they were a local session.
437        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    /// Total participants the server is aware of. Embedded: every LocalClient
522    /// counts (all local users). Remote: our peers + us.
523    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    /// Undo/redo are only safe when no other peer could be editing. For
531    /// Embedded mode, every "peer" is a local user whose edits we own, so
532    /// undo is always allowed. For Remote mode, undo is gated to sole-peer
533    /// sessions — per PLAN-MULTIPLAYER-WS-DEMO.md, a local snapshot stack
534    /// isn't coherent under LWW with other writers.
535    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                            // users[0] is self; peers start at index 1.
588                            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(&current, &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(&current, &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(&sections)
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(&sections, 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(&sections, 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(&sections, 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        // A stroke's undo snapshot is the canvas BEFORE the Down event
1204        // painted anything; capture from the pre-event clone here.
1205        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        // Regression guard for the "Replace wipes other client's work" bug.
1890        // Two clients submit edits to disjoint cells; the server canvas must
1891        // hold both after both apply.
1892        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        // Regression guard for the Welcome race: new_remote must fully drain
1932        // Welcome before returning, so the user's first paint isn't
1933        // overwritten by an empty snapshot arriving late.
1934        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        // Pre-seed the server with one cell to prove the snapshot actually
1947        // arrives — we expect our mirror to reflect it immediately.
1948        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        // After new_remote returns, Welcome must have been applied.
1971        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        // stand up a server + one "other" local peer to represent the
1996        // multi-user condition, then a ws client that drives App::new_remote.
1997        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        // Pre-existing peer (simulates another dartboard --connect having joined first)
2004        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        // Drain Welcome + any peer events
2022        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                &sections,
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        // Another copy on same selection: still no auto-lift, just another swatch push.
2287        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        // Most recent is at index 0.
2319        assert_eq!(
2320            app.swatches[0].as_ref().unwrap().clipboard.get(0, 0),
2321            Some(CellValue::Narrow('F'))
2322        );
2323        // Oldest ('A') evicted once a sixth swatch pushed in.
2324        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        // Slot order after three copies: [C (idx 0), B (idx 1), A (idx 2), _, _].
2339        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        // Push three more; B at slot 1 must not move or get evicted.
2347        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        // Slot 1 still B (pinned).
2354        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        // Newest (F) sits at slot 0.
2360        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); // lift from the older swatch (A at slot 1)
2418        assert_eq!(app.floating.as_ref().unwrap().source_index, Some(1));
2419
2420        // ^a while floating switches to slot 0 (B).
2421        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        // Pressing ^a again cycles transparency for the active swatch.
2426        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        // Pressing '1' now dismisses the lift and draws the digit like any other char.
2440        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); // flip to transparent
2494        assert!(app.floating.as_ref().unwrap().transparent);
2495
2496        app.activate_swatch(1); // switch: should be opaque again
2497        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        // Swatch history still intact so the user can re-enter.
2554        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        // Place existing content at stamp target
2621        app.canvas.set(Pos { x: 5, y: 5 }, 'Z');
2622
2623        // Move float and stamp
2624        app.cursor = Pos { x: 4, y: 5 };
2625        app.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL));
2626
2627        // A stamped at (4,5), space at (5,5) skipped so Z preserved, B at (6,5)
2628        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        // Paint stroke: click, drag to two positions, release
2646        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        // All three positions stamped
2672        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        // Float still active
2677        assert!(app.floating.is_some());
2678
2679        // Single undo reverts the entire paint stroke
2680        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        // Stamp at two positions
2698        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        // Undo only the second stamp
2707        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}