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 apply_selection_input_at_logical(
345        &mut self,
346        input: SelectionInput,
347        now: Instant,
348    ) -> rootcause::Result<()> {
349        if matches!(input, SelectionInput::Start(_) | SelectionInput::End(_)) {
350            self.selection_edge_scroll.clear();
351            self.edge_scroll_render_generation = None;
352        }
353        match input {
354            SelectionInput::Start(position)
355                if self
356                    .clicks
357                    .record_selection_start(position, &self.pane_regions, &self.frame_buffer, now)
358                    == SelectionClickOutcome::Double =>
359            {
360                self.selection
361                    .select_word(position, &self.pane_regions, &self.frame_buffer)?
362            }
363            SelectionInput::Start(position) => {
364                self.selection
365                    .apply(SelectionInput::Start(position), &self.pane_regions, &self.frame_buffer)?
366            }
367            SelectionInput::Update(position) => {
368                self.selection
369                    .apply(SelectionInput::Update(position), &self.pane_regions, &self.frame_buffer)?
370            }
371            SelectionInput::End(position) => {
372                self.selection
373                    .apply(SelectionInput::End(position), &self.pane_regions, &self.frame_buffer)?
374            }
375        };
376        Ok(())
377    }
378
379    pub fn mouse_request_for_event(&mut self, event: ClientMouseEvent) -> Option<ClientMouseEvent> {
380        if crate::pane::scroll::MouseWheelEvent::from(event) == crate::pane::scroll::MouseWheelEvent::Wheel {
381            return None;
382        }
383
384        if let Some(capture) = self.mouse_capture.as_ref() {
385            let event = ClientMouseEvent {
386                position: self::clamp_mouse_position_to_region(event.position, &capture.region),
387                ..event
388            };
389            if event.phase == ClientMouseEventPhase::Release {
390                self.mouse_capture = None;
391            }
392            return Some(event);
393        }
394
395        let region = self.pane_regions.pane_at(event.position)?;
396        if region.mouse_mode() == PaneMouseMode::None {
397            return None;
398        }
399        if MouseCaptureStart::from(event) == MouseCaptureStart::Start {
400            self.mouse_capture = Some(MouseCapture { region: region.clone() });
401        }
402        Some(event)
403    }
404
405    pub fn copy_selection(&self) -> rootcause::Result<()> {
406        let Some(text) = self.selection.selected_text() else {
407            return Ok(());
408        };
409        crate::copy_selection::copy_to_clipboard(&text)
410    }
411
412    pub fn copy_selection_inline(&self) -> rootcause::Result<()> {
413        let Some(text) = self.selection.selected_inline_text() else {
414            return Ok(());
415        };
416        crate::copy_selection::copy_to_clipboard(&text)
417    }
418
419    pub fn set_selection_edge_drag(
420        &mut self,
421        position: ClientMousePosition,
422        forced_direction: Option<PaneScrollDirection>,
423    ) -> Option<SelectionEdgeScrollRequest> {
424        let drag_region = self.selection.drag_region().cloned();
425        self.selection_edge_scroll
426            .set_edge_drag(position, forced_direction, drag_region.as_ref())
427    }
428
429    pub fn set_selection_outside_edge_drag(
430        &mut self,
431        position: ClientMousePosition,
432    ) -> Option<SelectionEdgeScrollRequest> {
433        let drag_region = self.selection.drag_region().cloned();
434        self.selection_edge_scroll
435            .set_outside_edge_drag(position, drag_region.as_ref())
436    }
437
438    fn refresh_edge_drag_selection_logical(&mut self) -> rootcause::Result<()> {
439        let Some(position) = self.selection_edge_scroll.drag_position(&self.pane_regions) else {
440            self.selection_edge_scroll.clear();
441            self.edge_scroll_render_generation = None;
442            return Ok(());
443        };
444        self.apply_selection_input_logical(SelectionInput::Update(position))
445    }
446
447    pub fn selection_edge_scroll_request(&self) -> Option<SelectionEdgeScrollRequest> {
448        self.selection_edge_scroll.scroll_request(&self.pane_regions)
449    }
450
451    pub fn apply_scroll_pane_line_result(
452        &mut self,
453        position: ClientMousePosition,
454        direction: PaneScrollDirection,
455        movement: PaneScrollLineMove,
456    ) {
457        self.selection_edge_scroll
458            .apply_scroll_pane_line_result(position, direction, movement);
459    }
460
461    fn presentation_changes(
462        &self,
463        committed: Option<&ClientPresentationSnapshot>,
464        logical: &ClientPresentationSnapshot,
465    ) -> rootcause::Result<Option<RenderFrameChanges>> {
466        let Some(committed) = committed else {
467            return Ok(self.frame_buffer.full_redraw_changes());
468        };
469        let Some(mut rows) = self.frame_buffer.changed_rows_since(&committed.frame_buffer) else {
470            return Ok(self.frame_buffer.full_redraw_changes());
471        };
472        if committed.selection != logical.selection {
473            rows.extend(crate::copy_selection::changed_selection_rows(
474                committed.selection.as_ref(),
475                logical.selection.as_ref(),
476            ));
477        }
478        let rows = rows
479            .into_iter()
480            .collect::<BTreeSet<_>>()
481            .into_iter()
482            .collect::<Vec<_>>();
483        if rows.is_empty() {
484            return if committed.layout == logical.layout {
485                if self.frame_buffer.cursor_matches(&committed.frame_buffer) {
486                    Ok(None)
487                } else {
488                    self.frame_buffer.row_redraw_changes(&[])
489                }
490            } else {
491                self.frame_buffer.row_redraw_changes(&[])
492            };
493        }
494        self.frame_buffer.row_redraw_changes(&rows)
495    }
496
497    pub const fn mouse_capture_state(&self) -> MouseCaptureState {
498        match self.mouse_capture {
499            Some(_) => MouseCaptureState::Captured,
500            None => MouseCaptureState::None,
501        }
502    }
503
504    pub const fn selection_edge_drag(&self) -> SelectionEdgeDrag {
505        match self.selection_edge_scroll.active_state() {
506            crate::copy_selection::SelectionEdgeScrollActive::Active => SelectionEdgeDrag::Active,
507            crate::copy_selection::SelectionEdgeScrollActive::Inactive => SelectionEdgeDrag::Inactive,
508        }
509    }
510
511    pub const fn mark_selection_edge_scroll_sent(&mut self, pending: SelectionEdgeScrollPending) {
512        self.selection_edge_scroll.mark_sent(pending);
513    }
514}
515
516fn region_for_pane_id(regions: &PaneRegionsSnapshot, pane_id: PaneId) -> Option<&PaneRegionSnapshot> {
517    regions.regions().iter().find(|region| *region.id() == pane_id)
518}
519
520fn clamp_mouse_position_to_region(position: ClientMousePosition, region: &PaneRegionSnapshot) -> ClientMousePosition {
521    ClientMousePosition {
522        row: position
523            .row
524            .clamp(region.row(), self::last_region_row_saturating(region)),
525        col: position
526            .col
527            .clamp(region.col(), self::last_region_col_saturating(region)),
528    }
529}
530
531const fn last_region_col_saturating(region: &PaneRegionSnapshot) -> u16 {
532    region.col().saturating_add(region.cols().saturating_sub(1))
533}
534
535const fn last_region_row_saturating(region: &PaneRegionSnapshot) -> u16 {
536    region.row().saturating_add(region.rows().saturating_sub(1))
537}
538
539#[derive(Clone, Copy, Debug, Eq, PartialEq)]
540pub enum MouseCaptureState {
541    Captured,
542    None,
543}
544
545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
546pub enum SelectionEdgeDrag {
547    Active,
548    Inactive,
549}
550
551#[derive(Clone, Copy, Debug, Eq, PartialEq)]
552enum MouseCaptureStart {
553    Ignore,
554    Start,
555}
556
557impl From<ClientMouseEvent> for MouseCaptureStart {
558    fn from(event: ClientMouseEvent) -> Self {
559        if event.phase == ClientMouseEventPhase::Press && event.button & (32 | 64) == 0 && event.button & 0b11 != 0b11 {
560            Self::Start
561        } else {
562            Self::Ignore
563        }
564    }
565}
566
567#[cfg(test)]
568pub mod test_helpers {
569    use super::*;
570
571    pub fn selected_text(renderer: &ClientRenderer) -> Option<String> {
572        renderer.selection.selected_text()
573    }
574
575    pub fn selection_contains(renderer: &ClientRenderer, row: u16, col: u16) -> bool {
576        renderer
577            .selection
578            .range()
579            .is_some_and(|selection| selection.contains(row, col))
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use std::time::Duration;
586    use std::time::Instant;
587
588    use muxr_core::ClientRequest;
589    use muxr_core::PaneSnapshot;
590    use muxr_core::RenderBaseline;
591    use muxr_core::TabId;
592    use muxr_core::TabSnapshot;
593    use muxr_core::TerminalSize;
594    use rootcause::report;
595    use test_that::prelude::*;
596
597    use super::*;
598    use crate::renderer::test_helpers;
599
600    #[test]
601    fn test_file_open_request_when_bare_file_is_clicked_resolves_against_pane_cwd() -> rootcause::Result<()> {
602        let directory = tempfile::tempdir()?;
603        let path = directory.path().join("README");
604        std::fs::write(&path, b"content")?;
605        let cwd = directory
606            .path()
607            .to_str()
608            .ok_or_else(|| report!("temporary test directory is not UTF-8"))?;
609
610        let request = self::file_open_request_for_text(cwd, "README")?;
611
612        assert_that!(
613            request,
614            some(eq(ClientRequest::OpenFile {
615                pane_id: PaneId::new(1)?,
616                path: path
617                    .to_str()
618                    .ok_or_else(|| report!("temporary test file path is not UTF-8"))?
619                    .to_owned(),
620                line: None,
621                column: None,
622            }))
623        );
624        Ok(())
625    }
626
627    #[test]
628    fn test_file_open_request_when_bare_directory_is_clicked_resolves_against_pane_cwd() -> rootcause::Result<()> {
629        let directory = tempfile::tempdir()?;
630        let path = directory.path().join("src");
631        std::fs::create_dir(&path)?;
632        let cwd = directory
633            .path()
634            .to_str()
635            .ok_or_else(|| report!("temporary test directory is not UTF-8"))?;
636
637        let request = self::file_open_request_for_text(cwd, "src")?;
638
639        assert_that!(
640            request,
641            some(eq(ClientRequest::OpenFile {
642                pane_id: PaneId::new(1)?,
643                path: path
644                    .to_str()
645                    .ok_or_else(|| report!("temporary directory path is not UTF-8"))?
646                    .to_owned(),
647                line: None,
648                column: None,
649            }))
650        );
651        Ok(())
652    }
653
654    fn file_open_request_for_text(cwd: &str, text: &str) -> rootcause::Result<Option<ClientRequest>> {
655        let width = u16::try_from(text.chars().count())?;
656        let layout = LayoutSnapshot::new(
657            TabId::new(1)?,
658            vec![TabSnapshot::new(
659                TabId::new(1)?,
660                "default",
661                PaneId::new(1)?,
662                vec![PaneSnapshot {
663                    tracked_process_state: muxr_core::TrackedProcessState::None,
664                    cwd: cwd.to_owned(),
665                    cmd_label: None,
666                    focus_seq: 1,
667                    id: PaneId::new(1)?,
668                    title: "shell".to_owned(),
669                }],
670            )?],
671        )?;
672        let pane_regions = PaneRegionsSnapshot::new(vec![PaneRegionSnapshot::new(
673            PaneId::new(1)?,
674            0,
675            0,
676            width,
677            1,
678            PaneMouseMode::None,
679            0,
680        )?])?;
681        let mut renderer = ClientRenderer::with_synchronized_output(layout, pane_regions, SynchronizedOutput::Csi);
682        let baseline = RenderBaseline::new(
683            1,
684            muxr_core::TerminalSize::new(width, 1)?,
685            muxr_core::RenderCursor {
686                row: 0,
687                col: 0,
688                shape: muxr_core::RenderCursorShape::Default,
689                visibility: muxr_core::RenderCursorVisibility::Visible,
690            },
691            vec![muxr_core::RenderRowSpan::new(
692                0,
693                0,
694                text.chars()
695                    .map(|ch| muxr_core::RenderCell::narrow(ch.to_string(), muxr_core::RenderStyle::default()))
696                    .collect(),
697            )?],
698        )?;
699        renderer.apply_render_logical(RenderUpdate::Baseline(baseline))?;
700        Ok(renderer.file_open_request(ClientMousePosition { row: 0, col: 1 }))
701    }
702
703    #[test]
704    fn test_presentation_transaction_when_layout_is_dirty_encodes_one_complete_frame() -> rootcause::Result<()> {
705        let mut renderer = ClientRenderer::with_synchronized_output(
706            layout_snapshot()?,
707            pane_regions_snapshot()?,
708            SynchronizedOutput::Csi,
709        );
710        renderer.apply_layout(two_tab_layout()?);
711        let outcome = renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
712        let transaction = renderer
713            .presentation_transaction(None)?
714            .ok_or_else(|| report!("expected presentation transaction"))?;
715
716        assert_that!(outcome, eq(ClientRenderOutcome::Drawn));
717        let terminal_output = String::from_utf8(transaction)?;
718        assert_that!(terminal_output, contains_substring("\x1b[?2026h"));
719        assert_that!(terminal_output, ends_with("\x1b[?2026l"));
720        let tab_bar_index = terminal_output.find("tab-1").unwrap_or(usize::MAX);
721        let pane_index = terminal_output.find("ab").unwrap_or(usize::MAX);
722        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
723        assert_that!(tab_bar_index, lt(pane_index));
724        Ok(())
725    }
726
727    #[test]
728    fn test_client_renderer_when_render_transaction_capacity_is_outlier_discards_it() -> rootcause::Result<()> {
729        let mut renderer = ClientRenderer::with_synchronized_output(
730            layout_snapshot()?,
731            pane_regions_snapshot()?,
732            SynchronizedOutput::Csi,
733        );
734        renderer
735            .render_transaction
736            .reserve(MAX_RETAINED_RENDER_TRANSACTION_BYTES + 1);
737
738        renderer.reset_render_transaction();
739
740        assert_that!(
741            renderer.render_transaction.capacity(),
742            le(MAX_RETAINED_RENDER_TRANSACTION_BYTES)
743        );
744        Ok(())
745    }
746
747    #[test]
748    fn test_client_renderer_apply_render_logical_when_resync_is_needed_returns_needs_resync() -> rootcause::Result<()> {
749        let mut renderer = ClientRenderer::with_synchronized_output(
750            layout_snapshot()?,
751            pane_regions_snapshot()?,
752            SynchronizedOutput::Csi,
753        );
754        let outcome = renderer.apply_render_logical(muxr_core::RenderUpdate::Diff(render_diff()?))?;
755
756        assert_that!(outcome, eq(ClientRenderOutcome::NeedsResync));
757        Ok(())
758    }
759
760    #[test]
761    fn test_presentation_transaction_when_pane_regions_need_mouse_capture_defers_until_first_render()
762    -> rootcause::Result<()> {
763        let mut renderer = ClientRenderer::with_synchronized_output(
764            layout_snapshot()?,
765            pane_regions_snapshot()?,
766            SynchronizedOutput::Csi,
767        );
768        renderer.apply_pane_regions_logical(any_motion_pane_regions_snapshot()?);
769        assert_that!(renderer.presentation_transaction(None)?, eq(None));
770        Ok(())
771    }
772
773    #[test]
774    fn test_client_renderer_apply_pane_regions_when_any_motion_is_no_longer_needed_reasserts_outer_capture()
775    -> rootcause::Result<()> {
776        let mut renderer = ClientRenderer::with_synchronized_output(
777            layout_snapshot()?,
778            any_motion_pane_regions_snapshot()?,
779            SynchronizedOutput::Csi,
780        );
781        renderer.sync_mouse_capture_logical();
782        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
783        let committed = renderer.presentation_snapshot();
784        renderer.apply_pane_regions_logical(pane_regions_snapshot()?);
785        let transaction = renderer
786            .presentation_transaction(Some(&committed))?
787            .ok_or_else(|| report!("expected mouse capture transaction"))?;
788
789        assert_that!(
790            String::from_utf8(transaction)?,
791            eq("\x1b[?1003l\x1b[?1000h\x1b[?1002h\x1b[?1006h")
792        );
793        Ok(())
794    }
795
796    #[test]
797    fn test_presentation_transaction_when_initial_frame_reasserts_current_mouse_capture_before_frame()
798    -> rootcause::Result<()> {
799        let mut renderer = ClientRenderer::with_synchronized_output(
800            layout_snapshot()?,
801            pane_regions_snapshot()?,
802            SynchronizedOutput::Csi,
803        );
804        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
805        renderer.apply_pane_regions_logical(any_motion_pane_regions_snapshot()?);
806        let transaction = renderer
807            .presentation_transaction(None)?
808            .ok_or_else(|| report!("expected a full redraw transaction for the current frame"))?;
809        let terminal_output = String::from_utf8(transaction)?;
810
811        assert_that!(terminal_output, starts_with("\x1b[?1003h\x1b[?2026h"));
812        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
813        Ok(())
814    }
815
816    #[test]
817    fn test_presentation_transaction_when_initial_frame_arrives_encodes_full_repaint_without_clear()
818    -> rootcause::Result<()> {
819        let mut renderer = ClientRenderer::with_synchronized_output(
820            layout_snapshot()?,
821            pane_regions_snapshot()?,
822            SynchronizedOutput::Csi,
823        );
824
825        assert_that!(
826            renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?,
827            eq(ClientRenderOutcome::Drawn)
828        );
829        let transaction = renderer
830            .presentation_transaction(None)?
831            .ok_or_else(|| report!("expected initial presentation transaction"))?;
832        let terminal_output = String::from_utf8(transaction)?;
833
834        assert_that!(terminal_output, contains_substring("\x1b[?2026h"));
835        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
836        Ok(())
837    }
838
839    #[test]
840    fn test_presentation_transaction_when_selection_changes_encodes_delta_from_committed_snapshot()
841    -> rootcause::Result<()> {
842        let mut renderer = ClientRenderer::with_synchronized_output(
843            layout_snapshot()?,
844            pane_regions_snapshot()?,
845            SynchronizedOutput::Csi,
846        );
847        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
848        let committed = renderer.presentation_snapshot();
849        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
850        renderer.apply_selection_input_logical(SelectionInput::Update(ClientMousePosition { row: 0, col: 1 }))?;
851
852        let transaction = renderer
853            .presentation_transaction(Some(&committed))?
854            .ok_or_else(|| report!("expected selection presentation transaction"))?;
855        let terminal_output = String::from_utf8(transaction)?;
856
857        assert_that!(terminal_output, starts_with("\x1b[?2026h"));
858        assert_that!(terminal_output, not(contains_substring("tab-0")));
859        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
860        Ok(())
861    }
862
863    #[test]
864    fn test_client_renderer_when_region_diff_is_pixel_identical_skips_terminal_transaction() -> rootcause::Result<()> {
865        let mut renderer = ClientRenderer::with_synchronized_output(
866            layout_snapshot()?,
867            pane_regions_snapshot()?,
868            SynchronizedOutput::Csi,
869        );
870        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
871        let committed = renderer.presentation_snapshot();
872
873        renderer.apply_render_logical(muxr_core::RenderUpdate::Diff(muxr_core::RenderDiff::new(
874            1,
875            2,
876            TerminalSize::new(2, 1)?,
877            muxr_core::RenderCursor {
878                row: 0,
879                col: 1,
880                shape: muxr_core::RenderCursorShape::Default,
881                visibility: muxr_core::RenderCursorVisibility::Visible,
882            },
883            Vec::new(),
884        )?))?;
885
886        assert_that!(renderer.presentation_transaction(Some(&committed))?, eq(None));
887        Ok(())
888    }
889
890    #[test]
891    fn test_client_renderer_when_sidebar_layout_changes_encodes_sidebar_only_transaction() -> rootcause::Result<()> {
892        let mut renderer = ClientRenderer::with_synchronized_output(
893            layout_snapshot()?,
894            pane_regions_snapshot()?,
895            SynchronizedOutput::Csi,
896        );
897        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
898        let committed = renderer.presentation_snapshot();
899
900        renderer.apply_sidebar_layout_logical(two_tab_layout()?);
901
902        let transaction = renderer
903            .presentation_transaction(Some(&committed))?
904            .ok_or_else(|| report!("expected sidebar-only presentation transaction"))?;
905        let terminal_output = String::from_utf8(transaction)?;
906
907        assert_that!(terminal_output, contains_substring("tab-1"));
908        assert_that!(terminal_output, not(contains_substring("\x1b[2J")));
909        Ok(())
910    }
911
912    #[test]
913    fn test_client_renderer_when_sidebar_follows_layout_defers_presentation_until_render() -> rootcause::Result<()> {
914        let mut renderer = ClientRenderer::with_synchronized_output(
915            layout_snapshot()?,
916            pane_regions_snapshot()?,
917            SynchronizedOutput::Csi,
918        );
919        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
920        let committed = renderer.presentation_snapshot();
921
922        let layout = two_tab_layout()?;
923        renderer.apply_layout(layout.clone());
924        renderer.apply_sidebar_layout_logical(layout);
925
926        assert_that!(renderer.presentation_transaction(Some(&committed))?, eq(None));
927        Ok(())
928    }
929
930    #[test]
931    fn test_client_renderer_mouse_request_for_event_when_wheel_in_tracking_pane_returns_none() -> rootcause::Result<()>
932    {
933        let mut renderer = ClientRenderer::with_synchronized_output(
934            layout_snapshot()?,
935            any_motion_pane_regions_snapshot()?,
936            SynchronizedOutput::Csi,
937        );
938
939        assert_that!(
940            renderer.mouse_request_for_event(ClientMouseEvent {
941                button: 64,
942                phase: ClientMouseEventPhase::Press,
943                position: ClientMousePosition { row: 0, col: 0 },
944            }),
945            eq(None)
946        );
947        Ok(())
948    }
949
950    #[test]
951    fn test_client_renderer_apply_selection_input_when_frame_exists_redraws_highlighted_selection()
952    -> rootcause::Result<()> {
953        let mut renderer = ClientRenderer::with_synchronized_output(
954            layout_snapshot()?,
955            pane_regions_snapshot()?,
956            SynchronizedOutput::Csi,
957        );
958        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
959        let committed = renderer.presentation_snapshot();
960        renderer
961            .apply_selection_input_logical(SelectionInput::Start(muxr_core::ClientMousePosition { row: 0, col: 0 }))?;
962        renderer.apply_selection_input_logical(SelectionInput::Update(muxr_core::ClientMousePosition {
963            row: 0,
964            col: 1,
965        }))?;
966
967        assert_that!(test_helpers::selection_contains(&renderer, 0, 0), eq(true));
968        assert_that!(test_helpers::selection_contains(&renderer, 0, 1), eq(true));
969        let selection_output = String::from_utf8(
970            renderer
971                .presentation_transaction(Some(&committed))?
972                .ok_or_else(|| report!("expected selection transaction"))?,
973        )?;
974        assert_that!(selection_output, not(contains_substring("\x1b[7m")));
975        Ok(())
976    }
977
978    #[test]
979    fn test_client_renderer_apply_pane_regions_when_selection_viewport_changes_redraws_selection_rows()
980    -> rootcause::Result<()> {
981        let mut renderer = ClientRenderer::with_synchronized_output(
982            layout_snapshot()?,
983            pane_regions_snapshot()?,
984            SynchronizedOutput::Csi,
985        );
986        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(render_baseline()?))?;
987        renderer
988            .apply_selection_input_logical(SelectionInput::Start(muxr_core::ClientMousePosition { row: 0, col: 0 }))?;
989        renderer
990            .apply_selection_input_logical(SelectionInput::End(muxr_core::ClientMousePosition { row: 0, col: 1 }))?;
991        let committed = renderer.presentation_snapshot();
992        renderer.apply_pane_regions_logical(pane_regions_snapshot_with_visible_top_row(1)?);
993
994        let redrawn = String::from_utf8(
995            renderer
996                .presentation_transaction(Some(&committed))?
997                .ok_or_else(|| report!("expected selection viewport transaction"))?,
998        )?;
999        assert_that!(redrawn, contains_substring("ab"));
1000        assert_that!(redrawn, not(contains_substring("\x1b[7m")));
1001        Ok(())
1002    }
1003
1004    #[test]
1005    fn test_client_renderer_apply_render_when_edge_drag_scrolls_extends_selection_after_viewport_moves()
1006    -> rootcause::Result<()> {
1007        let mut renderer = ClientRenderer::with_synchronized_output(
1008            layout_snapshot()?,
1009            three_row_pane_regions_snapshot(9)?,
1010            SynchronizedOutput::Csi,
1011        );
1012        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1013            "aa", "bb", "cc",
1014        )?))?;
1015        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1016        let scroll_request = renderer
1017            .set_selection_edge_drag(ClientMousePosition { row: 3, col: 1 }, None)
1018            .map(|request| request.into_parts().1);
1019        renderer.apply_selection_input_logical(SelectionInput::Update(ClientMousePosition { row: 3, col: 1 }))?;
1020
1021        assert_that!(
1022            scroll_request,
1023            eq(Some(ClientRequest::ScrollPaneLineAt {
1024                direction: PaneScrollDirection::Down,
1025                position: ClientMousePosition { row: 2, col: 1 },
1026            }))
1027        );
1028
1029        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1030        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1031            "bb", "cc", "dd",
1032        )?))?;
1033
1034        assert_that!(
1035            test_helpers::selected_text(&renderer),
1036            eq(Some("aa\nbb\ncc\ndd".to_owned()))
1037        );
1038        assert_that!(test_helpers::selection_contains(&renderer, 2, 0), eq(true));
1039        Ok(())
1040    }
1041
1042    #[test]
1043    fn test_client_renderer_acknowledge_presentation_when_edge_scroll_render_flushes_releases_next_request()
1044    -> rootcause::Result<()> {
1045        let mut renderer = ClientRenderer::with_synchronized_output(
1046            layout_snapshot()?,
1047            three_row_pane_regions_snapshot(9)?,
1048            SynchronizedOutput::Csi,
1049        );
1050        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1051            "aa", "bb", "cc",
1052        )?))?;
1053        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1054        let request = renderer
1055            .set_selection_edge_drag(ClientMousePosition { row: 3, col: 1 }, None)
1056            .ok_or_else(|| report!("expected edge-scroll request"))?;
1057        let (pending, _) = request.into_parts();
1058        renderer.mark_selection_edge_scroll_sent(pending);
1059        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1060
1061        let before_render = renderer.presentation_snapshot();
1062        renderer.acknowledge_presentation(&before_render);
1063        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1064
1065        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1066            "bb", "cc", "dd",
1067        )?))?;
1068        let flushed_snapshot = renderer.presentation_snapshot();
1069        renderer.acknowledge_presentation(&flushed_snapshot);
1070
1071        assert_that!(renderer.selection_edge_scroll_request().is_some(), eq(true));
1072        Ok(())
1073    }
1074
1075    #[test]
1076    fn test_client_renderer_when_old_edge_scroll_flushes_keeps_new_pending_request() -> rootcause::Result<()> {
1077        let mut renderer = ClientRenderer::with_synchronized_output(
1078            layout_snapshot()?,
1079            three_row_pane_regions_snapshot(9)?,
1080            SynchronizedOutput::Csi,
1081        );
1082        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1083            "aa", "bb", "cc",
1084        )?))?;
1085        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 0, col: 0 }))?;
1086
1087        let first_request = renderer
1088            .set_selection_edge_drag(ClientMousePosition { row: 2, col: 1 }, None)
1089            .ok_or_else(|| report!("expected first edge-scroll request"))?;
1090        let (first_pending, _) = first_request.into_parts();
1091        renderer.mark_selection_edge_scroll_sent(first_pending);
1092        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1093        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1094            "bb", "cc", "dd",
1095        )?))?;
1096        let first_snapshot = renderer.presentation_snapshot();
1097
1098        let second_request = renderer
1099            .set_selection_edge_drag(ClientMousePosition { row: 0, col: 1 }, None)
1100            .ok_or_else(|| report!("expected second edge-scroll request"))?;
1101        let (second_pending, _) = second_request.into_parts();
1102        renderer.mark_selection_edge_scroll_sent(second_pending);
1103        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(9)?);
1104
1105        renderer.acknowledge_presentation(&first_snapshot);
1106        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1107
1108        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1109            "aa", "bb", "cc",
1110        )?))?;
1111        let second_snapshot = renderer.presentation_snapshot();
1112        renderer.acknowledge_presentation(&second_snapshot);
1113
1114        assert_that!(renderer.selection_edge_scroll_request().is_some(), eq(true));
1115        Ok(())
1116    }
1117
1118    #[rstest::rstest]
1119    #[case::top_edge(ClientMousePosition { row: 0, col: 1 }, PaneScrollDirection::Up)]
1120    #[case::bottom_edge(ClientMousePosition { row: 2, col: 1 }, PaneScrollDirection::Down)]
1121    fn test_client_renderer_set_selection_edge_drag_when_pointer_is_on_edge_row_requests_scroll(
1122        #[case] position: ClientMousePosition,
1123        #[case] direction: PaneScrollDirection,
1124    ) -> rootcause::Result<()> {
1125        let mut renderer = ClientRenderer::with_synchronized_output(
1126            layout_snapshot()?,
1127            three_row_pane_regions_snapshot(9)?,
1128            SynchronizedOutput::Csi,
1129        );
1130        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1131            "aa", "bb", "cc",
1132        )?))?;
1133        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1134
1135        let request = renderer
1136            .set_selection_edge_drag(position, None)
1137            .map(|request| request.into_parts().1);
1138
1139        assert_that!(
1140            request,
1141            eq(Some(ClientRequest::ScrollPaneLineAt { position, direction }))
1142        );
1143        Ok(())
1144    }
1145
1146    #[test]
1147    fn test_client_renderer_apply_scroll_pane_line_result_when_scroll_is_noop_clears_pending_request()
1148    -> rootcause::Result<()> {
1149        let mut renderer = ClientRenderer::with_synchronized_output(
1150            layout_snapshot()?,
1151            three_row_pane_regions_snapshot(9)?,
1152            SynchronizedOutput::Csi,
1153        );
1154        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1155            "aa", "bb", "cc",
1156        )?))?;
1157        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1158        let position = ClientMousePosition { row: 2, col: 1 };
1159        let direction = PaneScrollDirection::Down;
1160        let request = renderer
1161            .set_selection_edge_drag(position, None)
1162            .ok_or_else(|| report!("expected muxr edge scroll request"))?;
1163        let (pending, _) = request.into_parts();
1164
1165        renderer.mark_selection_edge_scroll_sent(pending);
1166        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1167        renderer.apply_scroll_pane_line_result(position, direction, PaneScrollLineMove::Unchanged);
1168
1169        let retry = renderer
1170            .selection_edge_scroll_request()
1171            .map(|request| request.into_parts().1);
1172        assert_that!(retry, eq(Some(ClientRequest::ScrollPaneLineAt { position, direction })));
1173        Ok(())
1174    }
1175
1176    #[test]
1177    fn test_client_renderer_apply_scroll_pane_line_result_when_scroll_moves_waits_for_render_ack()
1178    -> rootcause::Result<()> {
1179        let mut renderer = ClientRenderer::with_synchronized_output(
1180            layout_snapshot()?,
1181            three_row_pane_regions_snapshot(9)?,
1182            SynchronizedOutput::Csi,
1183        );
1184        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1185            "aa", "bb", "cc",
1186        )?))?;
1187        renderer.apply_selection_input_logical(SelectionInput::Start(ClientMousePosition { row: 1, col: 0 }))?;
1188        let position = ClientMousePosition { row: 2, col: 1 };
1189        let direction = PaneScrollDirection::Down;
1190        let request = renderer
1191            .set_selection_edge_drag(position, None)
1192            .ok_or_else(|| report!("expected muxr edge scroll request"))?;
1193        let (pending, _) = request.into_parts();
1194
1195        renderer.mark_selection_edge_scroll_sent(pending);
1196        renderer.apply_scroll_pane_line_result(position, direction, PaneScrollLineMove::Moved);
1197
1198        assert_that!(renderer.selection_edge_scroll_request(), eq(None));
1199        renderer.apply_pane_regions_logical(three_row_pane_regions_snapshot(10)?);
1200        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(three_row_render_baseline(
1201            "bb", "cc", "dd",
1202        )?))?;
1203        let rendered_snapshot = renderer.presentation_snapshot();
1204        renderer.acknowledge_presentation(&rendered_snapshot);
1205        let retry = renderer
1206            .selection_edge_scroll_request()
1207            .map(|request| request.into_parts().1);
1208        assert_that!(retry, eq(Some(ClientRequest::ScrollPaneLineAt { position, direction })));
1209        Ok(())
1210    }
1211
1212    #[rstest::rstest]
1213    #[case::same_cell(4, 4)]
1214    #[case::same_word_different_cell(4, 6)]
1215    fn test_client_renderer_apply_selection_input_when_double_click_selects_visible_word(
1216        #[case] first_col: u16,
1217        #[case] second_col: u16,
1218    ) -> rootcause::Result<()> {
1219        let mut renderer = ClientRenderer::with_synchronized_output(
1220            layout_snapshot()?,
1221            word_pane_regions_snapshot()?,
1222            SynchronizedOutput::Csi,
1223        );
1224        renderer.apply_render_logical(muxr_core::RenderUpdate::Baseline(word_render_baseline()?))?;
1225        let now = Instant::now();
1226        let first_position = ClientMousePosition { row: 0, col: first_col };
1227        let second_position = ClientMousePosition {
1228            row: 0,
1229            col: second_col,
1230        };
1231        let second_click_at = now
1232            .checked_add(Duration::from_millis(100))
1233            .ok_or_else(|| report!("muxr double-click selection test instant overflowed"))?;
1234        renderer.apply_selection_input_at_logical(SelectionInput::Start(first_position), now)?;
1235        renderer.apply_selection_input_at_logical(SelectionInput::End(first_position), now)?;
1236        renderer.apply_selection_input_at_logical(SelectionInput::Start(second_position), second_click_at)?;
1237
1238        assert_that!(test_helpers::selected_text(&renderer), eq(Some("two".to_owned())));
1239        Ok(())
1240    }
1241
1242    #[test]
1243    fn test_client_renderer_apply_pane_regions_when_same_pane_remains_keeps_double_click() -> 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 position = ClientMousePosition { row: 0, col: 4 };
1252        let second_click_at = now
1253            .checked_add(Duration::from_millis(100))
1254            .ok_or_else(|| report!("muxr retained double-click selection test instant overflowed"))?;
1255        renderer.apply_selection_input_at_logical(SelectionInput::Start(position), now)?;
1256        renderer.apply_selection_input_at_logical(SelectionInput::End(position), now)?;
1257        renderer.apply_pane_regions_logical(word_pane_regions_snapshot()?);
1258        renderer.apply_selection_input_at_logical(SelectionInput::Start(position), second_click_at)?;
1259
1260        assert_that!(test_helpers::selected_text(&renderer), eq(Some("two".to_owned())));
1261        Ok(())
1262    }
1263
1264    fn layout_snapshot() -> rootcause::Result<LayoutSnapshot> {
1265        let active_tab = TabId::new(1)?;
1266        let active_pane = PaneId::new(1)?;
1267        let pane = PaneSnapshot {
1268            tracked_process_state: muxr_core::TrackedProcessState::None,
1269            cwd: "/tmp/default".to_owned(),
1270            cmd_label: None,
1271            focus_seq: 1,
1272            id: active_pane,
1273            title: "shell".to_owned(),
1274        };
1275        let tab = TabSnapshot::new(active_tab, "default", active_pane, vec![pane])?;
1276        LayoutSnapshot::new(active_tab, vec![tab])
1277    }
1278
1279    fn pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1280        self::pane_regions_snapshot_with_visible_top_row(0)
1281    }
1282
1283    fn pane_regions_snapshot_with_visible_top_row(visible_top_row: u64) -> rootcause::Result<PaneRegionsSnapshot> {
1284        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1285            muxr_core::PaneId::new(1)?,
1286            0,
1287            0,
1288            2,
1289            1,
1290            muxr_core::PaneMouseMode::None,
1291            visible_top_row,
1292        )?])
1293    }
1294
1295    fn any_motion_pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1296        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1297            muxr_core::PaneId::new(1)?,
1298            0,
1299            0,
1300            2,
1301            1,
1302            muxr_core::PaneMouseMode::AnyMotion,
1303            0,
1304        )?])
1305    }
1306
1307    fn word_pane_regions_snapshot() -> rootcause::Result<PaneRegionsSnapshot> {
1308        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1309            muxr_core::PaneId::new(1)?,
1310            0,
1311            0,
1312            7,
1313            1,
1314            muxr_core::PaneMouseMode::None,
1315            0,
1316        )?])
1317    }
1318
1319    fn three_row_pane_regions_snapshot(visible_top_row: u64) -> rootcause::Result<PaneRegionsSnapshot> {
1320        PaneRegionsSnapshot::new(vec![muxr_core::PaneRegionSnapshot::new(
1321            muxr_core::PaneId::new(1)?,
1322            0,
1323            0,
1324            2,
1325            3,
1326            muxr_core::PaneMouseMode::None,
1327            visible_top_row,
1328        )?])
1329    }
1330
1331    fn two_tab_layout() -> rootcause::Result<LayoutSnapshot> {
1332        LayoutSnapshot::new(
1333            muxr_core::TabId::new(2)?,
1334            vec![
1335                muxr_core::TabSnapshot::new(
1336                    muxr_core::TabId::new(1)?,
1337                    "default",
1338                    muxr_core::PaneId::new(1)?,
1339                    vec![muxr_core::PaneSnapshot {
1340                        tracked_process_state: muxr_core::TrackedProcessState::None,
1341                        cwd: "/tmp/tab-1".to_owned(),
1342                        cmd_label: None,
1343                        focus_seq: 1,
1344                        id: muxr_core::PaneId::new(1)?,
1345                        title: "shell".to_owned(),
1346                    }],
1347                )?,
1348                muxr_core::TabSnapshot::new(
1349                    muxr_core::TabId::new(2)?,
1350                    "tab 2",
1351                    muxr_core::PaneId::new(2)?,
1352                    vec![muxr_core::PaneSnapshot {
1353                        tracked_process_state: muxr_core::TrackedProcessState::None,
1354                        cwd: "/tmp/tab-2".to_owned(),
1355                        cmd_label: None,
1356                        focus_seq: 1,
1357                        id: muxr_core::PaneId::new(2)?,
1358                        title: "shell".to_owned(),
1359                    }],
1360                )?,
1361            ],
1362        )
1363    }
1364
1365    fn render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1366        muxr_core::RenderBaseline::new(
1367            1,
1368            TerminalSize::new(2, 1)?,
1369            muxr_core::RenderCursor {
1370                row: 0,
1371                col: 1,
1372                shape: muxr_core::RenderCursorShape::Default,
1373                visibility: muxr_core::RenderCursorVisibility::Visible,
1374            },
1375            vec![muxr_core::RenderRowSpan::new(
1376                0,
1377                0,
1378                vec![render_cell("a"), render_cell("b")],
1379            )?],
1380        )
1381    }
1382
1383    fn word_render_baseline() -> rootcause::Result<muxr_core::RenderBaseline> {
1384        muxr_core::RenderBaseline::new(
1385            1,
1386            TerminalSize::new(7, 1)?,
1387            muxr_core::RenderCursor {
1388                row: 0,
1389                col: 1,
1390                shape: muxr_core::RenderCursorShape::Default,
1391                visibility: muxr_core::RenderCursorVisibility::Visible,
1392            },
1393            vec![muxr_core::RenderRowSpan::new(
1394                0,
1395                0,
1396                "one two".chars().map(|ch| render_cell(&ch.to_string())).collect(),
1397            )?],
1398        )
1399    }
1400
1401    fn three_row_render_baseline(
1402        first: &str,
1403        second: &str,
1404        third: &str,
1405    ) -> rootcause::Result<muxr_core::RenderBaseline> {
1406        muxr_core::RenderBaseline::new(
1407            1,
1408            TerminalSize::new(2, 3)?,
1409            muxr_core::RenderCursor {
1410                row: 0,
1411                col: 1,
1412                shape: muxr_core::RenderCursorShape::Default,
1413                visibility: muxr_core::RenderCursorVisibility::Visible,
1414            },
1415            vec![
1416                muxr_core::RenderRowSpan::new(0, 0, first.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1417                muxr_core::RenderRowSpan::new(1, 0, second.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1418                muxr_core::RenderRowSpan::new(2, 0, third.chars().map(|ch| render_cell(&ch.to_string())).collect())?,
1419            ],
1420        )
1421    }
1422
1423    fn render_diff() -> rootcause::Result<muxr_core::RenderDiff> {
1424        muxr_core::RenderDiff::new(
1425            1,
1426            2,
1427            TerminalSize::new(2, 1)?,
1428            muxr_core::RenderCursor {
1429                row: 0,
1430                col: 1,
1431                shape: muxr_core::RenderCursorShape::Default,
1432                visibility: muxr_core::RenderCursorVisibility::Visible,
1433            },
1434            vec![muxr_core::RenderRowSpan::new(0, 0, vec![render_cell("x")])?],
1435        )
1436    }
1437
1438    fn render_cell(text: &str) -> muxr_core::RenderCell {
1439        muxr_core::RenderCell::narrow(text, muxr_core::RenderStyle::default())
1440    }
1441}