Skip to main content

muxr_client/
copy_selection.rs

1use std::collections::BTreeMap;
2use std::collections::BTreeSet;
3use std::time::Instant;
4
5use muxr_core::ClientMousePosition;
6use muxr_core::ClientRequest;
7use muxr_core::PaneId;
8use muxr_core::PaneRegionContainment;
9use muxr_core::PaneRegionSnapshot;
10use muxr_core::PaneRegionsSnapshot;
11use muxr_core::PaneScrollDirection;
12use muxr_core::PaneScrollLineMove;
13use muxr_core::RenderCellWidth;
14use muxr_core::RowWrap;
15use rootcause::prelude::ResultExt;
16use rootcause::report;
17
18use crate::frame_buffer::FrameBuffer;
19
20const DOUBLE_CLICK_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(400);
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum SelectionInput {
24    Start(ClientMousePosition),
25    Update(ClientMousePosition),
26    End(ClientMousePosition),
27}
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
30pub enum SelectionClickOutcome {
31    Double,
32    Single,
33}
34
35#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36pub enum SelectionChange {
37    Changed,
38    Unchanged,
39}
40
41impl SelectionChange {
42    fn between(next: Option<&SelectionRange>, previous: Option<&SelectionRange>) -> Self {
43        if next == previous {
44            Self::Unchanged
45        } else {
46            Self::Changed
47        }
48    }
49}
50
51#[derive(Clone, Debug, Default)]
52pub struct SelectionClickTracker {
53    count: u8,
54    previous: Option<TrackedClick>,
55}
56
57impl SelectionClickTracker {
58    pub fn record_selection_start(
59        &mut self,
60        position: ClientMousePosition,
61        regions: &PaneRegionsSnapshot,
62        frame_buffer: &FrameBuffer,
63        now: Instant,
64    ) -> SelectionClickOutcome {
65        let Some(target) = self::click_target(position, regions, frame_buffer) else {
66            self.reset();
67            return SelectionClickOutcome::Single;
68        };
69        self.record_target(target, now)
70    }
71
72    pub fn retain_for_regions(&mut self, regions: &PaneRegionsSnapshot) {
73        let keep_previous = self
74            .previous
75            .as_ref()
76            .is_some_and(|previous| previous.target.region_state(regions) == ClickTargetRegion::Present);
77        if !keep_previous {
78            self.reset();
79        }
80    }
81
82    const fn reset(&mut self) {
83        self.count = 0;
84        self.previous = None;
85    }
86
87    fn record_target(&mut self, target: ClickTarget, now: Instant) -> SelectionClickOutcome {
88        let continues_previous = self.previous.as_ref().is_some_and(|previous| {
89            previous.target == target
90                && now
91                    .checked_duration_since(previous.at)
92                    .is_some_and(|elapsed| elapsed <= DOUBLE_CLICK_THRESHOLD)
93        });
94        self.count = if continues_previous {
95            self.count.saturating_add(1)
96        } else {
97            1
98        };
99        self.previous = Some(TrackedClick { at: now, target });
100        if self.count == 2 {
101            SelectionClickOutcome::Double
102        } else {
103            SelectionClickOutcome::Single
104        }
105    }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109enum ClickTarget {
110    Cell {
111        pane_id: PaneId,
112        position: ClientMousePosition,
113    },
114    Word {
115        end: ClientMousePosition,
116        pane_id: PaneId,
117        start: ClientMousePosition,
118    },
119}
120
121impl ClickTarget {
122    fn region_state(&self, regions: &PaneRegionsSnapshot) -> ClickTargetRegion {
123        match self {
124            Self::Cell { pane_id, position } => {
125                if regions.pane_at(*position).is_some_and(|region| region.id() == pane_id) {
126                    ClickTargetRegion::Present
127                } else {
128                    ClickTargetRegion::Missing
129                }
130            }
131            Self::Word { end, pane_id, start } => {
132                if self::matching_region(regions, *pane_id).is_some_and(|region| {
133                    region.containment(start.row, start.col) == PaneRegionContainment::Inside
134                        && region.containment(end.row, end.col) == PaneRegionContainment::Inside
135                }) {
136                    ClickTargetRegion::Present
137                } else {
138                    ClickTargetRegion::Missing
139                }
140            }
141        }
142    }
143}
144
145#[derive(Clone, Copy, Debug, Eq, PartialEq)]
146enum ClickTargetRegion {
147    Missing,
148    Present,
149}
150
151#[derive(Clone, Debug)]
152struct TrackedClick {
153    at: Instant,
154    target: ClickTarget,
155}
156
157// Tracks one edge-scroll request through server ack and rendered-frame ack so retry cannot outrun viewport updates.
158#[derive(Clone, Debug, Default, Eq, PartialEq)]
159enum SelectionEdgeScrollPendingState {
160    #[default]
161    Idle,
162    WaitingForViewportMove(SelectionEdgeScrollPending),
163    WaitingForRender(SelectionEdgeScrollPending),
164}
165
166impl SelectionEdgeScrollPendingState {
167    const fn pending(&self) -> Option<&SelectionEdgeScrollPending> {
168        match self {
169            Self::Idle => None,
170            Self::WaitingForRender(pending) | Self::WaitingForViewportMove(pending) => Some(pending),
171        }
172    }
173}
174
175#[derive(Clone, Debug, Default, Eq, PartialEq)]
176pub struct SelectionEdgeScrollState {
177    drag: Option<SelectionEdgeDrag>,
178    pending: SelectionEdgeScrollPendingState,
179}
180
181impl SelectionEdgeScrollState {
182    pub fn retain_for_regions(&mut self, regions: &PaneRegionsSnapshot) {
183        self.drag = self
184            .drag
185            .take()
186            .and_then(|drag| self::matching_region(regions, drag.pane_id).map(|_| drag));
187        self.update_pending(regions);
188    }
189
190    pub fn set_edge_drag(
191        &mut self,
192        position: ClientMousePosition,
193        forced_direction: Option<PaneScrollDirection>,
194        drag_region: Option<&PaneRegionSnapshot>,
195    ) -> Option<SelectionEdgeScrollRequest> {
196        self.set_edge_drag_with_trigger(
197            position,
198            forced_direction,
199            drag_region,
200            SelectionEdgeScrollTrigger::EdgeRow,
201        )
202    }
203
204    pub fn set_outside_edge_drag(
205        &mut self,
206        position: ClientMousePosition,
207        drag_region: Option<&PaneRegionSnapshot>,
208    ) -> Option<SelectionEdgeScrollRequest> {
209        self.set_edge_drag_with_trigger(position, None, drag_region, SelectionEdgeScrollTrigger::OutsideOnly)
210    }
211
212    pub fn drag_position(&self, regions: &PaneRegionsSnapshot) -> Option<ClientMousePosition> {
213        let drag = self.drag.as_ref()?;
214        let region = self::matching_region(regions, drag.pane_id)?;
215        Some(ClientMousePosition {
216            row: self::selection_edge_row(&region, drag.direction),
217            col: drag.col.clamp(region.col(), self::last_region_col_saturating(&region)),
218        })
219    }
220
221    pub fn scroll_request(&self, regions: &PaneRegionsSnapshot) -> Option<SelectionEdgeScrollRequest> {
222        let drag = self.drag.clone()?;
223        let region = self::matching_region(regions, drag.pane_id)?;
224        let position = self.drag_position(regions)?;
225        self.scroll_request_for(drag.direction, drag.pane_id, region.visible_top_row(), position)
226    }
227
228    pub fn apply_scroll_pane_line_result(
229        &mut self,
230        position: ClientMousePosition,
231        direction: PaneScrollDirection,
232        movement: PaneScrollLineMove,
233    ) {
234        let matches_pending = self
235            .pending
236            .pending()
237            .is_some_and(|pending| pending.position == position && pending.direction == direction);
238        if matches_pending && matches!(movement, PaneScrollLineMove::Unchanged) {
239            // No-op scrolls, usually hard top/bottom scrollback bounds, do not produce a render ack. Clear only the
240            // matching pending request so the held edge drag can retry without unrelated events breaking coalescing.
241            self.pending = SelectionEdgeScrollPendingState::Idle;
242        }
243    }
244
245    pub fn clear_render_acknowledged_pending(&mut self, expected: &SelectionEdgeScrollPending) {
246        if matches!(&self.pending, SelectionEdgeScrollPendingState::WaitingForRender(pending) if pending == expected) {
247            self.pending = SelectionEdgeScrollPendingState::Idle;
248        }
249    }
250
251    pub const fn active_state(&self) -> SelectionEdgeScrollActive {
252        match self.drag {
253            Some(_) => SelectionEdgeScrollActive::Active,
254            None => SelectionEdgeScrollActive::Inactive,
255        }
256    }
257
258    pub const fn waits_for_render(&self) -> bool {
259        matches!(&self.pending, SelectionEdgeScrollPendingState::WaitingForRender(_))
260    }
261
262    pub const fn render_pending(&self) -> Option<&SelectionEdgeScrollPending> {
263        match &self.pending {
264            SelectionEdgeScrollPendingState::WaitingForRender(pending) => Some(pending),
265            SelectionEdgeScrollPendingState::Idle | SelectionEdgeScrollPendingState::WaitingForViewportMove(_) => None,
266        }
267    }
268
269    pub const fn mark_sent(&mut self, pending: SelectionEdgeScrollPending) {
270        self.pending = SelectionEdgeScrollPendingState::WaitingForViewportMove(pending);
271    }
272
273    pub const fn clear(&mut self) {
274        self.drag = None;
275        self.pending = SelectionEdgeScrollPendingState::Idle;
276    }
277
278    fn set_edge_drag_with_trigger(
279        &mut self,
280        position: ClientMousePosition,
281        forced_direction: Option<PaneScrollDirection>,
282        drag_region: Option<&PaneRegionSnapshot>,
283        trigger: SelectionEdgeScrollTrigger,
284    ) -> Option<SelectionEdgeScrollRequest> {
285        let Some(region) = drag_region else {
286            self.pending = SelectionEdgeScrollPendingState::Idle;
287            return None;
288        };
289        let (direction, row) = if let Some(direction) = forced_direction {
290            (direction, self::selection_edge_row(region, direction))
291        } else if let Some(direction) = self::selection_edge_direction(position, region, trigger) {
292            (direction, self::selection_edge_row(region, direction))
293        } else {
294            self.drag = None;
295            self.pending = SelectionEdgeScrollPendingState::Idle;
296            return None;
297        };
298        if self
299            .pending
300            .pending()
301            .is_some_and(|pending| pending.direction != direction || pending.pane_id != *region.id())
302        {
303            self.pending = SelectionEdgeScrollPendingState::Idle;
304        }
305        let col = position
306            .col
307            .clamp(region.col(), self::last_region_col_saturating(region));
308        let pane_id = *region.id();
309        self.drag = Some(SelectionEdgeDrag {
310            col,
311            direction,
312            pane_id,
313        });
314        self.scroll_request_for(
315            direction,
316            pane_id,
317            region.visible_top_row(),
318            ClientMousePosition { row, col },
319        )
320    }
321
322    const fn scroll_request_for(
323        &self,
324        direction: PaneScrollDirection,
325        pane_id: PaneId,
326        previous_visible_top_row: u64,
327        position: ClientMousePosition,
328    ) -> Option<SelectionEdgeScrollRequest> {
329        if !matches!(&self.pending, SelectionEdgeScrollPendingState::Idle) {
330            return None;
331        }
332        Some(SelectionEdgeScrollRequest {
333            pending: SelectionEdgeScrollPending {
334                direction,
335                pane_id,
336                position,
337                previous_visible_top_row,
338            },
339            request: ClientRequest::ScrollPaneLineAt { direction, position },
340        })
341    }
342
343    fn update_pending(&mut self, regions: &PaneRegionsSnapshot) {
344        let Some(pending) = self.pending.pending().cloned() else {
345            return;
346        };
347        let Some(region) = self::matching_region(regions, pending.pane_id) else {
348            self.pending = SelectionEdgeScrollPendingState::Idle;
349            return;
350        };
351        let viewport_moved = match pending.direction {
352            PaneScrollDirection::Down => region.visible_top_row() > pending.previous_visible_top_row,
353            PaneScrollDirection::Up => region.visible_top_row() < pending.previous_visible_top_row,
354        };
355        self.pending = if viewport_moved {
356            SelectionEdgeScrollPendingState::WaitingForRender(pending)
357        } else {
358            SelectionEdgeScrollPendingState::WaitingForViewportMove(pending)
359        };
360    }
361}
362
363#[derive(Clone, Copy, Debug, Eq, PartialEq)]
364pub enum SelectionEdgeScrollActive {
365    Active,
366    Inactive,
367}
368
369#[derive(Clone, Debug, Eq, PartialEq)]
370struct SelectionEdgeDrag {
371    col: u16,
372    direction: PaneScrollDirection,
373    pane_id: PaneId,
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
377pub struct SelectionEdgeScrollPending {
378    direction: PaneScrollDirection,
379    pane_id: PaneId,
380    position: ClientMousePosition,
381    previous_visible_top_row: u64,
382}
383
384#[derive(Clone, Debug, Eq, PartialEq)]
385pub struct SelectionEdgeScrollRequest {
386    pending: SelectionEdgeScrollPending,
387    request: ClientRequest,
388}
389
390impl SelectionEdgeScrollRequest {
391    pub fn into_parts(self) -> (SelectionEdgeScrollPending, ClientRequest) {
392        (self.pending, self.request)
393    }
394}
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq)]
397enum SelectionEdgeScrollTrigger {
398    EdgeRow,
399    OutsideOnly,
400}
401
402#[derive(Clone, Debug, Default, Eq, PartialEq)]
403pub struct SelectionState {
404    // Copy text is cached by stable content row so viewport scrolling moves the highlight without dropping rows that
405    // have already passed off-screen during a drag selection. The final joined string is built lazily on copy so drag
406    // updates do not rebuild the full selected text every mouse packet.
407    cached_rows: BTreeMap<u64, CachedSelectionRow>,
408    drag: Option<SelectionDrag>,
409    selected: Option<SelectionRange>,
410}
411
412impl SelectionState {
413    pub fn apply(
414        &mut self,
415        input: SelectionInput,
416        regions: &PaneRegionsSnapshot,
417        frame_buffer: &FrameBuffer,
418    ) -> rootcause::Result<SelectionChange> {
419        let previous = self.selected.clone();
420        match input {
421            SelectionInput::Start(position) => self.start(position, regions, frame_buffer),
422            SelectionInput::Update(position) => self.update(position, frame_buffer)?,
423            SelectionInput::End(position) => self.end(position, frame_buffer)?,
424        }
425        Ok(SelectionChange::between(self.selected.as_ref(), previous.as_ref()))
426    }
427
428    pub fn clear_if_regions_changed(&mut self, regions: &PaneRegionsSnapshot) -> SelectionChange {
429        let previous = self.selected.clone();
430        self.drag = self.drag.take().and_then(|drag| {
431            self::matching_region(regions, *drag.region.id()).map(|region| SelectionDrag {
432                anchor: drag.anchor,
433                raw_anchor: self::clamp_to_region(drag.raw_anchor, &region),
434                region,
435            })
436        });
437        self.selected = self.selected.take().and_then(|selected| {
438            self::matching_region(regions, *selected.region.id()).map(|region| selected.with_region(region))
439        });
440        if self.selected.is_none() {
441            self.cached_rows.clear();
442        } else {
443            self.retain_cached_rows();
444        }
445        SelectionChange::between(self.selected.as_ref(), previous.as_ref())
446    }
447
448    pub fn selected_text(&self) -> Option<String> {
449        self.selected
450            .as_ref()
451            .and_then(|selection| self::selected_text(&self.cached_rows, selection))
452            .filter(|text| !text.is_empty())
453    }
454
455    pub fn selected_inline_text(&self) -> Option<String> {
456        self.selected
457            .as_ref()
458            .and_then(|selection| self::selected_inline_text(&self.cached_rows, selection))
459            .filter(|text| !text.is_empty())
460    }
461
462    pub fn select_word(
463        &mut self,
464        position: ClientMousePosition,
465        regions: &PaneRegionsSnapshot,
466        frame_buffer: &FrameBuffer,
467    ) -> rootcause::Result<SelectionChange> {
468        let previous = self.selected.clone();
469        self.drag = None;
470        self.set_selected(self::word_selection_at(position, regions, frame_buffer), frame_buffer)?;
471        Ok(SelectionChange::between(self.selected.as_ref(), previous.as_ref()))
472    }
473
474    #[must_use]
475    pub const fn range(&self) -> Option<&SelectionRange> {
476        self.selected.as_ref()
477    }
478
479    #[must_use]
480    pub fn drag_region(&self) -> Option<&PaneRegionSnapshot> {
481        self.drag.as_ref().map(|drag| &drag.region)
482    }
483
484    pub fn refresh_visible_rows(&mut self, frame_buffer: &FrameBuffer) -> rootcause::Result<()> {
485        self.rebuild_selected_text(frame_buffer)
486    }
487
488    fn start(&mut self, position: ClientMousePosition, regions: &PaneRegionsSnapshot, frame_buffer: &FrameBuffer) {
489        let Some(region) = regions.pane_at(position) else {
490            self.clear();
491            return;
492        };
493        let region = region.clone();
494        let raw_anchor = self::clamp_to_region(position, &region);
495        let Some(anchor) =
496            self::content_position(self::selectable_position(raw_anchor, &region, frame_buffer), &region)
497        else {
498            self.clear();
499            return;
500        };
501        self.drag = Some(SelectionDrag {
502            anchor,
503            raw_anchor,
504            region,
505        });
506        self.selected = None;
507        self.cached_rows.clear();
508    }
509
510    fn update(&mut self, position: ClientMousePosition, frame_buffer: &FrameBuffer) -> rootcause::Result<()> {
511        let Some(drag) = &self.drag else {
512            return Ok(());
513        };
514        let raw_focus = self::clamp_to_region(position, &drag.region);
515        let Some(focus) = self::content_position(
516            self::selectable_position(raw_focus, &drag.region, frame_buffer),
517            &drag.region,
518        ) else {
519            self.set_selected(None, frame_buffer)?;
520            return Ok(());
521        };
522        self.set_selected(
523            Some(SelectionRange {
524                anchor: drag.anchor,
525                focus,
526                region: drag.region.clone(),
527            }),
528            frame_buffer,
529        )
530    }
531
532    fn end(&mut self, position: ClientMousePosition, frame_buffer: &FrameBuffer) -> rootcause::Result<()> {
533        let Some(drag) = self.drag.take() else {
534            return Ok(());
535        };
536        let raw_focus = self::clamp_to_region(position, &drag.region);
537        let Some(focus) = self::content_position(
538            self::selectable_position(raw_focus, &drag.region, frame_buffer),
539            &drag.region,
540        ) else {
541            self.set_selected(None, frame_buffer)?;
542            return Ok(());
543        };
544        let selected = (raw_focus != drag.raw_anchor).then_some(SelectionRange {
545            anchor: drag.anchor,
546            focus,
547            region: drag.region,
548        });
549        self.set_selected(selected, frame_buffer)
550    }
551
552    fn set_selected(&mut self, selected: Option<SelectionRange>, frame_buffer: &FrameBuffer) -> rootcause::Result<()> {
553        self.selected = selected;
554        self.rebuild_selected_text(frame_buffer)?;
555        Ok(())
556    }
557
558    fn rebuild_selected_text(&mut self, frame_buffer: &FrameBuffer) -> rootcause::Result<()> {
559        if self.selected.is_none() {
560            self.cached_rows.clear();
561            return Ok(());
562        }
563
564        self.retain_cached_rows();
565        if let Some(selection) = self.selected.as_ref() {
566            self::cache_visible_selected_rows(&mut self.cached_rows, frame_buffer, selection)?;
567        }
568        Ok(())
569    }
570
571    fn retain_cached_rows(&mut self) {
572        let Some(selected) = self.selected.as_ref() else {
573            self.cached_rows.clear();
574            return;
575        };
576        self.cached_rows
577            .retain(|content_row, _| selected.contains_content_row(*content_row));
578    }
579
580    fn clear(&mut self) {
581        self.cached_rows.clear();
582        self.drag = None;
583        self.selected = None;
584    }
585}
586
587#[derive(Clone, Debug, Eq, PartialEq)]
588pub struct SelectionRange {
589    anchor: SelectionContentPosition,
590    focus: SelectionContentPosition,
591    region: PaneRegionSnapshot,
592}
593
594impl SelectionRange {
595    #[must_use]
596    pub fn contains(&self, row: u16, col: u16) -> bool {
597        if self.region.containment(row, col) == PaneRegionContainment::Outside {
598            return false;
599        }
600        let Some(position) = self::content_position(ClientMousePosition { row, col }, &self.region) else {
601            return false;
602        };
603
604        let bounds = self.bounds();
605        if position.row < bounds.start.row || position.row > bounds.end.row {
606            return false;
607        }
608
609        if bounds.start.row == bounds.end.row {
610            return position.col >= bounds.start.col && position.col <= bounds.end.col;
611        }
612        if position.row == bounds.start.row {
613            return position.col >= bounds.start.col;
614        }
615        if position.row == bounds.end.row {
616            return position.col <= bounds.end.col;
617        }
618        true
619    }
620
621    #[must_use]
622    pub fn row_bounds(&self) -> Option<(u16, u16)> {
623        let bounds = self.bounds();
624        let viewport_start = self.region.visible_top_row();
625        let viewport_end = viewport_start.saturating_add(u64::from(self.region.rows().saturating_sub(1)));
626        let start = bounds.start.row.max(viewport_start);
627        let end = bounds.end.row.min(viewport_end);
628        if start > end {
629            return None;
630        }
631
632        Some((
633            self::visible_row_for_content_row(&self.region, start)?,
634            self::visible_row_for_content_row(&self.region, end)?,
635        ))
636    }
637
638    #[must_use]
639    pub fn bounds_positions(&self) -> Option<(ClientMousePosition, ClientMousePosition)> {
640        let bounds = self.bounds();
641        Some((
642            self::visible_position(&self.region, bounds.start)?,
643            self::visible_position(&self.region, bounds.end)?,
644        ))
645    }
646
647    #[must_use]
648    pub const fn pane_id(&self) -> PaneId {
649        *self.region.id()
650    }
651
652    fn bounds(&self) -> SelectionBounds {
653        if (self.anchor.row, self.anchor.col) <= (self.focus.row, self.focus.col) {
654            SelectionBounds {
655                start: self.anchor,
656                end: self.focus,
657            }
658        } else {
659            SelectionBounds {
660                start: self.focus,
661                end: self.anchor,
662            }
663        }
664    }
665
666    fn contains_content_row(&self, row: u64) -> bool {
667        let bounds = self.bounds();
668        row >= bounds.start.row && row <= bounds.end.row
669    }
670
671    fn with_region(self, region: PaneRegionSnapshot) -> Self {
672        let last_col = region.cols().saturating_sub(1);
673        Self {
674            anchor: self.anchor.clamp_col(last_col),
675            focus: self.focus.clamp_col(last_col),
676            region,
677        }
678    }
679}
680
681#[derive(Clone, Copy, Debug, Eq, PartialEq)]
682struct SelectionContentPosition {
683    col: u16,
684    row: u64,
685}
686
687impl SelectionContentPosition {
688    #[must_use]
689    const fn clamp_col(self, last_col: u16) -> Self {
690        Self {
691            col: if self.col > last_col { last_col } else { self.col },
692            row: self.row,
693        }
694    }
695}
696
697#[derive(Clone, Debug, Eq, PartialEq)]
698struct SelectionDrag {
699    anchor: SelectionContentPosition,
700    raw_anchor: ClientMousePosition,
701    region: PaneRegionSnapshot,
702}
703
704#[derive(Clone, Copy)]
705struct SelectionBounds {
706    end: SelectionContentPosition,
707    start: SelectionContentPosition,
708}
709
710#[derive(Clone, Debug, Eq, PartialEq)]
711struct CachedSelectionCell {
712    text: String,
713    width: RenderCellWidth,
714}
715
716#[derive(Clone, Debug, Eq, PartialEq)]
717enum CachedSelectionRow {
718    EndsBeforeSoftWrap(Vec<CachedSelectionCell>),
719    EndsWithSoftWrap(Vec<CachedSelectionCell>),
720}
721
722impl CachedSelectionRow {
723    const fn new(cells: Vec<CachedSelectionCell>, row_wrap: RowWrap) -> Self {
724        match row_wrap {
725            RowWrap::EndsBeforeSoftWrap => Self::EndsBeforeSoftWrap(cells),
726            RowWrap::EndsWithSoftWrap => Self::EndsWithSoftWrap(cells),
727        }
728    }
729
730    fn cells(&self) -> &[CachedSelectionCell] {
731        match self {
732            Self::EndsBeforeSoftWrap(cells) | Self::EndsWithSoftWrap(cells) => cells,
733        }
734    }
735
736    const fn row_wrap(&self) -> RowWrap {
737        match self {
738            Self::EndsBeforeSoftWrap(_) => RowWrap::EndsBeforeSoftWrap,
739            Self::EndsWithSoftWrap(_) => RowWrap::EndsWithSoftWrap,
740        }
741    }
742}
743
744#[derive(Clone, Debug, Eq, PartialEq)]
745enum SelectedRowText {
746    EndsBeforeSoftWrap(String),
747    EndsWithSoftWrap(String),
748}
749
750impl SelectedRowText {
751    fn text(&self) -> &str {
752        match self {
753            Self::EndsBeforeSoftWrap(text) | Self::EndsWithSoftWrap(text) => text,
754        }
755    }
756
757    fn into_text(self) -> String {
758        match self {
759            Self::EndsBeforeSoftWrap(text) | Self::EndsWithSoftWrap(text) => text,
760        }
761    }
762
763    const fn row_wrap(&self) -> RowWrap {
764        match self {
765            Self::EndsBeforeSoftWrap(_) => RowWrap::EndsBeforeSoftWrap,
766            Self::EndsWithSoftWrap(_) => RowWrap::EndsWithSoftWrap,
767        }
768    }
769}
770
771pub fn copy_to_clipboard(text: &str) -> rootcause::Result<()> {
772    let mut bytes = text.as_bytes();
773    Ok(ytil_sys::file::cp_to_system_clipboard(&mut bytes).context("failed to copy muxr selection to clipboard")?)
774}
775
776fn selected_inline_text(cached_rows: &BTreeMap<u64, CachedSelectionRow>, selection: &SelectionRange) -> Option<String> {
777    let bounds = selection.bounds();
778    let mut inlined = String::new();
779    // Terminal wrap metadata is the only reliable boundary signal: full-width hard lines and soft-wrapped lines can
780    // have identical cells, but only soft wraps should suppress the inline separator.
781    let mut previous_row_wrap = RowWrap::EndsBeforeSoftWrap;
782    for content_row in bounds.start.row..=bounds.end.row {
783        let cached_row = cached_rows.get(&content_row)?;
784        let start_col = if content_row == bounds.start.row {
785            bounds.start.col
786        } else {
787            0
788        };
789        let end_col = if content_row == bounds.end.row {
790            bounds.end.col
791        } else {
792            selection.region.cols().saturating_sub(1)
793        };
794        let selected_row = self::selected_row_text(cached_row.cells(), start_col, end_col, cached_row.row_wrap());
795        let row_wrap = selected_row.row_wrap();
796        let line = selected_row.text().trim();
797        if line.is_empty() {
798            previous_row_wrap = RowWrap::EndsBeforeSoftWrap;
799            continue;
800        }
801        if !inlined.is_empty() && previous_row_wrap != RowWrap::EndsWithSoftWrap {
802            inlined.push(' ');
803        }
804        inlined.push_str(line);
805        previous_row_wrap = if content_row == bounds.end.row {
806            RowWrap::EndsBeforeSoftWrap
807        } else {
808            row_wrap
809        };
810    }
811    Some(inlined)
812}
813
814pub fn changed_selection_rows(previous: Option<&SelectionRange>, next: Option<&SelectionRange>) -> Vec<u16> {
815    let mut rows = BTreeSet::new();
816    for selection in [previous, next].into_iter().flatten() {
817        if let Some((start_row, end_row)) = selection.row_bounds() {
818            for row in start_row..=end_row {
819                rows.insert(row);
820            }
821        }
822    }
823    rows.into_iter().collect()
824}
825
826fn word_selection_at(
827    position: ClientMousePosition,
828    regions: &PaneRegionsSnapshot,
829    frame_buffer: &FrameBuffer,
830) -> Option<SelectionRange> {
831    let region = regions.pane_at(position)?.clone();
832    let position = self::clamp_to_region(position, &region);
833    if WordCell::at(frame_buffer, position.row, position.col, &region) == WordCell::No {
834        return None;
835    }
836
837    let start_col = self::word_start_col(frame_buffer, position.row, position.col, &region);
838    let end_col = self::word_end_col(frame_buffer, position.row, position.col, &region);
839    Some(SelectionRange {
840        anchor: self::content_position(
841            ClientMousePosition {
842                row: position.row,
843                col: start_col,
844            },
845            &region,
846        )?,
847        focus: self::content_position(
848            ClientMousePosition {
849                row: position.row,
850                col: end_col,
851            },
852            &region,
853        )?,
854        region: region.clone(),
855    })
856}
857
858fn click_target(
859    position: ClientMousePosition,
860    regions: &PaneRegionsSnapshot,
861    frame_buffer: &FrameBuffer,
862) -> Option<ClickTarget> {
863    let region = regions.pane_at(position)?;
864    if let Some(selection) = self::word_selection_at(position, regions, frame_buffer)
865        && let Some((start, end)) = selection.bounds_positions()
866    {
867        return Some(ClickTarget::Word {
868            end,
869            pane_id: selection.pane_id(),
870            start,
871        });
872    }
873
874    Some(ClickTarget::Cell {
875        pane_id: *region.id(),
876        position,
877    })
878}
879
880fn selected_text(cached_rows: &BTreeMap<u64, CachedSelectionRow>, selection: &SelectionRange) -> Option<String> {
881    let bounds = selection.bounds();
882    let mut lines = Vec::new();
883    for content_row in bounds.start.row..=bounds.end.row {
884        let Some(cached_row) = cached_rows.get(&content_row) else {
885            // A selected range is copyable only when every selected content row was rendered and cached; copying a
886            // subset would silently drop text after skipped/coalesced edge-scroll renders.
887            return None;
888        };
889        let start_col = if content_row == bounds.start.row {
890            bounds.start.col
891        } else {
892            0
893        };
894        let end_col = if content_row == bounds.end.row {
895            bounds.end.col
896        } else {
897            selection.region.cols().saturating_sub(1)
898        };
899        lines.push(self::selected_row_text(cached_row.cells(), start_col, end_col, cached_row.row_wrap()).into_text());
900    }
901    Some(lines.join("\n"))
902}
903
904fn cache_visible_selected_rows(
905    cached_rows: &mut BTreeMap<u64, CachedSelectionRow>,
906    frame_buffer: &FrameBuffer,
907    selection: &SelectionRange,
908) -> rootcause::Result<()> {
909    let bounds = selection.bounds();
910    let viewport_start = selection.region.visible_top_row();
911    let viewport_end = viewport_start.saturating_add(u64::from(selection.region.rows().saturating_sub(1)));
912    let start = bounds.start.row.max(viewport_start);
913    let end = bounds.end.row.min(viewport_end);
914    if start > end {
915        return Ok(());
916    }
917
918    for content_row in start..=end {
919        let Some(visible_row) = self::visible_row_for_content_row(&selection.region, content_row) else {
920            continue;
921        };
922        cached_rows.insert(
923            content_row,
924            CachedSelectionRow::new(
925                self::cached_row_cells(frame_buffer, visible_row, &selection.region)?,
926                selection.region.content_row_wrap(content_row),
927            ),
928        );
929    }
930    Ok(())
931}
932
933fn cached_row_cells(
934    frame_buffer: &FrameBuffer,
935    row: u16,
936    region: &PaneRegionSnapshot,
937) -> rootcause::Result<Vec<CachedSelectionCell>> {
938    let mut cells = Vec::with_capacity(usize::from(region.cols()));
939    for local_col in 0..region.cols() {
940        let absolute_col = region
941            .col()
942            .checked_add(local_col)
943            .ok_or_else(|| report!("muxr pane region column range overflowed"))?;
944        let cell = frame_buffer.cell(row, absolute_col).map_or_else(
945            || CachedSelectionCell {
946                text: String::new(),
947                width: RenderCellWidth::Narrow,
948            },
949            |cell| CachedSelectionCell {
950                text: cell.text().to_owned(),
951                width: cell.width(),
952            },
953        );
954        cells.push(cell);
955    }
956    Ok(cells)
957}
958
959fn selected_row_text(
960    cells: &[CachedSelectionCell],
961    start_col: u16,
962    end_col: u16,
963    row_wrap: RowWrap,
964) -> SelectedRowText {
965    let mut line = String::new();
966    for local_col in start_col..=end_col {
967        let Some(cell) = cells.get(usize::from(local_col)) else {
968            continue;
969        };
970        if matches!(cell.width, RenderCellWidth::WideContinuation) {
971            continue;
972        }
973        if cell.text.is_empty() {
974            line.push(' ');
975        } else {
976            line.push_str(&cell.text);
977        }
978    }
979    while line.ends_with(' ') {
980        line.pop();
981    }
982    match row_wrap {
983        RowWrap::EndsBeforeSoftWrap => SelectedRowText::EndsBeforeSoftWrap(line),
984        RowWrap::EndsWithSoftWrap => SelectedRowText::EndsWithSoftWrap(line),
985    }
986}
987
988fn word_start_col(frame_buffer: &FrameBuffer, row: u16, col: u16, region: &PaneRegionSnapshot) -> u16 {
989    let mut start_col = col;
990    while start_col > region.col() {
991        let previous_col = start_col.saturating_sub(1);
992        if WordCell::at(frame_buffer, row, previous_col, region) == WordCell::No {
993            break;
994        }
995        start_col = previous_col;
996    }
997    start_col
998}
999
1000fn word_end_col(frame_buffer: &FrameBuffer, row: u16, col: u16, region: &PaneRegionSnapshot) -> u16 {
1001    let mut end_col = col;
1002    let last_col = self::last_region_col_saturating(region);
1003    while end_col < last_col {
1004        let next_col = end_col.saturating_add(1);
1005        if WordCell::at(frame_buffer, row, next_col, region) == WordCell::No {
1006            break;
1007        }
1008        end_col = next_col;
1009    }
1010    end_col
1011}
1012
1013#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1014enum WordCell {
1015    No,
1016    Yes,
1017}
1018
1019impl WordCell {
1020    fn at(frame_buffer: &FrameBuffer, row: u16, col: u16, region: &PaneRegionSnapshot) -> Self {
1021        if region.containment(row, col) == PaneRegionContainment::Outside {
1022            return Self::No;
1023        }
1024        let Some(cell) = frame_buffer.cell(row, col) else {
1025            return Self::No;
1026        };
1027
1028        if matches!(cell.width(), RenderCellWidth::WideContinuation) {
1029            let Some(previous_col) = col.checked_sub(1) else {
1030                return Self::No;
1031            };
1032            if region.containment(row, previous_col) == PaneRegionContainment::Outside {
1033                return Self::No;
1034            }
1035            let Some(previous_cell) = frame_buffer.cell(row, previous_col) else {
1036                return Self::No;
1037            };
1038            return if matches!(previous_cell.width(), RenderCellWidth::Wide)
1039                && CellWhitespace::from(previous_cell.text()) == CellWhitespace::NonWhitespace
1040            {
1041                Self::Yes
1042            } else {
1043                Self::No
1044            };
1045        }
1046
1047        if CellWhitespace::from(cell.text()) == CellWhitespace::NonWhitespace {
1048            Self::Yes
1049        } else {
1050            Self::No
1051        }
1052    }
1053}
1054
1055#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1056enum CellWhitespace {
1057    NonWhitespace,
1058    Whitespace,
1059}
1060
1061impl From<&str> for CellWhitespace {
1062    fn from(text: &str) -> Self {
1063        if text.is_empty() || text.chars().all(char::is_whitespace) {
1064            Self::Whitespace
1065        } else {
1066            Self::NonWhitespace
1067        }
1068    }
1069}
1070
1071fn matching_region(regions: &PaneRegionsSnapshot, pane_id: PaneId) -> Option<PaneRegionSnapshot> {
1072    regions.regions().iter().find(|region| *region.id() == pane_id).cloned()
1073}
1074
1075fn content_position(position: ClientMousePosition, region: &PaneRegionSnapshot) -> Option<SelectionContentPosition> {
1076    if region.containment(position.row, position.col) == PaneRegionContainment::Outside {
1077        return None;
1078    }
1079    let row = region
1080        .visible_top_row()
1081        .checked_add(u64::from(position.row.saturating_sub(region.row())))?;
1082    Some(SelectionContentPosition {
1083        col: position.col.saturating_sub(region.col()),
1084        row,
1085    })
1086}
1087
1088fn visible_position(region: &PaneRegionSnapshot, position: SelectionContentPosition) -> Option<ClientMousePosition> {
1089    if position.col >= region.cols() {
1090        return None;
1091    }
1092    let row = self::visible_row_for_content_row(region, position.row)?;
1093    Some(ClientMousePosition {
1094        row,
1095        col: region.col().checked_add(position.col)?,
1096    })
1097}
1098
1099fn visible_row_for_content_row(region: &PaneRegionSnapshot, row: u64) -> Option<u16> {
1100    let local_row = row.checked_sub(region.visible_top_row())?;
1101    let local_row = u16::try_from(local_row).ok()?;
1102    if local_row >= region.rows() {
1103        return None;
1104    }
1105    region.row().checked_add(local_row)
1106}
1107
1108fn selectable_position(
1109    position: ClientMousePosition,
1110    region: &PaneRegionSnapshot,
1111    frame_buffer: &FrameBuffer,
1112) -> ClientMousePosition {
1113    let position = self::clamp_to_region(position, region);
1114    let Some(cell) = frame_buffer.cell(position.row, position.col) else {
1115        return position;
1116    };
1117    if !matches!(cell.width(), RenderCellWidth::WideContinuation) {
1118        return position;
1119    }
1120    let Some(previous_col) = position.col.checked_sub(1) else {
1121        return position;
1122    };
1123    if region.containment(position.row, previous_col) == PaneRegionContainment::Outside {
1124        return position;
1125    }
1126    let Some(previous_cell) = frame_buffer.cell(position.row, previous_col) else {
1127        return position;
1128    };
1129    // Mouse reports can land on the continuation half of a wide cell; snap back so copy/render keeps the glyph.
1130    if matches!(previous_cell.width(), RenderCellWidth::Wide) {
1131        ClientMousePosition {
1132            row: position.row,
1133            col: previous_col,
1134        }
1135    } else {
1136        position
1137    }
1138}
1139
1140fn clamp_to_region(position: ClientMousePosition, region: &PaneRegionSnapshot) -> ClientMousePosition {
1141    ClientMousePosition {
1142        row: position
1143            .row
1144            .clamp(region.row(), self::last_region_row_saturating(region)),
1145        col: position
1146            .col
1147            .clamp(region.col(), self::last_region_col_saturating(region)),
1148    }
1149}
1150
1151const fn selection_edge_row(region: &PaneRegionSnapshot, direction: PaneScrollDirection) -> u16 {
1152    match direction {
1153        PaneScrollDirection::Up => region.row(),
1154        PaneScrollDirection::Down => self::last_region_row_saturating(region),
1155    }
1156}
1157
1158const fn selection_edge_direction(
1159    position: ClientMousePosition,
1160    region: &PaneRegionSnapshot,
1161    trigger: SelectionEdgeScrollTrigger,
1162) -> Option<PaneScrollDirection> {
1163    match trigger {
1164        SelectionEdgeScrollTrigger::EdgeRow if position.row <= region.row() => Some(PaneScrollDirection::Up),
1165        SelectionEdgeScrollTrigger::EdgeRow if position.row >= self::last_region_row_saturating(region) => {
1166            Some(PaneScrollDirection::Down)
1167        }
1168        SelectionEdgeScrollTrigger::OutsideOnly if position.row < region.row() => Some(PaneScrollDirection::Up),
1169        SelectionEdgeScrollTrigger::OutsideOnly if position.row > self::last_region_row_saturating(region) => {
1170            Some(PaneScrollDirection::Down)
1171        }
1172        SelectionEdgeScrollTrigger::EdgeRow | SelectionEdgeScrollTrigger::OutsideOnly => None,
1173    }
1174}
1175
1176const fn last_region_col_saturating(region: &PaneRegionSnapshot) -> u16 {
1177    region.col().saturating_add(region.cols().saturating_sub(1))
1178}
1179
1180const fn last_region_row_saturating(region: &PaneRegionSnapshot) -> u16 {
1181    region.row().saturating_add(region.rows().saturating_sub(1))
1182}
1183
1184#[cfg(test)]
1185pub mod test_helpers {
1186    use muxr_core::ClientRequest;
1187
1188    use super::SelectionEdgeScrollRequest;
1189
1190    pub const fn edge_scroll_request(request: &SelectionEdgeScrollRequest) -> &ClientRequest {
1191        &request.request
1192    }
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use std::time::Duration;
1198    use std::time::Instant;
1199
1200    use muxr_core::RenderBaseline;
1201    use muxr_core::RenderCell;
1202    use muxr_core::RenderCursor;
1203    use muxr_core::RenderRowSpan;
1204    use muxr_core::RenderStyle;
1205    use muxr_core::RenderUpdate;
1206    use muxr_core::TerminalSize;
1207    use test_that::prelude::*;
1208
1209    use super::*;
1210
1211    #[rstest::rstest]
1212    #[case::same_cell_within_threshold(0, 0, 399, SelectionClickOutcome::Double)]
1213    #[case::same_cell_after_threshold(0, 0, 401, SelectionClickOutcome::Single)]
1214    #[case::different_cell_within_threshold(0, 1, 100, SelectionClickOutcome::Single)]
1215    fn test_selection_click_tracker_record_selection_start_when_clicks_are_repeated_detects_double_click(
1216        #[case] row: u16,
1217        #[case] col: u16,
1218        #[case] elapsed_ms: u64,
1219        #[case] expected_outcome: SelectionClickOutcome,
1220    ) -> rootcause::Result<()> {
1221        let mut clicks = SelectionClickTracker::default();
1222        let frame_buffer = FrameBuffer::default();
1223        let regions = pane_regions()?;
1224        let now = Instant::now();
1225        assert_that!(
1226            clicks.record_selection_start(ClientMousePosition { row: 0, col: 0 }, &regions, &frame_buffer, now),
1227            eq(SelectionClickOutcome::Single)
1228        );
1229
1230        let next_click_at = now
1231            .checked_add(Duration::from_millis(elapsed_ms))
1232            .ok_or_else(|| report!("muxr click tracker test instant overflowed"))?;
1233        assert_that!(
1234            clicks.record_selection_start(ClientMousePosition { row, col }, &regions, &frame_buffer, next_click_at),
1235            eq(expected_outcome)
1236        );
1237        Ok(())
1238    }
1239
1240    #[test]
1241    fn test_selection_state_when_drag_crosses_pane_border_clamps_to_start_pane() -> rootcause::Result<()> {
1242        let mut selection = SelectionState::default();
1243        let frame_buffer = FrameBuffer::default();
1244
1245        assert_that!(
1246            selection.apply(
1247                SelectionInput::Start(ClientMousePosition { row: 0, col: 2 }),
1248                &pane_regions()?,
1249                &frame_buffer,
1250            )?,
1251            eq(SelectionChange::Unchanged)
1252        );
1253        assert_that!(
1254            selection.apply(
1255                SelectionInput::Update(ClientMousePosition { row: 0, col: 8 }),
1256                &pane_regions()?,
1257                &frame_buffer,
1258            )?,
1259            eq(SelectionChange::Changed)
1260        );
1261
1262        let range = selection
1263            .range()
1264            .ok_or_else(|| report!("expected muxr selection range"))?;
1265        assert_that!(
1266            range.bounds_positions(),
1267            eq(Some((
1268                ClientMousePosition { row: 0, col: 2 },
1269                ClientMousePosition { row: 0, col: 4 },
1270            )))
1271        );
1272        Ok(())
1273    }
1274
1275    #[test]
1276    fn test_selection_state_selected_text_when_vertical_split_exists_copies_only_selected_pane() -> rootcause::Result<()>
1277    {
1278        let mut frame_buffer = FrameBuffer::default();
1279        frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))?;
1280        let mut selection = SelectionState::default();
1281
1282        selection.apply(
1283            SelectionInput::Start(ClientMousePosition { row: 0, col: 1 }),
1284            &pane_regions()?,
1285            &frame_buffer,
1286        )?;
1287        selection.apply(
1288            SelectionInput::End(ClientMousePosition { row: 0, col: 9 }),
1289            &pane_regions()?,
1290            &frame_buffer,
1291        )?;
1292
1293        assert_that!(selection.selected_text(), eq(Some("eft".to_owned())));
1294        Ok(())
1295    }
1296
1297    #[test]
1298    fn test_selection_state_selected_text_when_drag_starts_on_wide_continuation_copies_wide_cell()
1299    -> rootcause::Result<()> {
1300        let mut frame_buffer = FrameBuffer::default();
1301        frame_buffer.apply(RenderUpdate::Baseline(wide_render_baseline()?))?;
1302        let mut selection = SelectionState::default();
1303
1304        selection.apply(
1305            SelectionInput::Start(ClientMousePosition { row: 0, col: 1 }),
1306            &wide_pane_regions()?,
1307            &frame_buffer,
1308        )?;
1309        selection.apply(
1310            SelectionInput::End(ClientMousePosition { row: 0, col: 2 }),
1311            &wide_pane_regions()?,
1312            &frame_buffer,
1313        )?;
1314
1315        let range = selection
1316            .range()
1317            .ok_or_else(|| report!("expected muxr wide-cell drag selection range"))?;
1318        assert_that!(
1319            range.bounds_positions(),
1320            eq(Some((
1321                ClientMousePosition { row: 0, col: 0 },
1322                ClientMousePosition { row: 0, col: 2 },
1323            )))
1324        );
1325        assert_that!(selection.selected_text(), eq(Some("表".to_owned())));
1326        Ok(())
1327    }
1328
1329    #[test]
1330    fn test_selection_state_select_word_when_word_is_clicked_selects_whitespace_delimited_word() -> rootcause::Result<()>
1331    {
1332        let mut frame_buffer = FrameBuffer::default();
1333        frame_buffer.apply(RenderUpdate::Baseline(render_baseline()?))?;
1334        let mut selection = SelectionState::default();
1335
1336        assert_that!(
1337            selection.select_word(ClientMousePosition { row: 0, col: 8 }, &pane_regions()?, &frame_buffer,)?,
1338            eq(SelectionChange::Changed)
1339        );
1340
1341        assert_that!(selection.selected_text(), eq(Some("right".to_owned())));
1342        Ok(())
1343    }
1344
1345    #[rstest::rstest]
1346    #[case::wide_start(0)]
1347    #[case::wide_continuation(1)]
1348    fn test_selection_state_select_word_when_wide_cell_is_clicked_selects_whole_wide_cell(
1349        #[case] col: u16,
1350    ) -> rootcause::Result<()> {
1351        let mut frame_buffer = FrameBuffer::default();
1352        frame_buffer.apply(RenderUpdate::Baseline(wide_render_baseline()?))?;
1353        let mut selection = SelectionState::default();
1354
1355        assert_that!(
1356            selection.select_word(
1357                ClientMousePosition { row: 0, col },
1358                &wide_pane_regions()?,
1359                &frame_buffer,
1360            )?,
1361            eq(SelectionChange::Changed)
1362        );
1363
1364        let range = selection
1365            .range()
1366            .ok_or_else(|| report!("expected muxr wide-cell selection range"))?;
1367        assert_that!(
1368            range.bounds_positions(),
1369            eq(Some((
1370                ClientMousePosition { row: 0, col: 0 },
1371                ClientMousePosition { row: 0, col: 1 },
1372            )))
1373        );
1374        assert_that!(selection.selected_text(), eq(Some("表".to_owned())));
1375        Ok(())
1376    }
1377
1378    #[test]
1379    fn test_selection_state_when_pane_scrolls_keeps_text_and_moves_highlight_with_content() -> rootcause::Result<()> {
1380        let mut frame_buffer = FrameBuffer::default();
1381        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("aa", "bb", "cc")?))?;
1382        let mut selection = SelectionState::default();
1383
1384        selection.apply(
1385            SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }),
1386            &three_row_pane_regions(10)?,
1387            &frame_buffer,
1388        )?;
1389        selection.apply(
1390            SelectionInput::End(ClientMousePosition { row: 1, col: 1 }),
1391            &three_row_pane_regions(10)?,
1392            &frame_buffer,
1393        )?;
1394        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("zz", "aa", "bb")?))?;
1395
1396        assert_that!(
1397            selection.clear_if_regions_changed(&three_row_pane_regions(9)?),
1398            eq(SelectionChange::Changed)
1399        );
1400
1401        let range = selection
1402            .range()
1403            .ok_or_else(|| report!("expected muxr scrolled selection range"))?;
1404        assert_that!(
1405            range.bounds_positions(),
1406            eq(Some((
1407                ClientMousePosition { row: 2, col: 0 },
1408                ClientMousePosition { row: 2, col: 1 },
1409            )))
1410        );
1411        assert_that!(selection.selected_text(), eq(Some("bb".to_owned())));
1412        Ok(())
1413    }
1414
1415    #[test]
1416    fn test_selection_state_when_edge_drag_scrolls_keeps_offscreen_selected_text() -> rootcause::Result<()> {
1417        let mut frame_buffer = FrameBuffer::default();
1418        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("aa", "bb", "cc")?))?;
1419        let mut selection = SelectionState::default();
1420
1421        selection.apply(
1422            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1423            &three_row_pane_regions(9)?,
1424            &frame_buffer,
1425        )?;
1426        selection.apply(
1427            SelectionInput::Update(ClientMousePosition { row: 2, col: 1 }),
1428            &three_row_pane_regions(9)?,
1429            &frame_buffer,
1430        )?;
1431        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("bb", "cc", "dd")?))?;
1432        assert_that!(
1433            selection.clear_if_regions_changed(&three_row_pane_regions(10)?),
1434            eq(SelectionChange::Changed)
1435        );
1436        selection.refresh_visible_rows(&frame_buffer)?;
1437        selection.apply(
1438            SelectionInput::Update(ClientMousePosition { row: 2, col: 1 }),
1439            &three_row_pane_regions(10)?,
1440            &frame_buffer,
1441        )?;
1442
1443        assert_that!(selection.selected_text(), eq(Some("aa\nbb\ncc\ndd".to_owned())));
1444        Ok(())
1445    }
1446
1447    #[test]
1448    fn test_selection_state_selected_inline_text_when_rows_do_not_reach_edge_inserts_space() -> rootcause::Result<()> {
1449        let mut frame_buffer = FrameBuffer::default();
1450        frame_buffer.apply(RenderUpdate::Baseline(two_row_render_baseline(10, "let value", "+ 2")?))?;
1451        let mut selection = SelectionState::default();
1452
1453        selection.apply(
1454            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1455            &two_row_pane_regions(10)?,
1456            &frame_buffer,
1457        )?;
1458        selection.apply(
1459            SelectionInput::End(ClientMousePosition { row: 1, col: 2 }),
1460            &two_row_pane_regions(10)?,
1461            &frame_buffer,
1462        )?;
1463
1464        assert_that!(selection.selected_inline_text(), eq(Some("let value + 2".to_owned())));
1465        Ok(())
1466    }
1467
1468    #[test]
1469    fn test_selection_state_selected_inline_text_when_wrap_splits_word_continues_word() -> rootcause::Result<()> {
1470        let mut frame_buffer = FrameBuffer::default();
1471        frame_buffer.apply(RenderUpdate::Baseline(two_row_render_baseline(
1472            8, "(reasoni", "ng 54)",
1473        )?))?;
1474        let mut selection = SelectionState::default();
1475
1476        selection.apply(
1477            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1478            &two_row_pane_regions_with_wraps(8, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1479            &frame_buffer,
1480        )?;
1481        selection.apply(
1482            SelectionInput::End(ClientMousePosition { row: 1, col: 5 }),
1483            &two_row_pane_regions_with_wraps(8, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1484            &frame_buffer,
1485        )?;
1486
1487        assert_that!(selection.selected_inline_text(), eq(Some("(reasoning 54)".to_owned())));
1488        Ok(())
1489    }
1490
1491    #[test]
1492    fn test_selection_state_selected_inline_text_when_wrap_splits_number_continues_number() -> rootcause::Result<()> {
1493        let mut frame_buffer = FrameBuffer::default();
1494        frame_buffer.apply(RenderUpdate::Baseline(two_row_render_baseline(
1495            27,
1496            "output=107,820 (reasoning 5",
1497            "4,014)",
1498        )?))?;
1499        let mut selection = SelectionState::default();
1500
1501        selection.apply(
1502            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1503            &two_row_pane_regions_with_wraps(27, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1504            &frame_buffer,
1505        )?;
1506        selection.apply(
1507            SelectionInput::End(ClientMousePosition { row: 1, col: 5 }),
1508            &two_row_pane_regions_with_wraps(27, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1509            &frame_buffer,
1510        )?;
1511
1512        assert_that!(
1513            selection.selected_inline_text(),
1514            eq(Some("output=107,820 (reasoning 54,014)".to_owned()))
1515        );
1516        Ok(())
1517    }
1518
1519    #[test]
1520    fn test_selection_state_selected_inline_text_when_hard_line_reaches_edge_inserts_space() -> rootcause::Result<()> {
1521        let mut frame_buffer = FrameBuffer::default();
1522        frame_buffer.apply(RenderUpdate::Baseline(two_row_render_baseline(4, "WARN", "next")?))?;
1523        let mut selection = SelectionState::default();
1524
1525        selection.apply(
1526            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1527            &two_row_pane_regions(4)?,
1528            &frame_buffer,
1529        )?;
1530        selection.apply(
1531            SelectionInput::End(ClientMousePosition { row: 1, col: 3 }),
1532            &two_row_pane_regions(4)?,
1533            &frame_buffer,
1534        )?;
1535
1536        assert_that!(selection.selected_inline_text(), eq(Some("WARN next".to_owned())));
1537        Ok(())
1538    }
1539
1540    #[test]
1541    fn test_selection_state_selected_inline_text_when_wrap_ends_with_wide_cell_continues_word() -> rootcause::Result<()>
1542    {
1543        let style = RenderStyle::default();
1544        let mut frame_buffer = FrameBuffer::default();
1545        frame_buffer.apply(RenderUpdate::Baseline(two_row_render_baseline_cells(
1546            3,
1547            vec![
1548                RenderCell::narrow("a", style),
1549                RenderCell::wide("表", style),
1550                RenderCell::wide_continuation(style),
1551            ],
1552            padded_render_cells(3, "b"),
1553        )?))?;
1554        let mut selection = SelectionState::default();
1555
1556        selection.apply(
1557            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1558            &two_row_pane_regions_with_wraps(3, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1559            &frame_buffer,
1560        )?;
1561        selection.apply(
1562            SelectionInput::End(ClientMousePosition { row: 1, col: 0 }),
1563            &two_row_pane_regions_with_wraps(3, [RowWrap::EndsWithSoftWrap, RowWrap::EndsBeforeSoftWrap])?,
1564            &frame_buffer,
1565        )?;
1566
1567        assert_that!(selection.selected_inline_text(), eq(Some("a表b".to_owned())));
1568        Ok(())
1569    }
1570
1571    #[test]
1572    fn test_selection_state_selected_text_when_cached_row_is_missing_returns_none() -> rootcause::Result<()> {
1573        let mut frame_buffer = FrameBuffer::default();
1574        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("aa", "bb", "cc")?))?;
1575        let mut selection = SelectionState::default();
1576
1577        selection.apply(
1578            SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }),
1579            &three_row_pane_regions(9)?,
1580            &frame_buffer,
1581        )?;
1582        selection.apply(
1583            SelectionInput::Update(ClientMousePosition { row: 2, col: 1 }),
1584            &three_row_pane_regions(9)?,
1585            &frame_buffer,
1586        )?;
1587        frame_buffer.apply(RenderUpdate::Baseline(three_row_render_baseline("ee", "ff", "gg")?))?;
1588        assert_that!(
1589            selection.clear_if_regions_changed(&three_row_pane_regions(13)?),
1590            eq(SelectionChange::Changed)
1591        );
1592        selection.refresh_visible_rows(&frame_buffer)?;
1593        selection.apply(
1594            SelectionInput::Update(ClientMousePosition { row: 2, col: 1 }),
1595            &three_row_pane_regions(13)?,
1596            &frame_buffer,
1597        )?;
1598
1599        assert_that!(selection.selected_text(), eq(None));
1600        Ok(())
1601    }
1602
1603    fn pane_regions() -> rootcause::Result<PaneRegionsSnapshot> {
1604        PaneRegionsSnapshot::new(vec![
1605            PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, 5, 1, muxr_core::PaneMouseMode::None, 0)?,
1606            PaneRegionSnapshot::new(PaneId::new(2)?, 6, 0, 5, 1, muxr_core::PaneMouseMode::None, 0)?,
1607        ])
1608    }
1609
1610    fn wide_pane_regions() -> rootcause::Result<PaneRegionsSnapshot> {
1611        PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
1612            PaneId::new(1)?,
1613            0,
1614            0,
1615            3,
1616            1,
1617            muxr_core::PaneMouseMode::None,
1618            0,
1619        )?])
1620    }
1621
1622    fn three_row_pane_regions(visible_top_row: u64) -> rootcause::Result<PaneRegionsSnapshot> {
1623        PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
1624            PaneId::new(1)?,
1625            0,
1626            0,
1627            2,
1628            3,
1629            muxr_core::PaneMouseMode::None,
1630            visible_top_row,
1631        )?])
1632    }
1633
1634    fn two_row_pane_regions(cols: u16) -> rootcause::Result<PaneRegionsSnapshot> {
1635        self::two_row_pane_regions_with_wraps(cols, [RowWrap::EndsBeforeSoftWrap, RowWrap::EndsBeforeSoftWrap])
1636    }
1637
1638    fn two_row_pane_regions_with_wraps(
1639        cols: u16,
1640        wrapped_rows: [RowWrap; 2],
1641    ) -> rootcause::Result<PaneRegionsSnapshot> {
1642        PaneRegionsSnapshot::new(vec![
1643            PaneRegionSnapshot::new(PaneId::new(1)?, 0, 0, cols, 2, muxr_core::PaneMouseMode::None, 0)?
1644                .with_wrapped_rows(wrapped_rows.to_vec())?,
1645        ])
1646    }
1647
1648    fn render_baseline() -> rootcause::Result<RenderBaseline> {
1649        RenderBaseline::new(
1650            1,
1651            TerminalSize::new(11, 1)?,
1652            RenderCursor {
1653                row: 0,
1654                col: 0,
1655                shape: muxr_core::RenderCursorShape::Default,
1656                visibility: muxr_core::RenderCursorVisibility::Visible,
1657            },
1658            vec![RenderRowSpan::new(
1659                0,
1660                0,
1661                "left |right"
1662                    .chars()
1663                    .map(|ch| RenderCell::narrow(ch.to_string(), RenderStyle::default()))
1664                    .collect(),
1665            )?],
1666        )
1667    }
1668
1669    fn wide_render_baseline() -> rootcause::Result<RenderBaseline> {
1670        let style = RenderStyle::default();
1671        RenderBaseline::new(
1672            1,
1673            TerminalSize::new(3, 1)?,
1674            RenderCursor {
1675                row: 0,
1676                col: 0,
1677                shape: muxr_core::RenderCursorShape::Default,
1678                visibility: muxr_core::RenderCursorVisibility::Visible,
1679            },
1680            vec![RenderRowSpan::new(
1681                0,
1682                0,
1683                vec![
1684                    RenderCell::wide("表", style),
1685                    RenderCell::wide_continuation(style),
1686                    RenderCell::narrow(" ", style),
1687                ],
1688            )?],
1689        )
1690    }
1691
1692    fn three_row_render_baseline(first: &str, second: &str, third: &str) -> rootcause::Result<RenderBaseline> {
1693        RenderBaseline::new(
1694            1,
1695            TerminalSize::new(2, 3)?,
1696            RenderCursor {
1697                row: 0,
1698                col: 0,
1699                shape: muxr_core::RenderCursorShape::Default,
1700                visibility: muxr_core::RenderCursorVisibility::Visible,
1701            },
1702            vec![
1703                RenderRowSpan::new(0, 0, first.chars().map(render_cell).collect())?,
1704                RenderRowSpan::new(1, 0, second.chars().map(render_cell).collect())?,
1705                RenderRowSpan::new(2, 0, third.chars().map(render_cell).collect())?,
1706            ],
1707        )
1708    }
1709
1710    fn two_row_render_baseline(cols: u16, first: &str, second: &str) -> rootcause::Result<RenderBaseline> {
1711        self::two_row_render_baseline_cells(
1712            cols,
1713            padded_render_cells(cols, first),
1714            padded_render_cells(cols, second),
1715        )
1716    }
1717
1718    fn two_row_render_baseline_cells(
1719        cols: u16,
1720        first: Vec<RenderCell>,
1721        second: Vec<RenderCell>,
1722    ) -> rootcause::Result<RenderBaseline> {
1723        RenderBaseline::new(
1724            1,
1725            TerminalSize::new(cols, 2)?,
1726            RenderCursor {
1727                row: 0,
1728                col: 0,
1729                shape: muxr_core::RenderCursorShape::Default,
1730                visibility: muxr_core::RenderCursorVisibility::Visible,
1731            },
1732            vec![RenderRowSpan::new(0, 0, first)?, RenderRowSpan::new(1, 0, second)?],
1733        )
1734    }
1735
1736    fn padded_render_cells(cols: u16, text: &str) -> Vec<RenderCell> {
1737        let mut cells = text.chars().map(render_cell).collect::<Vec<_>>();
1738        while cells.len() < usize::from(cols) {
1739            cells.push(render_cell(' '));
1740        }
1741        cells
1742    }
1743
1744    fn render_cell(ch: char) -> RenderCell {
1745        RenderCell::narrow(ch.to_string(), RenderStyle::default())
1746    }
1747}