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#[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(®ion, drag.direction),
217 col: drag.col.clamp(region.col(), self::last_region_col_saturating(®ion)),
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 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 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, ®ion),
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, ®ion);
495 let Some(anchor) =
496 self::content_position(self::selectable_position(raw_anchor, ®ion, frame_buffer), ®ion)
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 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, ®ion);
833 if WordCell::at(frame_buffer, position.row, position.col, ®ion) == WordCell::No {
834 return None;
835 }
836
837 let start_col = self::word_start_col(frame_buffer, position.row, position.col, ®ion);
838 let end_col = self::word_end_col(frame_buffer, position.row, position.col, ®ion);
839 Some(SelectionRange {
840 anchor: self::content_position(
841 ClientMousePosition {
842 row: position.row,
843 col: start_col,
844 },
845 ®ion,
846 )?,
847 focus: self::content_position(
848 ClientMousePosition {
849 row: position.row,
850 col: end_col,
851 },
852 ®ion,
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 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 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 }, ®ions, &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 }, ®ions, &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}