Skip to main content

muxr_client/
renderer.rs

1use std::collections::BTreeSet;
2use std::time::Instant;
3
4use muxr_config::MuxrConfig;
5use muxr_config::SelectionStyle;
6use muxr_config::TabBarConfig;
7use muxr_core::ClientMouseEvent;
8use muxr_core::ClientMouseEventPhase;
9use muxr_core::ClientMousePosition;
10use muxr_core::ClientRequest;
11use muxr_core::LayoutSnapshot;
12use muxr_core::PaneId;
13use muxr_core::PaneMouseMode;
14use muxr_core::PaneRegionSnapshot;
15use muxr_core::PaneRegionsSnapshot;
16use muxr_core::PaneScrollDirection;
17use muxr_core::PaneScrollLineMove;
18use muxr_core::RenderUpdate;
19use muxr_core::TabId;
20
21use crate::copy_selection::SelectionClickOutcome;
22use crate::copy_selection::SelectionClickTracker;
23use crate::copy_selection::SelectionEdgeScrollPending;
24use crate::copy_selection::SelectionEdgeScrollRequest;
25use crate::copy_selection::SelectionEdgeScrollState;
26use crate::copy_selection::SelectionInput;
27use crate::copy_selection::SelectionRange;
28use crate::copy_selection::SelectionState;
29use crate::frame_buffer::ApplyOutcome;
30use crate::frame_buffer::FrameBuffer;
31use crate::frame_buffer::RenderFrameChanges;
32use crate::frame_buffer::RenderFrameScope;
33use crate::frame_buffer::SelectionHighlight;
34use crate::frame_buffer::TerminalOrigin;
35use crate::frame_buffer::TerminalRender;
36use crate::frame_buffer::TerminalUpdateEncoder;
37use crate::terminal::MouseAnyMotionCapture;
38use crate::terminal::SynchronizedOutput;
39
40const MAX_RETAINED_RENDER_TRANSACTION_BYTES: usize = 64 * 1_024;
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub enum ClientRenderOutcome {
44    Drawn,
45    NeedsResync,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49struct MouseCapture {
50    region: PaneRegionSnapshot,
51}
52
53impl MouseCapture {
54    fn retain_for_regions(self, regions: &PaneRegionsSnapshot) -> Option<Self> {
55        self::region_for_pane_id(regions, *self.region.id())
56            .cloned()
57            .map(|region| Self { region })
58    }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62enum TabBarDmg {
63    Clean,
64    Dirty,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68enum FileOpenCapture {
69    Armed,
70}
71
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum FileOpenRelease {
74    Consumed,
75    NotCaptured,
76}
77
78pub struct ClientRenderer {
79    any_motion_capture: MouseAnyMotionCapture,
80    tab_bar_dmg: TabBarDmg,
81    selection_style: SelectionStyle,
82    tab_bar_config: TabBarConfig,
83    clicks: SelectionClickTracker,
84    frame_buffer: FrameBuffer,
85    layout: LayoutSnapshot,
86    mouse_capture: Option<MouseCapture>,
87    file_open_capture: Option<FileOpenCapture>,
88    pane_regions: PaneRegionsSnapshot,
89    selection_edge_scroll: SelectionEdgeScrollState,
90    selection: SelectionState,
91    synchronized_output: SynchronizedOutput,
92    terminal_encoder: TerminalUpdateEncoder,
93    render_transaction: Vec<u8>,
94    render_generation: u64,
95    edge_scroll_render_generation: Option<u64>,
96}
97
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct ClientPresentationSnapshot {
100    any_motion_capture: MouseAnyMotionCapture,
101    edge_scroll_pending: Option<crate::copy_selection::SelectionEdgeScrollPending>,
102    frame_buffer: FrameBuffer,
103    layout: LayoutSnapshot,
104    edge_scroll_render_generation: Option<u64>,
105    selection: Option<SelectionRange>,
106}
107
108impl ClientRenderer {
109    pub fn new(config: &MuxrConfig, layout: LayoutSnapshot, pane_regions: PaneRegionsSnapshot) -> Self {
110        Self::with_config_and_synchronized_output(
111            config,
112            layout,
113            pane_regions,
114            SynchronizedOutput::for_term(std::env::var("TERM").ok().as_deref()),
115        )
116    }
117
118    #[cfg(test)]
119    pub fn with_synchronized_output(
120        layout: LayoutSnapshot,
121        pane_regions: PaneRegionsSnapshot,
122        synchronized_output: SynchronizedOutput,
123    ) -> Self {
124        let config = MuxrConfig::default();
125        Self::with_config_and_synchronized_output(&config, layout, pane_regions, synchronized_output)
126    }
127
128    fn with_config_and_synchronized_output(
129        config: &MuxrConfig,
130        layout: LayoutSnapshot,
131        pane_regions: PaneRegionsSnapshot,
132        synchronized_output: SynchronizedOutput,
133    ) -> Self {
134        Self {
135            any_motion_capture: MouseAnyMotionCapture::Disabled,
136            tab_bar_dmg: TabBarDmg::Dirty,
137            selection_style: config.selection,
138            tab_bar_config: config.tab_bar,
139            clicks: SelectionClickTracker::default(),
140            frame_buffer: FrameBuffer::default(),
141            layout,
142            mouse_capture: None,
143            file_open_capture: None,
144            pane_regions,
145            selection_edge_scroll: SelectionEdgeScrollState::default(),
146            selection: SelectionState::default(),
147            synchronized_output,
148            terminal_encoder: TerminalUpdateEncoder::default(),
149            render_transaction: Vec::new(),
150            render_generation: 0,
151            edge_scroll_render_generation: None,
152        }
153    }
154
155    pub fn apply_layout(&mut self, layout: LayoutSnapshot) {
156        // Layout events precede their matching render baseline; defer tab bar writes so the user never sees new tab
157        // state over an old pane frame.
158        self.layout = layout;
159        self.tab_bar_dmg = TabBarDmg::Dirty;
160    }
161
162    pub fn apply_sidebar_layout_logical(&mut self, layout: LayoutSnapshot) {
163        self.layout = layout;
164    }
165
166    pub fn tab_id_at_sidebar_row(&self, row: u16) -> Option<TabId> {
167        crate::tab_bar::tab_id_at_row(&self.layout, row)
168    }
169
170    pub(crate) fn tab_focus_request_for_sidebar_click(&self, event: ClientMouseEvent) -> Option<ClientRequest> {
171        if event.phase != ClientMouseEventPhase::Press || event.button != 0 {
172            return None;
173        }
174        self.tab_id_at_sidebar_row(event.position.row)
175            .map(ClientRequest::FocusTab)
176    }
177
178    pub fn file_open_request(&self, position: ClientMousePosition) -> Option<ClientRequest> {
179        let region = self.pane_regions.pane_at(position)?;
180        let tab = self
181            .layout
182            .tabs()
183            .iter()
184            .find(|tab| *tab.id() == *self.layout.active_tab())?;
185        let pane = tab.panes().iter().find(|pane| pane.id == *region.id())?;
186        let link = self.frame_buffer.file_link_at_resolved(region, position, &pane.cwd)?;
187        Some(ClientRequest::OpenFile {
188            pane_id: *region.id(),
189            path: link.path.to_str()?.to_owned(),
190            line: link.line,
191            column: link.column,
192        })
193    }
194
195    pub const fn begin_file_open_capture(&mut self) {
196        self.file_open_capture = Some(FileOpenCapture::Armed);
197    }
198
199    pub const fn finish_file_open_capture(&mut self) -> FileOpenRelease {
200        match self.file_open_capture.take() {
201            Some(FileOpenCapture::Armed) => FileOpenRelease::Consumed,
202            None => FileOpenRelease::NotCaptured,
203        }
204    }
205
206    pub fn apply_pane_regions_logical(&mut self, pane_regions: PaneRegionsSnapshot) {
207        self.pane_regions = pane_regions;
208        self.clicks.retain_for_regions(&self.pane_regions);
209        self.mouse_capture = self
210            .mouse_capture
211            .take()
212            .and_then(|capture| capture.retain_for_regions(&self.pane_regions));
213        self.selection_edge_scroll.retain_for_regions(&self.pane_regions);
214        self.selection.clear_if_regions_changed(&self.pane_regions);
215        self.sync_mouse_capture_logical();
216    }
217
218    pub fn sync_mouse_capture_logical(&mut self) {
219        self.any_motion_capture = self.next_mouse_capture();
220    }
221
222    fn next_mouse_capture(&self) -> MouseAnyMotionCapture {
223        if self
224            .pane_regions
225            .regions()
226            .iter()
227            .any(|region| region.mouse_mode() == PaneMouseMode::AnyMotion)
228        {
229            MouseAnyMotionCapture::Enabled
230        } else {
231            MouseAnyMotionCapture::Disabled
232        }
233    }
234
235    pub fn apply_render_logical(&mut self, update: RenderUpdate) -> rootcause::Result<ClientRenderOutcome> {
236        match self.frame_buffer.apply(update)? {
237            ApplyOutcome::Applied(_) => {
238                self.render_generation = self
239                    .render_generation
240                    .checked_add(1)
241                    .ok_or_else(|| rootcause::report!("muxr client render generation overflowed"))?;
242                if self.selection_edge_scroll.waits_for_render() {
243                    self.edge_scroll_render_generation = Some(self.render_generation);
244                }
245                self.selection.refresh_visible_rows(&self.frame_buffer)?;
246                self.refresh_edge_drag_selection_logical()?;
247                self.tab_bar_dmg = TabBarDmg::Clean;
248                Ok(ClientRenderOutcome::Drawn)
249            }
250            ApplyOutcome::NeedsResync => Ok(ClientRenderOutcome::NeedsResync),
251        }
252    }
253
254    pub fn presentation_snapshot(&self) -> ClientPresentationSnapshot {
255        ClientPresentationSnapshot {
256            any_motion_capture: self.any_motion_capture,
257            edge_scroll_pending: self.selection_edge_scroll.render_pending().cloned(),
258            frame_buffer: self.frame_buffer.clone(),
259            layout: self.layout.clone(),
260            edge_scroll_render_generation: self.edge_scroll_render_generation,
261            selection: self.selection.range().cloned(),
262        }
263    }
264
265    pub fn presentation_transaction(
266        &mut self,
267        committed: Option<&ClientPresentationSnapshot>,
268    ) -> rootcause::Result<Option<Vec<u8>>> {
269        if self.tab_bar_dmg == TabBarDmg::Dirty {
270            return Ok(None);
271        }
272        let logical = self.presentation_snapshot();
273        let mouse_changed = committed.is_none_or(|previous| previous.any_motion_capture != logical.any_motion_capture);
274        let Some(changes) = self.presentation_changes(committed, &logical)? else {
275            if !mouse_changed {
276                return Ok(None);
277            }
278            let mut transaction = Vec::new();
279            crate::terminal::queue_mouse_any_motion_capture(&mut transaction, logical.any_motion_capture)?;
280            return Ok(Some(transaction));
281        };
282        let render_tab_bar = committed.is_none_or(|previous| previous.layout != logical.layout)
283            || changes.scope() == RenderFrameScope::Full;
284        self.render_transaction.clear();
285        let result = (|| {
286            if mouse_changed {
287                crate::terminal::queue_mouse_any_motion_capture(
288                    &mut self.render_transaction,
289                    logical.any_motion_capture,
290                )?;
291            }
292            crate::terminal::queue_synchronized_update_start(&mut self.render_transaction, self.synchronized_output)?;
293            if changes.scope() == RenderFrameScope::Full {
294                crate::frame_buffer::queue_full_redraw_start(&mut self.render_transaction)?;
295            }
296            if render_tab_bar {
297                let rows = self.frame_buffer.size().map_or(0, muxr_core::TerminalSize::rows);
298                crate::tab_bar::queue(&mut self.render_transaction, self.tab_bar_config, &self.layout, rows)?;
299            }
300            let selection = self.selection.range().map(|range| SelectionHighlight {
301                background: self.selection_style.bg,
302                range,
303            });
304            self.terminal_encoder.encode(
305                &mut self.render_transaction,
306                TerminalRender {
307                    changes: &changes,
308                    frame_buffer: &self.frame_buffer,
309                    origin: TerminalOrigin {
310                        col: self.tab_bar_config.width,
311                        row: 0,
312                    },
313                    selection,
314                },
315            )?;
316            crate::terminal::queue_synchronized_update_end(&mut self.render_transaction, self.synchronized_output)
317        })();
318        let transaction = result.map(|()| self.render_transaction.clone());
319        self.reset_render_transaction();
320        transaction.map(Some)
321    }
322
323    pub fn acknowledge_presentation(&mut self, snapshot: &ClientPresentationSnapshot) {
324        if snapshot.edge_scroll_render_generation == self.edge_scroll_render_generation
325            && snapshot.edge_scroll_render_generation.is_some()
326            && let Some(pending) = snapshot.edge_scroll_pending.as_ref()
327        {
328            self.selection_edge_scroll.clear_render_acknowledged_pending(pending);
329            self.edge_scroll_render_generation = None;
330        }
331    }
332
333    fn reset_render_transaction(&mut self) {
334        self.render_transaction.clear();
335        if self.render_transaction.capacity() > MAX_RETAINED_RENDER_TRANSACTION_BYTES {
336            self.render_transaction = Vec::new();
337        }
338    }
339
340    pub fn apply_selection_input_logical(&mut self, input: SelectionInput) -> rootcause::Result<()> {
341        self.apply_selection_input_at_logical(input, Instant::now())
342    }
343
344    pub(crate) fn clear_selection(&mut self) {
345        self.clicks.reset();
346        self.selection.clear();
347        self.selection_edge_scroll.clear();
348        self.edge_scroll_render_generation = None;
349    }
350
351    pub(crate) fn apply_selection_input_at_logical(
352        &mut self,
353        input: SelectionInput,
354        now: Instant,
355    ) -> rootcause::Result<()> {
356        if matches!(input, SelectionInput::Start(_) | SelectionInput::End(_)) {
357            self.selection_edge_scroll.clear();
358            self.edge_scroll_render_generation = None;
359        }
360        match input {
361            SelectionInput::Start(position)
362                if self
363                    .clicks
364                    .record_selection_start(position, &self.pane_regions, &self.frame_buffer, now)
365                    == SelectionClickOutcome::Double =>
366            {
367                self.selection
368                    .select_word(position, &self.pane_regions, &self.frame_buffer)?
369            }
370            SelectionInput::Start(position) => {
371                self.selection
372                    .apply(SelectionInput::Start(position), &self.pane_regions, &self.frame_buffer)?
373            }
374            SelectionInput::Update(position) => {
375                self.selection
376                    .apply(SelectionInput::Update(position), &self.pane_regions, &self.frame_buffer)?
377            }
378            SelectionInput::End(position) => {
379                self.selection
380                    .apply(SelectionInput::End(position), &self.pane_regions, &self.frame_buffer)?
381            }
382        };
383        Ok(())
384    }
385
386    pub fn mouse_request_for_event(&mut self, event: ClientMouseEvent) -> Option<ClientMouseEvent> {
387        if crate::pane::scroll::MouseWheelEvent::from(event) == crate::pane::scroll::MouseWheelEvent::Wheel {
388            return None;
389        }
390
391        if let Some(capture) = self.mouse_capture.as_ref() {
392            let event = ClientMouseEvent {
393                position: self::clamp_mouse_position_to_region(event.position, &capture.region),
394                ..event
395            };
396            if event.phase == ClientMouseEventPhase::Release {
397                self.mouse_capture = None;
398            }
399            return Some(event);
400        }
401
402        let region = self.pane_regions.pane_at(event.position)?;
403        if region.mouse_mode() == PaneMouseMode::None {
404            return None;
405        }
406        if MouseCaptureStart::from(event) == MouseCaptureStart::Start {
407            self.mouse_capture = Some(MouseCapture { region: region.clone() });
408        }
409        Some(event)
410    }
411
412    pub fn copy_selection(&self) -> rootcause::Result<()> {
413        let Some(text) = self.selection.selected_text() else {
414            return Ok(());
415        };
416        crate::copy_selection::copy_to_clipboard(&text)
417    }
418
419    pub fn copy_selection_inline(&self) -> rootcause::Result<()> {
420        let Some(text) = self.selection.selected_inline_text() else {
421            return Ok(());
422        };
423        crate::copy_selection::copy_to_clipboard(&text)
424    }
425
426    pub fn set_selection_edge_drag(
427        &mut self,
428        position: ClientMousePosition,
429        forced_direction: Option<PaneScrollDirection>,
430    ) -> Option<SelectionEdgeScrollRequest> {
431        let drag_region = self.selection.drag_region().cloned();
432        self.selection_edge_scroll
433            .set_edge_drag(position, forced_direction, drag_region.as_ref())
434    }
435
436    pub fn set_selection_outside_edge_drag(
437        &mut self,
438        position: ClientMousePosition,
439    ) -> Option<SelectionEdgeScrollRequest> {
440        let drag_region = self.selection.drag_region().cloned();
441        self.selection_edge_scroll
442            .set_outside_edge_drag(position, drag_region.as_ref())
443    }
444
445    fn refresh_edge_drag_selection_logical(&mut self) -> rootcause::Result<()> {
446        let Some(position) = self.selection_edge_scroll.drag_position(&self.pane_regions) else {
447            self.selection_edge_scroll.clear();
448            self.edge_scroll_render_generation = None;
449            return Ok(());
450        };
451        self.apply_selection_input_logical(SelectionInput::Update(position))
452    }
453
454    pub fn selection_edge_scroll_request(&self) -> Option<SelectionEdgeScrollRequest> {
455        self.selection_edge_scroll.scroll_request(&self.pane_regions)
456    }
457
458    pub fn apply_scroll_pane_line_result(
459        &mut self,
460        position: ClientMousePosition,
461        direction: PaneScrollDirection,
462        movement: PaneScrollLineMove,
463    ) {
464        self.selection_edge_scroll
465            .apply_scroll_pane_line_result(position, direction, movement);
466    }
467
468    fn presentation_changes(
469        &self,
470        committed: Option<&ClientPresentationSnapshot>,
471        logical: &ClientPresentationSnapshot,
472    ) -> rootcause::Result<Option<RenderFrameChanges>> {
473        let Some(committed) = committed else {
474            return Ok(self.frame_buffer.full_redraw_changes());
475        };
476        let Some(mut rows) = self.frame_buffer.changed_rows_since(&committed.frame_buffer) else {
477            return Ok(self.frame_buffer.full_redraw_changes());
478        };
479        if committed.selection != logical.selection {
480            rows.extend(crate::copy_selection::changed_selection_rows(
481                committed.selection.as_ref(),
482                logical.selection.as_ref(),
483            ));
484        }
485        let rows = rows
486            .into_iter()
487            .collect::<BTreeSet<_>>()
488            .into_iter()
489            .collect::<Vec<_>>();
490        if rows.is_empty() {
491            return if committed.layout == logical.layout {
492                if self.frame_buffer.cursor_matches(&committed.frame_buffer) {
493                    Ok(None)
494                } else {
495                    self.frame_buffer.row_redraw_changes(&[])
496                }
497            } else {
498                self.frame_buffer.row_redraw_changes(&[])
499            };
500        }
501        self.frame_buffer.row_redraw_changes(&rows)
502    }
503
504    pub const fn mouse_capture_state(&self) -> MouseCaptureState {
505        match self.mouse_capture {
506            Some(_) => MouseCaptureState::Captured,
507            None => MouseCaptureState::None,
508        }
509    }
510
511    pub const fn selection_edge_drag(&self) -> SelectionEdgeDrag {
512        match self.selection_edge_scroll.active_state() {
513            crate::copy_selection::SelectionEdgeScrollActive::Active => SelectionEdgeDrag::Active,
514            crate::copy_selection::SelectionEdgeScrollActive::Inactive => SelectionEdgeDrag::Inactive,
515        }
516    }
517
518    pub const fn mark_selection_edge_scroll_sent(&mut self, pending: SelectionEdgeScrollPending) {
519        self.selection_edge_scroll.mark_sent(pending);
520    }
521}
522
523fn region_for_pane_id(regions: &PaneRegionsSnapshot, pane_id: PaneId) -> Option<&PaneRegionSnapshot> {
524    regions.regions().iter().find(|region| *region.id() == pane_id)
525}
526
527fn clamp_mouse_position_to_region(position: ClientMousePosition, region: &PaneRegionSnapshot) -> ClientMousePosition {
528    ClientMousePosition {
529        row: position
530            .row
531            .clamp(region.row(), self::last_region_row_saturating(region)),
532        col: position
533            .col
534            .clamp(region.col(), self::last_region_col_saturating(region)),
535    }
536}
537
538const fn last_region_col_saturating(region: &PaneRegionSnapshot) -> u16 {
539    region.col().saturating_add(region.cols().saturating_sub(1))
540}
541
542const fn last_region_row_saturating(region: &PaneRegionSnapshot) -> u16 {
543    region.row().saturating_add(region.rows().saturating_sub(1))
544}
545
546#[derive(Clone, Copy, Debug, Eq, PartialEq)]
547pub enum MouseCaptureState {
548    Captured,
549    None,
550}
551
552#[derive(Clone, Copy, Debug, Eq, PartialEq)]
553pub enum SelectionEdgeDrag {
554    Active,
555    Inactive,
556}
557
558#[derive(Clone, Copy, Debug, Eq, PartialEq)]
559enum MouseCaptureStart {
560    Ignore,
561    Start,
562}
563
564impl From<ClientMouseEvent> for MouseCaptureStart {
565    fn from(event: ClientMouseEvent) -> Self {
566        if event.phase == ClientMouseEventPhase::Press && event.button & (32 | 64) == 0 && event.button & 0b11 != 0b11 {
567            Self::Start
568        } else {
569            Self::Ignore
570        }
571    }
572}
573
574#[cfg(test)]
575pub mod test_helpers {
576    use super::*;
577
578    pub fn selected_text(renderer: &ClientRenderer) -> Option<String> {
579        renderer.selection.selected_text()
580    }
581
582    pub fn selection_contains(renderer: &ClientRenderer, row: u16, col: u16) -> bool {
583        renderer
584            .selection
585            .range()
586            .is_some_and(|selection| selection.contains(row, col))
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use std::time::Duration;
593    use std::time::Instant;
594
595    use muxr_core::ClientRequest;
596    use muxr_core::PaneSnapshot;
597    use muxr_core::RenderBaseline;
598    use muxr_core::TabId;
599    use muxr_core::TabSnapshot;
600    use muxr_core::TerminalSize;
601    use rootcause::report;
602    use test_that::prelude::*;
603
604    use super::*;
605    use crate::renderer::test_helpers;
606
607    #[test]
608    fn test_file_open_request_when_bare_file_is_clicked_resolves_against_pane_cwd() -> rootcause::Result<()> {
609        let directory = tempfile::tempdir()?;
610        let path = directory.path().join("README");
611        std::fs::write(&path, b"content")?;
612        let cwd = directory
613            .path()
614            .to_str()
615            .ok_or_else(|| report!("temporary test directory is not UTF-8"))?;
616
617        let request = self::file_open_request_for_text(cwd, "README")?;
618
619        assert_that!(
620            request,
621            some(eq(ClientRequest::OpenFile {
622                pane_id: PaneId::new(1)?,
623                path: path
624                    .to_str()
625                    .ok_or_else(|| report!("temporary test file path is not UTF-8"))?
626                    .to_owned(),
627                line: None,
628                column: None,
629            }))
630        );
631        Ok(())
632    }
633
634    #[test]
635    fn test_file_open_request_when_bare_directory_is_clicked_resolves_against_pane_cwd() -> rootcause::Result<()> {
636        let directory = tempfile::tempdir()?;
637        let path = directory.path().join("src");
638        std::fs::create_dir(&path)?;
639        let cwd = directory
640            .path()
641            .to_str()
642            .ok_or_else(|| report!("temporary test directory is not UTF-8"))?;
643
644        let request = self::file_open_request_for_text(cwd, "src")?;
645
646        assert_that!(
647            request,
648            some(eq(ClientRequest::OpenFile {
649                pane_id: PaneId::new(1)?,
650                path: path
651                    .to_str()
652                    .ok_or_else(|| report!("temporary directory path is not UTF-8"))?
653                    .to_owned(),
654                line: None,
655                column: None,
656            }))
657        );
658        Ok(())
659    }
660
661    fn file_open_request_for_text(cwd: &str, text: &str) -> rootcause::Result<Option<ClientRequest>> {
662        let width = u16::try_from(text.chars().count())?;
663        let layout = LayoutSnapshot::new(
664            TabId::new(1)?,
665            vec![TabSnapshot::new(
666                TabId::new(1)?,
667                "default",
668                PaneId::new(1)?,
669                vec![PaneSnapshot {
670                    tracked_process_state: muxr_core::TrackedProcessState::None,
671                    cwd: cwd.to_owned(),
672                    cmd_label: None,
673                    focus_seq: 1,
674                    id: PaneId::new(1)?,
675                    title: "shell".to_owned(),
676                }],
677            )?],
678        )?;
679        let pane_regions = PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
680            PaneId::new(1)?,
681            0,
682            0,
683            width,
684            1,
685            PaneMouseMode::None,
686            0,
687        )?])?;
688        let mut renderer = ClientRenderer::with_synchronized_output(layout, pane_regions, SynchronizedOutput::Csi);
689        let baseline = RenderBaseline::new(
690            1,
691            muxr_core::TerminalSize::new(width, 1)?,
692            muxr_core::RenderCursor {
693                row: 0,
694                col: 0,
695                shape: muxr_core::RenderCursorShape::Default,
696                visibility: muxr_core::RenderCursorVisibility::Visible,
697            },
698            vec![muxr_core::RenderRowSpan::new(
699                0,
700                0,
701                text.chars()
702                    .map(|ch| muxr_core::RenderCell::narrow(ch.to_string(), muxr_core::RenderStyle::default()))
703                    .collect(),
704            )?],
705        )?;
706        renderer.apply_render_logical(RenderUpdate::Baseline(baseline))?;
707        Ok(renderer.file_open_request(ClientMousePosition { row: 0, col: 1 }))
708    }
709
710    #[test]
711    fn test_presentation_transaction_when_layout_is_dirty_encodes_one_complete_frame() -> rootcause::Result<()> {
712        let mut renderer = ClientRenderer::with_synchronized_output(
713            layout_snapshot()?,
714            pane_regions_snapshot()?,
715            SynchronizedOutput::Csi,
716        );
717        renderer.apply_layout(two_tab_layout()?);
718        let outcome = renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
719        let transaction = renderer
720            .presentation_transaction(None)?
721            .ok_or_else(|| report!("expected presentation transaction"))?;
722
723        assert_that!(outcome, eq(ClientRenderOutcome::Drawn));
724        let terminal_output = String::from_utf8(transaction)?;
725        assert_that!(terminal_output, contains_substring("\x1b[?2026h"));
726        assert_that!(terminal_output, ends_with("\x1b[?2026l"));
727        let tab_bar_index = terminal_output.find("tab-1").unwrap_or(usize::MAX);
728        let pane_index = terminal_output.find("ab").unwrap_or(usize::MAX);
729        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
730        assert_that!(tab_bar_index, lt(pane_index));
731        Ok(())
732    }
733
734    #[test]
735    fn test_client_renderer_when_render_transaction_capacity_is_outlier_discards_it() -> rootcause::Result<()> {
736        let mut renderer = ClientRenderer::with_synchronized_output(
737            layout_snapshot()?,
738            pane_regions_snapshot()?,
739            SynchronizedOutput::Csi,
740        );
741        renderer
742            .render_transaction
743            .reserve(MAX_RETAINED_RENDER_TRANSACTION_BYTES + 1);
744
745        renderer.reset_render_transaction();
746
747        assert_that!(
748            renderer.render_transaction.capacity(),
749            le(MAX_RETAINED_RENDER_TRANSACTION_BYTES)
750        );
751        Ok(())
752    }
753
754    #[test]
755    fn test_client_renderer_apply_render_logical_when_resync_is_needed_returns_needs_resync() -> rootcause::Result<()> {
756        let mut renderer = ClientRenderer::with_synchronized_output(
757            layout_snapshot()?,
758            pane_regions_snapshot()?,
759            SynchronizedOutput::Csi,
760        );
761        let outcome = renderer.apply_render_logical(muxr_core::RenderUpdate::Diff(render_diff()?))?;
762
763        assert_that!(outcome, eq(ClientRenderOutcome::NeedsResync));
764        Ok(())
765    }
766
767    #[test]
768    fn test_presentation_transaction_when_pane_regions_need_mouse_capture_defers_until_first_render()
769    -> rootcause::Result<()> {
770        let mut renderer = ClientRenderer::with_synchronized_output(
771            layout_snapshot()?,
772            pane_regions_snapshot()?,
773            SynchronizedOutput::Csi,
774        );
775        renderer.apply_pane_regions_logical(any_motion_pane_regions_snapshot()?);
776        assert_that!(renderer.presentation_transaction(None)?, eq(None));
777        Ok(())
778    }
779
780    #[test]
781    fn test_client_renderer_apply_pane_regions_when_any_motion_is_no_longer_needed_reasserts_outer_capture()
782    -> rootcause::Result<()> {
783        let mut renderer = ClientRenderer::with_synchronized_output(
784            layout_snapshot()?,
785            any_motion_pane_regions_snapshot()?,
786            SynchronizedOutput::Csi,
787        );
788        renderer.sync_mouse_capture_logical();
789        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
790        let committed = renderer.presentation_snapshot();
791        renderer.apply_pane_regions_logical(pane_regions_snapshot()?);
792        let transaction = renderer
793            .presentation_transaction(Some(&committed))?
794            .ok_or_else(|| report!("expected mouse capture transaction"))?;
795
796        assert_that!(
797            String::from_utf8(transaction)?,
798            eq("\x1b[?1003l\x1b[?1000h\x1b[?1002h\x1b[?1006h")
799        );
800        Ok(())
801    }
802
803    #[test]
804    fn test_presentation_transaction_when_initial_frame_reasserts_current_mouse_capture_before_frame()
805    -> rootcause::Result<()> {
806        let mut renderer = ClientRenderer::with_synchronized_output(
807            layout_snapshot()?,
808            pane_regions_snapshot()?,
809            SynchronizedOutput::Csi,
810        );
811        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
812        renderer.apply_pane_regions_logical(any_motion_pane_regions_snapshot()?);
813        let transaction = renderer
814            .presentation_transaction(None)?
815            .ok_or_else(|| report!("expected a full redraw transaction for the current frame"))?;
816        let terminal_output = String::from_utf8(transaction)?;
817
818        assert_that!(terminal_output, starts_with("\x1b[?1003h\x1b[?2026h"));
819        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
820        Ok(())
821    }
822
823    #[test]
824    fn test_presentation_transaction_when_initial_frame_arrives_encodes_full_repaint_without_clear()
825    -> rootcause::Result<()> {
826        let mut renderer = ClientRenderer::with_synchronized_output(
827            layout_snapshot()?,
828            pane_regions_snapshot()?,
829            SynchronizedOutput::Csi,
830        );
831
832        assert_that!(
833            renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?,
834            eq(ClientRenderOutcome::Drawn)
835        );
836        let transaction = renderer
837            .presentation_transaction(None)?
838            .ok_or_else(|| report!("expected initial presentation transaction"))?;
839        let terminal_output = String::from_utf8(transaction)?;
840
841        assert_that!(terminal_output, contains_substring("\x1b[?2026h"));
842        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
843        Ok(())
844    }
845
846    #[test]
847    fn test_presentation_transaction_when_selection_changes_encodes_delta_from_committed_snapshot()
848    -> rootcause::Result<()> {
849        let mut renderer = ClientRenderer::with_synchronized_output(
850            layout_snapshot()?,
851            pane_regions_snapshot()?,
852            SynchronizedOutput::Csi,
853        );
854        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
855        let committed = renderer.presentation_snapshot();
856        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
857        renderer.apply_selection_input_logical(SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }))?;
858
859        let transaction = renderer
860            .presentation_transaction(Some(&committed))?
861            .ok_or_else(|| report!("expected selection presentation transaction"))?;
862        let terminal_output = String::from_utf8(transaction)?;
863
864        assert_that!(terminal_output, starts_with("\x1b[?2026h"));
865        assert_that!(terminal_output, not(contains_substring("tab-0")));
866        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
867        Ok(())
868    }
869
870    #[test]
871    fn test_client_renderer_clear_selection_when_selection_exists_removes_selection() -> rootcause::Result<()> {
872        let mut renderer = ClientRenderer::with_synchronized_output(
873            layout_snapshot()?,
874            pane_regions_snapshot()?,
875            SynchronizedOutput::Csi,
876        );
877        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
878        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
879        renderer.apply_selection_input_logical(SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }))?;
880
881        renderer.clear_selection();
882
883        assert_that!(test_helpers::selection_contains(&renderer, 0, 0), eq(false));
884        assert_that!(test_helpers::selected_text(&renderer), eq(None));
885        Ok(())
886    }
887
888    #[test]
889    fn test_client_renderer_when_region_diff_is_pixel_identical_skips_terminal_transaction() -> rootcause::Result<()> {
890        let mut renderer = ClientRenderer::with_synchronized_output(
891            layout_snapshot()?,
892            pane_regions_snapshot()?,
893            SynchronizedOutput::Csi,
894        );
895        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
896        let committed = renderer.presentation_snapshot();
897
898        renderer.apply_render_logical(muxr_core::RenderUpdate::Diff(muxr_core::RenderDiff::new(
899            1,
900            2,
901            TerminalSize::new(2, 1)?,
902            muxr_core::RenderCursor {
903                row: 0,
904                col: 1,
905                shape: muxr_core::RenderCursorShape::Default,
906                visibility: muxr_core::RenderCursorVisibility::Visible,
907            },
908            Vec::new(),
909        )?))?;
910
911        assert_that!(renderer.presentation_transaction(Some(&committed))?, eq(None));
912        Ok(())
913    }
914
915    #[test]
916    fn test_client_renderer_when_sidebar_layout_changes_encodes_sidebar_only_transaction() -> rootcause::Result<()> {
917        let mut renderer = ClientRenderer::with_synchronized_output(
918            layout_snapshot()?,
919            pane_regions_snapshot()?,
920            SynchronizedOutput::Csi,
921        );
922        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
923        let committed = renderer.presentation_snapshot();
924
925        renderer.apply_sidebar_layout_logical(two_tab_layout()?);
926
927        let transaction = renderer
928            .presentation_transaction(Some(&committed))?
929            .ok_or_else(|| report!("expected sidebar-only presentation transaction"))?;
930        let terminal_output = String::from_utf8(transaction)?;
931
932        assert_that!(terminal_output, contains_substring("tab-1"));
933        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
934        Ok(())
935    }
936
937    #[test]
938    fn test_client_renderer_when_sidebar_follows_layout_defers_presentation_until_render() -> rootcause::Result<()> {
939        let mut renderer = ClientRenderer::with_synchronized_output(
940            layout_snapshot()?,
941            pane_regions_snapshot()?,
942            SynchronizedOutput::Csi,
943        );
944        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
945        let committed = renderer.presentation_snapshot();
946
947        let layout = two_tab_layout()?;
948        renderer.apply_layout(layout.clone());
949        renderer.apply_sidebar_layout_logical(layout);
950
951        assert_that!(renderer.presentation_transaction(Some(&committed))?, eq(None));
952        Ok(())
953    }
954
955    #[test]
956    fn test_client_renderer_mouse_request_for_event_when_wheel_in_tracking_pane_returns_none() -> rootcause::Result<()>
957    {
958        let mut renderer = ClientRenderer::with_synchronized_output(
959            layout_snapshot()?,
960            any_motion_pane_regions_snapshot()?,
961            SynchronizedOutput::Csi,
962        );
963
964        assert_that!(
965            renderer.mouse_request_for_event(ClientMouseEvent {
966                button: 64,
967                phase: ClientMouseEventPhase::Press,
968                position: ClientMousePosition { row: 0, col: 0 },
969            }),
970            eq(None)
971        );
972        Ok(())
973    }
974
975    #[test]
976    fn test_client_renderer_apply_selection_input_when_frame_exists_redraws_highlighted_selection()
977    -> rootcause::Result<()> {
978        let mut renderer = ClientRenderer::with_synchronized_output(
979            layout_snapshot()?,
980            pane_regions_snapshot()?,
981            SynchronizedOutput::Csi,
982        );
983        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
984        let committed = renderer.presentation_snapshot();
985        renderer
986            .apply_selection_input_logical(SelectionInput::Start(muxr_core::ClientMousePosition { row: 0, col: 0 }))?;
987        renderer.apply_selection_input_logical(SelectionInput::Update(muxr_core::ClientMousePosition {
988            row: 0,
989            col: 1,
990        }))?;
991
992        assert_that!(test_helpers::selection_contains(&renderer, 0, 0), eq(true));
993        assert_that!(test_helpers::selection_contains(&renderer, 0, 1), eq(true));
994        let selection_output = String::from_utf8(
995            renderer
996                .presentation_transaction(Some(&committed))?
997                .ok_or_else(|| report!("expected selection transaction"))?,
998        )?;
999        assert_that!(selection_output, not(contains_substring("\x1b[7m")));
1000        Ok(())
1001    }
1002
1003    #[test]
1004    fn test_client_renderer_apply_pane_regions_when_selection_viewport_changes_redraws_selection_rows()
1005    -> rootcause::Result<()> {
1006        let mut renderer = ClientRenderer::with_synchronized_output(
1007            layout_snapshot()?,
1008            pane_regions_snapshot()?,
1009            SynchronizedOutput::Csi,
1010        );
1011        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
1012        renderer
1013            .apply_selection_input_logical(SelectionInput::Start(muxr_core::ClientMousePosition { row: 0, col: 0 }))?;
1014        renderer
1015            .apply_selection_input_logical(SelectionInput::End(muxr_core::ClientMousePosition { row: 0, col: 1 }))?;
1016        let committed = renderer.presentation_snapshot();
1017        renderer.apply_pane_regions_logical(pane_regions_snapshot_with_visible_top_row(1)?);
1018
1019        let redrawn = String::from_utf8(
1020            renderer
1021                .presentation_transaction(Some(&committed))?
1022                .ok_or_else(|| report!("expected selection viewport transaction"))?,
1023        )?;
1024        assert_that!(redrawn, contains_substring("ab"));
1025        assert_that!(redrawn, not(contains_substring("\x1b[7m")));
1026        Ok(())
1027    }
1028
1029    #[test]
1030    fn test_client_renderer_apply_render_when_edge_drag_scrolls_extends_selection_after_viewport_moves()
1031    -> rootcause::Result<()> {
1032        let mut renderer = ClientRenderer::with_synchronized_output(
1033            layout_snapshot()?,
1034            three_row_pane_regions_snapshot(9)?,
1035            SynchronizedOutput::Csi,
1036        );
1037        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1038            "aa", "bb", "cc",
1039        )?))?;
1040        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1041        let scroll_request = renderer
1042            .set_selection_edge_drag(ClientMousePosition { row: 3, col: 1 }, None)
1043            .map(|request| request.into_parts().1);
1044        renderer.apply_selection_input_logical(SelectionInput::Update(ClientMousePosition { row: 3, col: 1 }))?;
1045
1046        assert_that!(
1047            scroll_request,
1048            eq(Some(ClientRequest::ScrollPaneLineAt {
1049                direction: PaneScrollDirection::Down,
1050                position: ClientMousePosition { row: 2, col: 1 },
1051            }))
1052        );
1053
1054        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1055        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1056            "bb", "cc", "dd",
1057        )?))?;
1058
1059        assert_that!(
1060            test_helpers::selected_text(&renderer),
1061            eq(Some("aa\nbb\ncc\ndd".to_owned()))
1062        );
1063        assert_that!(test_helpers::selection_contains(&renderer, 2, 0), eq(true));
1064        Ok(())
1065    }
1066
1067    #[test]
1068    fn test_client_renderer_acknowledge_presentation_when_edge_scroll_render_flushes_releases_next_request()
1069    -> rootcause::Result<()> {
1070        let mut renderer = ClientRenderer::with_synchronized_output(
1071            layout_snapshot()?,
1072            three_row_pane_regions_snapshot(9)?,
1073            SynchronizedOutput::Csi,
1074        );
1075        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1076            "aa", "bb", "cc",
1077        )?))?;
1078        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1079        let request = renderer
1080            .set_selection_edge_drag(ClientMousePosition { row: 3, col: 1 }, None)
1081            .ok_or_else(|| report!("expected edge-scroll request"))?;
1082        let (pending, _) = request.into_parts();
1083        renderer.mark_selection_edge_scroll_sent(pending);
1084        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1085
1086        let before_render = renderer.presentation_snapshot();
1087        renderer.acknowledge_presentation(&before_render);
1088        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1089
1090        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1091            "bb", "cc", "dd",
1092        )?))?;
1093        let flushed_snapshot = renderer.presentation_snapshot();
1094        renderer.acknowledge_presentation(&flushed_snapshot);
1095
1096        assert_that!(renderer.selection_edge_scroll_request().is_some(), eq(true));
1097        Ok(())
1098    }
1099
1100    #[test]
1101    fn test_client_renderer_when_old_edge_scroll_flushes_keeps_new_pending_request() -> rootcause::Result<()> {
1102        let mut renderer = ClientRenderer::with_synchronized_output(
1103            layout_snapshot()?,
1104            three_row_pane_regions_snapshot(9)?,
1105            SynchronizedOutput::Csi,
1106        );
1107        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1108            "aa", "bb", "cc",
1109        )?))?;
1110        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1111
1112        let first_request = renderer
1113            .set_selection_edge_drag(ClientMousePosition { row: 2, col: 1 }, None)
1114            .ok_or_else(|| report!("expected first edge-scroll request"))?;
1115        let (first_pending, _) = first_request.into_parts();
1116        renderer.mark_selection_edge_scroll_sent(first_pending);
1117        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1118        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1119            "bb", "cc", "dd",
1120        )?))?;
1121        let first_snapshot = renderer.presentation_snapshot();
1122
1123        let second_request = renderer
1124            .set_selection_edge_drag(ClientMousePosition { row: 0, col: 1 }, None)
1125            .ok_or_else(|| report!("expected second edge-scroll request"))?;
1126        let (second_pending, _) = second_request.into_parts();
1127        renderer.mark_selection_edge_scroll_sent(second_pending);
1128        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(9)?);
1129
1130        renderer.acknowledge_presentation(&first_snapshot);
1131        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1132
1133        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1134            "aa", "bb", "cc",
1135        )?))?;
1136        let second_snapshot = renderer.presentation_snapshot();
1137        renderer.acknowledge_presentation(&second_snapshot);
1138
1139        assert_that!(renderer.selection_edge_scroll_request().is_some(), eq(true));
1140        Ok(())
1141    }
1142
1143    #[rstest::rstest]
1144    #[case::top_edge(ClientMousePosition { row: 0, col: 1 }, PaneScrollDirection::Up)]
1145    #[case::bottom_edge(ClientMousePosition { row: 2, col: 1 }, PaneScrollDirection::Down)]
1146    fn test_client_renderer_set_selection_edge_drag_when_pointer_is_on_edge_row_requests_scroll(
1147        #[case] position: ClientMousePosition,
1148        #[case] direction: PaneScrollDirection,
1149    ) -> rootcause::Result<()> {
1150        let mut renderer = ClientRenderer::with_synchronized_output(
1151            layout_snapshot()?,
1152            three_row_pane_regions_snapshot(9)?,
1153            SynchronizedOutput::Csi,
1154        );
1155        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1156            "aa", "bb", "cc",
1157        )?))?;
1158        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1159
1160        let request = renderer
1161            .set_selection_edge_drag(position, None)
1162            .map(|request| request.into_parts().1);
1163
1164        assert_that!(
1165            request,
1166            eq(Some(ClientRequest::ScrollPaneLineAt { position, direction }))
1167        );
1168        Ok(())
1169    }
1170
1171    #[test]
1172    fn test_client_renderer_apply_scroll_pane_line_result_when_scroll_is_noop_clears_pending_request()
1173    -> rootcause::Result<()> {
1174        let mut renderer = ClientRenderer::with_synchronized_output(
1175            layout_snapshot()?,
1176            three_row_pane_regions_snapshot(9)?,
1177            SynchronizedOutput::Csi,
1178        );
1179        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1180            "aa", "bb", "cc",
1181        )?))?;
1182        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1183        let position = ClientMousePosition { row: 2, col: 1 };
1184        let direction = PaneScrollDirection::Down;
1185        let request = renderer
1186            .set_selection_edge_drag(position, None)
1187            .ok_or_else(|| report!("expected muxr edge scroll request"))?;
1188        let (pending, _) = request.into_parts();
1189
1190        renderer.mark_selection_edge_scroll_sent(pending);
1191        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1192        renderer.apply_scroll_pane_line_result(position, direction, PaneScrollLineMove::Unchanged);
1193
1194        let retry = renderer
1195            .selection_edge_scroll_request()
1196            .map(|request| request.into_parts().1);
1197        assert_that!(retry, eq(Some(ClientRequest::ScrollPaneLineAt { position, direction })));
1198        Ok(())
1199    }
1200
1201    #[test]
1202    fn test_client_renderer_apply_scroll_pane_line_result_when_scroll_moves_waits_for_render_ack()
1203    -> rootcause::Result<()> {
1204        let mut renderer = ClientRenderer::with_synchronized_output(
1205            layout_snapshot()?,
1206            three_row_pane_regions_snapshot(9)?,
1207            SynchronizedOutput::Csi,
1208        );
1209        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1210            "aa", "bb", "cc",
1211        )?))?;
1212        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1213        let position = ClientMousePosition { row: 2, col: 1 };
1214        let direction = PaneScrollDirection::Down;
1215        let request = renderer
1216            .set_selection_edge_drag(position, None)
1217            .ok_or_else(|| report!("expected muxr edge scroll request"))?;
1218        let (pending, _) = request.into_parts();
1219
1220        renderer.mark_selection_edge_scroll_sent(pending);
1221        renderer.apply_scroll_pane_line_result(position, direction, PaneScrollLineMove::Moved);
1222
1223        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1224        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1225        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1226            "bb", "cc", "dd",
1227        )?))?;
1228        let rendered_snapshot = renderer.presentation_snapshot();
1229        renderer.acknowledge_presentation(&rendered_snapshot);
1230        let retry = renderer
1231            .selection_edge_scroll_request()
1232            .map(|request| request.into_parts().1);
1233        assert_that!(retry, eq(Some(ClientRequest::ScrollPaneLineAt { position, direction })));
1234        Ok(())
1235    }
1236
1237    #[rstest::rstest]
1238    #[case::same_cell(4, 4)]
1239    #[case::same_word_different_cell(4, 6)]
1240    fn test_client_renderer_apply_selection_input_when_double_click_selects_visible_word(
1241        #[case] first_col: u16,
1242        #[case] second_col: u16,
1243    ) -> rootcause::Result<()> {
1244        let mut renderer = ClientRenderer::with_synchronized_output(
1245            layout_snapshot()?,
1246            word_pane_regions_snapshot()?,
1247            SynchronizedOutput::Csi,
1248        );
1249        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(word_render_baseline()?))?;
1250        let now = Instant::now();
1251        let first_position = ClientMousePosition { row: 0, col: first_col };
1252        let second_position = ClientMousePosition {
1253            row: 0,
1254            col: second_col,
1255        };
1256        let second_click_at = now
1257            .checked_add(Duration::from_millis(100))
1258            .ok_or_else(|| report!("muxr double-click selection test instant overflowed"))?;
1259        renderer.apply_selection_input_at_logical(SelectionInput::Start(first_position), now)?;
1260        renderer.apply_selection_input_at_logical(SelectionInput::End(first_position), now)?;
1261        renderer.apply_selection_input_at_logical(SelectionInput::Start(second_position), second_click_at)?;
1262
1263        assert_that!(test_helpers::selected_text(&renderer), eq(Some("two".to_owned())));
1264        Ok(())
1265    }
1266
1267    #[test]
1268    fn test_client_renderer_apply_pane_regions_when_same_pane_remains_keeps_double_click() -> rootcause::Result<()> {
1269        let mut renderer = ClientRenderer::with_synchronized_output(
1270            layout_snapshot()?,
1271            word_pane_regions_snapshot()?,
1272            SynchronizedOutput::Csi,
1273        );
1274        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(word_render_baseline()?))?;
1275        let now = Instant::now();
1276        let position = ClientMousePosition { row: 0, col: 4 };
1277        let second_click_at = now
1278            .checked_add(Duration::from_millis(100))
1279            .ok_or_else(|| report!("muxr retained double-click selection test instant overflowed"))?;
1280        renderer.apply_selection_input_at_logical(SelectionInput::Start(position), now)?;
1281        renderer.apply_selection_input_at_logical(SelectionInput::End(position), now)?;
1282        renderer.apply_pane_regions_logical(word_pane_regions_snapshot()?);
1283        renderer.apply_selection_input_at_logical(SelectionInput::Start(position), second_click_at)?;
1284
1285        assert_that!(test_helpers::selected_text(&renderer), eq(Some("two".to_owned())));
1286        Ok(())
1287    }
1288
1289    fn layout_snapshot() -> rootcause::Result<LayoutSnapshot> {
1290        let active_tab = TabId::new(1)?;
1291        let active_pane = PaneId::new(1)?;
1292        let pane = PaneSnapshot {
1293            tracked_process_state: muxr_core::TrackedProcessState::None,
1294            cwd: "/tmp/default".to_owned(),
1295            cmd_label: None,
1296            focus_seq: 1,
1297            id: active_pane,
1298            title: "shell".to_owned(),
1299        };
1300        let tab = TabSnapshot::new(active_tab, "default", active_pane, vec![pane])?;
1301        LayoutSnapshot::new(active_tab, vec![tab])
1302    }
1303
1304    fn pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1305        self::pane_regions_snapshot_with_visible_top_row(0)
1306    }
1307
1308    fn pane_regions_snapshot_with_visible_top_row(visible_top_row: u64) -> rootcause::Result<PaneRegionsSnapshot> {
1309        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1310            muxr_core::PaneId::new(1)?,
1311            0,
1312            0,
1313            2,
1314            1,
1315            muxr_core::PaneMouseMode::None,
1316            visible_top_row,
1317        )?])
1318    }
1319
1320    fn any_motion_pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1321        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1322            muxr_core::PaneId::new(1)?,
1323            0,
1324            0,
1325            2,
1326            1,
1327            muxr_core::PaneMouseMode::AnyMotion,
1328            0,
1329        )?])
1330    }
1331
1332    fn word_pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1333        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1334            muxr_core::PaneId::new(1)?,
1335            0,
1336            0,
1337            7,
1338            1,
1339            muxr_core::PaneMouseMode::None,
1340            0,
1341        )?])
1342    }
1343
1344    fn three_row_pane_regions_snapshot(visible_top_row: u64) -> rootcause::Result<PaneRegionsSnapshot> {
1345        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1346            muxr_core::PaneId::new(1)?,
1347            0,
1348            0,
1349            2,
1350            3,
1351            muxr_core::PaneMouseMode::None,
1352            visible_top_row,
1353        )?])
1354    }
1355
1356    fn two_tab_layout() -> rootcause::Result<LayoutSnapshot> {
1357        LayoutSnapshot::new(
1358            muxr_core::TabId::new(2)?,
1359            vec![
1360                muxr_core::TabSnapshot::new(
1361                    muxr_core::TabId::new(1)?,
1362                    "default",
1363                    muxr_core::PaneId::new(1)?,
1364                    vec![muxr_core::PaneSnapshot {
1365                        tracked_process_state: muxr_core::TrackedProcessState::None,
1366                        cwd: "/tmp/tab-1".to_owned(),
1367                        cmd_label: None,
1368                        focus_seq: 1,
1369                        id: muxr_core::PaneId::new(1)?,
1370                        title: "shell".to_owned(),
1371                    }],
1372                )?,
1373                muxr_core::TabSnapshot::new(
1374                    muxr_core::TabId::new(2)?,
1375                    "tab 2",
1376                    muxr_core::PaneId::new(2)?,
1377                    vec![muxr_core::PaneSnapshot {
1378                        tracked_process_state: muxr_core::TrackedProcessState::None,
1379                        cwd: "/tmp/tab-2".to_owned(),
1380                        cmd_label: None,
1381                        focus_seq: 1,
1382                        id: muxr_core::PaneId::new(2)?,
1383                        title: "shell".to_owned(),
1384                    }],
1385                )?,
1386            ],
1387        )
1388    }
1389
1390    fn render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1391        muxr_core::RenderBaseline::new(
1392            1,
1393            TerminalSize::new(2, 1)?,
1394            muxr_core::RenderCursor {
1395                row: 0,
1396                col: 1,
1397                shape: muxr_core::RenderCursorShape::Default,
1398                visibility: muxr_core::RenderCursorVisibility::Visible,
1399            },
1400            vec![muxr_core::RenderRowSpan::new(
1401                0,
1402                0,
1403                vec![render_cell("a"), render_cell("b")],
1404            )?],
1405        )
1406    }
1407
1408    fn word_render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1409        muxr_core::RenderBaseline::new(
1410            1,
1411            TerminalSize::new(7, 1)?,
1412            muxr_core::RenderCursor {
1413                row: 0,
1414                col: 1,
1415                shape: muxr_core::RenderCursorShape::Default,
1416                visibility: muxr_core::RenderCursorVisibility::Visible,
1417            },
1418            vec![muxr_core::RenderRowSpan::new(
1419                0,
1420                0,
1421                "one two".chars().map(|ch| render_cell(&ch.to_string())).collect(),
1422            )?],
1423        )
1424    }
1425
1426    fn three_row_render_baseline(
1427        first: &str,
1428        second: &str,
1429        third: &str,
1430    ) -> rootcause::Result<muxr_core::RenderBaseline> {
1431        muxr_core::RenderBaseline::new(
1432            1,
1433            TerminalSize::new(2, 3)?,
1434            muxr_core::RenderCursor {
1435                row: 0,
1436                col: 1,
1437                shape: muxr_core::RenderCursorShape::Default,
1438                visibility: muxr_core::RenderCursorVisibility::Visible,
1439            },
1440            vec![
1441                muxr_core::RenderRowSpan::new(0, 0, first.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1442                muxr_core::RenderRowSpan::new(1, 0, second.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1443                muxr_core::RenderRowSpan::new(2, 0, third.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1444            ],
1445        )
1446    }
1447
1448    fn render_diff() -> rootcause::Result<muxr_core::RenderDiff> {
1449        muxr_core::RenderDiff::new(
1450            1,
1451            2,
1452            TerminalSize::new(2, 1)?,
1453            muxr_core::RenderCursor {
1454                row: 0,
1455                col: 1,
1456                shape: muxr_core::RenderCursorShape::Default,
1457                visibility: muxr_core::RenderCursorVisibility::Visible,
1458            },
1459            vec![muxr_core::RenderRowSpan::new(0, 0, vec![render_cell("x")])?],
1460        )
1461    }
1462
1463    fn render_cell(text: &str) -> muxr_core::RenderCell {
1464        muxr_core::RenderCell::narrow(text, muxr_core::RenderStyle::default())
1465    }
1466}