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