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