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 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}