Enable text selection and mouse scrolling in the warning viewer (#57271)

## What changed

- Allow selecting warning text while preserving wrapped URLs and hard line breaks when copying.
- Add mouse-wheel scrolling within the warning body and keep page navigation working after selection.
- Use the shared menu surface and show configured scroll hints only for directions with more content.
- Preserve painted backgrounds when rendering text selections.

## Testing

Add coverage for warning selection without changing the draft, paging after selection, wheel scrolling and directional hints, and selection boundaries in scrolled text containing tabs and wrapped words. Update warning panel snapshots.

GitOrigin-RevId: c4b77277a60b7b4af92043cb3761769ddc636454
This commit is contained in:
Ian MacLeod
2026-10-26 21:36:05 +00:00
committed by copyberry
parent 51e3f94b2b
commit 7077b1edb9
23 changed files with 308 additions and 87 deletions
+18 -2
View File
@@ -3,6 +3,7 @@
use super::*;
use crate::clipboard_copy::CopyFormat;
use crate::keymap::ListAction;
use crate::rendered_selection::RegionAction;
use crate::rendered_selection::RenderedSelection;
use crate::transcript_view::ViewAction;
@@ -13,10 +14,12 @@ impl App {
tui: &mut tui::Tui,
event: &TuiEvent,
) -> Result<bool> {
let warning_active = self.chat_widget.keymap_contexts().is_warnings();
if !tui.is_owned_screen()
|| self.overlay.is_some()
|| !self.chat_widget.no_modal_or_popup_active()
|| self.chat_widget.is_external_writer_view()
|| (!warning_active
&& (!self.chat_widget.no_modal_or_popup_active()
|| self.chat_widget.is_external_writer_view()))
{
*self.chat_widget.rendered_selection.borrow_mut() = RenderedSelection::default();
return Ok(false);
@@ -29,6 +32,16 @@ impl App {
}
let action = {
let mut selection = self.chat_widget.rendered_selection.borrow_mut();
if warning_active
&& let TuiEvent::Key(key) = event
&& matches!(
self.keymap.list.action_for(*key),
Some(ListAction::PageUp | ListAction::PageDown)
)
{
selection.clear();
return Ok(false);
}
selection.view.copy_on_select = self
.local_settings
.copy_on_select(&codex_terminal_detection::terminal_info());
@@ -40,6 +53,9 @@ impl App {
let Some(action) = action else {
return Ok(false);
};
if warning_active {
self.chat_widget.record_warning_activity();
}
self.cancel_pending_key_chord();
match action {
RegionAction::Click(mouse) => {
+4
View File
@@ -330,6 +330,10 @@ impl App {
.filter(|_| chat_widget.no_modal_or_popup_active());
feedback_tick =
view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer, now);
chat_widget
.rendered_selection
.borrow_mut()
.register_layout(chat_widget.take_warning_body());
if let Some((_, tip)) = &turn_tip
&& let Some(area) = tip.rendered.get()
{
+26 -4
View File
@@ -1927,10 +1927,10 @@ async fn footer_selection_defers_warning_clicks_and_preserves_the_draft() -> Res
let size = tui.terminal.size()?;
app.chat_widget
.apply_external_edit("draft stays here".into());
app.insert_history_cell(
&mut tui,
Box::new(history_cell::new_warning_event("Example warning".into())),
);
let url = "https://docs.example.invalid/troubleshooting/mcp/oauth/sign-in/this-is-a-long-reference-for-terminal-copy";
let padding = "\n".repeat(8);
let message = format!("Example warning\n{url}\nNext line{padding}Last line");
app.insert_history_cell(&mut tui, Box::new(history_cell::new_warning_event(message)));
app.render_owned_transcript(&mut tui, size)?;
let draft = app.chat_widget.capture_thread_input_state();
let cursor = tui.terminal.last_known_cursor_pos;
@@ -1979,6 +1979,28 @@ async fn footer_selection_defers_warning_clicks_and_preserves_the_draft() -> Res
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
}
assert!(app.chat_widget.keymap_contexts().is_warnings());
app.render_owned_transcript(&mut tui, size)?;
let body = app.chat_widget.rendered_selection.borrow().areas[0];
assert!(
!app.handle_rendered_selection_event(&mut tui, &mouse(Down(Left), body.x, body.y - 1))?
);
let end_row = row_containing(&tui, "Next line");
for event in [
mouse(Down(Left), body.x, body.y + 1),
mouse(Drag(Left), body.x + 4, end_row),
mouse(Up(Left), body.x + 4, end_row),
] {
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
}
let mut selection = app.chat_widget.rendered_selection.borrow_mut();
let crate::rendered_selection::RenderedSelection { view, cells, .. } = &mut *selection;
assert_eq!(view.selected_text(cells), Some(format!("{url}\nNext")));
drop(selection);
let page = KeyCode::PageDown.into();
assert!(!app.handle_rendered_selection_event(&mut tui, &TuiEvent::Key(page))?);
app.chat_widget.handle_key_event(page);
app.render_owned_transcript(&mut tui, size)?;
assert!((body.y..body.bottom()).contains(&row_containing(&tui, "Last line")));
tui.set_owned_screen(/*owned*/ false)?;
Ok(())
}
@@ -1,6 +1,5 @@
---
source: tui/src/app/warning_notice_tests.rs
assertion_line: 116
expression: screen(&tui)
---
@@ -26,4 +25,4 @@ expression: screen(&tui)
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
@@ -12,7 +12,7 @@ Earlier transcript row 18
Earlier transcript row 19
Earlier transcript row 20
Earlier transcript row 21
Earlier transcript row 22
Warnings · 1 of 1 · Warning
@@ -25,4 +25,4 @@ Earlier transcript row 22
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
@@ -36,7 +36,7 @@ First warning details:
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
Second warning details:
@@ -54,4 +54,4 @@ Second warning details:
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
@@ -1,4 +1,20 @@
use crate::keymap::ListAction;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::layout::Position;
use ratatui::layout::Rect;
/// An unmodified wheel tick moves three rows within the painted region.
pub(super) fn wheel_rows_in(event: MouseEvent, area: Rect) -> Option<i16> {
if !event.modifiers.is_empty() || !area.contains(Position::new(event.column, event.row)) {
return None;
}
match event.kind {
MouseEventKind::ScrollUp => Some(-3),
MouseEventKind::ScrollDown => Some(3),
_ => None,
}
}
/// Generic scroll/selection state for a vertical list menu.
///
@@ -2,12 +2,12 @@
source: tui/src/bottom_pane/warnings_view_tests.rs
expression: "draw(&view, 40, 9)"
---
Warnings · 1 of 2 · MCP · example
Long diagnostic 日 本 語
Details retained in full, including
remediation instructions.
Details retained in full, including
remediation instructions.
Details retained in full, including
k keep & next · esc dismiss & close
@@ -2,6 +2,7 @@
source: tui/src/bottom_pane/warnings_view_tests.rs
expression: "draw(&view, 32, 9)"
---
Warnings · 1 of 2 · MCP · ex
Review https://github.com/
@@ -9,5 +10,4 @@ expression: "draw(&view, 32, 9)"
diff=split before
continuing.
k keep & next · ⌃o copy
@@ -77,15 +77,10 @@ impl TextArea {
/// Consume events at the edges too, but only detach from the caret if the viewport moves.
pub(crate) fn scroll_mouse(&self, event: MouseEvent, state: &mut TextAreaState) -> bool {
let rows = match event.kind {
MouseEventKind::ScrollUp => -3,
MouseEventKind::ScrollDown => 3,
_ => return false,
};
if !event.modifiers.is_empty() || !self.contains_mouse(event) {
return false;
}
let area = self.rendered_area.get();
let Some(rows) = crate::bottom_pane::scroll_state::wheel_rows_in(event, area) else {
return false;
};
let lines = self.wrapped_lines(area.width);
let total = u16::try_from(lines.len()).unwrap_or(u16::MAX);
if total <= area.height {
+7 -6
View File
@@ -23,13 +23,14 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn active_warning(&self) -> Option<&warnings_view::WarningsView> {
self.warnings_view.as_ref().filter(|_| {
self.view_stack.is_empty() && !self.questions.as_ref().is_some_and(|q| q.expanded)
})
}
pub(crate) fn warnings_active(&self) -> bool {
self.warnings_view.is_some()
&& self.view_stack.is_empty()
&& !self
.questions
.as_ref()
.is_some_and(|questions| questions.expanded)
self.active_warning().is_some()
}
pub(crate) fn warning_notice_contains(&self, position: ratatui::layout::Position) -> bool {
+28 -11
View File
@@ -13,6 +13,8 @@ use crate::keymap::RuntimeKeymap;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::MouseEvent;
use ratatui::layout::Rect;
use std::cell::Cell;
use std::collections::BTreeSet;
use std::sync::Arc;
@@ -20,15 +22,16 @@ use std::sync::Arc;
#[path = "warnings_view_render.rs"]
mod render;
pub(super) struct WarningsView {
pub(crate) struct WarningsView {
entries: Vec<WarningEntry>,
transcript: Arc<()>,
current: usize,
visited: Vec<Cell<bool>>,
kept: BTreeSet<usize>,
offset: Cell<usize>,
page_size: Cell<usize>,
max_offset: Cell<usize>,
pub(crate) body_area: Cell<Rect>,
pub(crate) body_layout: Cell<Option<(crate::transcript_view::TextLayout, usize)>>,
keymap: RuntimeKeymap,
tx: AppEventSender,
pub(super) pending_hint: Option<Vec<(String, String)>>,
@@ -51,8 +54,9 @@ impl WarningsView {
current: 0,
kept: BTreeSet::new(),
offset: Cell::new(/*value*/ 0),
page_size: Cell::new(/*value*/ 1),
max_offset: Cell::new(/*value*/ 0),
body_area: Cell::default(),
body_layout: Cell::default(),
keymap,
tx,
pending_hint: None,
@@ -88,6 +92,22 @@ impl WarningsView {
}
}
pub(crate) fn scroll_mouse(&self, event: MouseEvent) -> bool {
let Some(rows) = super::scroll_state::wheel_rows_in(event, self.body_area.get()) else {
return false;
};
if self.max_offset.get() == 0 {
return false;
}
self.offset.set(
self.offset
.get()
.saturating_add_signed(isize::from(rows))
.min(self.max_offset.get()),
);
true
}
/// Returns true when the user closes the viewer. No key is forwarded to the draft.
pub(super) fn handle_key(&mut self, event: KeyEvent) -> bool {
if event.kind == KeyEventKind::Release {
@@ -118,6 +138,7 @@ impl WarningsView {
return false;
}
let offset = self.offset.get();
let page_size = usize::from(self.body_area.get().height.max(/*other*/ 1));
match action {
Some(ListAction::MoveLeft | ListAction::MoveRight) => {
let previous = self.current;
@@ -132,14 +153,10 @@ impl WarningsView {
}
Some(ListAction::MoveUp) => self.offset.set(offset.saturating_sub(/*rhs*/ 1)),
Some(ListAction::MoveDown) => self.offset.set((offset + 1).min(self.max_offset.get())),
Some(ListAction::PageUp) => {
self.offset.set(offset.saturating_sub(self.page_size.get()))
}
Some(ListAction::PageDown) => self.offset.set(
offset
.saturating_add(self.page_size.get())
.min(self.max_offset.get()),
),
Some(ListAction::PageUp) => self.offset.set(offset.saturating_sub(page_size)),
Some(ListAction::PageDown) => self
.offset
.set(offset.saturating_add(page_size).min(self.max_offset.get())),
Some(ListAction::JumpTop) => self.offset.set(/*val*/ 0),
Some(ListAction::JumpBottom) => self.offset.set(self.max_offset.get()),
Some(ListAction::Cancel) => return true,
@@ -2,6 +2,8 @@
use super::*;
use crate::bottom_pane::footer_hint_items_line;
use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::render::renderable::Renderable;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -12,20 +14,24 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Widget;
const WARNING_PANEL_CONTENT_ROWS: u16 = 10;
impl Renderable for WarningsView {
fn desired_height(&self, _width: u16) -> u16 {
12
WARNING_PANEL_CONTENT_ROWS + menu_surface_padding_height() + 1
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let area = area.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
let [header, _, body, footer] = Layout::vertical([
Constraint::Length(1),
let [panel, footer] =
Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
let content = render_menu_surface(panel, buf);
let [header, _, body] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(u16::from(content.height > 2)),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
.areas(content);
let footer = footer.inner(Margin::new(/*horizontal*/ 2, /*vertical*/ 0));
let entry = self.entries.get(self.current);
let title = match entry {
Some(entry) => format!(
@@ -47,34 +53,24 @@ impl Renderable for WarningsView {
{
visited.set(/*val*/ true);
}
let lines: Vec<_> = details
let lines = details
.lines()
.chain(restart_hint.into_iter().flat_map(str::lines))
.flat_map(|text| {
let source =
crate::terminal_hyperlinks::annotate_web_urls_in_line(text.to_owned().into());
let wrapped =
crate::wrapping::wrap_ranges_trim(text, usize::from(body.width.max(1)))
.into_iter()
.map(|range| crate::wrapping::WrappedLine {
line: (&text[range.clone()]).into(),
range,
prefix_bytes: 0,
})
.collect();
crate::terminal_hyperlinks::remap_source_wrapped_line(&source, wrapped)
.map(|text| {
crate::terminal_hyperlinks::annotate_web_urls_in_line(text.to_owned().into())
})
.collect();
let page_size = usize::from(body.height.max(/*other*/ 1));
self.page_size.set(page_size);
self.max_offset.set(lines.len().saturating_sub(page_size));
let layout = crate::transcript_view::TextLayout::new(lines, body.width);
self.body_area.set(body);
self.max_offset.set(if body.is_empty() {
0
} else {
layout.row_count().saturating_sub(usize::from(body.height))
});
let offset = self.offset.get().min(self.max_offset.get());
self.offset.set(offset);
crate::terminal_hyperlinks::HyperlinkParagraph::new(
&lines[offset..],
ratatui::style::Style::default(),
)
.render(body, buf);
layout.render(body, buf, offset);
self.body_layout.set(Some((layout, offset)));
// Plain k belongs to this viewer, including when configured as a chord prefix.
let hint = |context, action| {
self.keymap.primary_hint(context, action).filter(|hint| {
@@ -87,10 +83,12 @@ impl Renderable for WarningsView {
*binding != key_hint::plain(KeyCode::Char('k'))
})
};
let list = |action| {
hint(KeymapContext::List, action).map(crate::key_hint::ShortcutHint::display_label)
};
let navigation = ["move_left", "move_right"]
.into_iter()
.filter_map(|action| hint(KeymapContext::List, action))
.map(crate::key_hint::ShortcutHint::display_label)
.filter_map(list)
.collect::<Vec<_>>()
.join("/");
let mut items = Vec::new();
@@ -111,8 +109,12 @@ impl Renderable for WarningsView {
),
((!navigation.is_empty()).then_some(navigation), "warning"),
(
hint(KeymapContext::List, "move_down")
.map(crate::key_hint::ShortcutHint::display_label),
crate::footer_hint::scroll_keys(
offset,
self.max_offset.get(),
list("move_up"),
list("move_down"),
),
"scroll",
),
] {
@@ -5,6 +5,7 @@ use crate::history_cell::WarningId;
use crate::render::renderable::Renderable;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use crossterm::event::MouseEventKind;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -96,6 +97,38 @@ fn warning_pages_wrap_and_scroll_long_diagnostics() {
assert!(draw(&view, /*width*/ 40, /*height*/ 9).contains("Long diagnostic"));
}
#[test]
fn scroll_hint_tracks_wheel_and_keys_across_wrapped_rows() {
let (tx, _) = unbounded_channel();
let mut data = entries();
data[0].details = "word ".repeat(/*n*/ 160);
let keymap =
RuntimeKeymap::from_config(&toml::from_str("[list]\nmove_down = 'z'").unwrap()).unwrap();
let mut view = WarningsView::new(data, Arc::default(), keymap, AppEventSender::new(tx));
let height = view.desired_height(/*width*/ 84);
let wheel = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 2,
row: 3,
modifiers: KeyModifiers::NONE,
};
assert!(draw(&view, /*width*/ 84, height).ends_with(" · z scroll"));
for row in [1, height - 1] {
assert!(!view.scroll_mouse(MouseEvent { row, ..wheel }));
}
key(&mut view, KeyCode::Char('z'));
assert!(draw(&view, /*width*/ 84, height).ends_with(" · ↑/z scroll"));
assert!(view.scroll_mouse(wheel));
assert!(draw(&view, /*width*/ 84, height).ends_with(" · ↑ scroll"));
assert!(view.scroll_mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
..wheel
}));
assert!(draw(&view, /*width*/ 84, height).ends_with(" · z scroll"));
assert!(!draw(&view, /*width*/ 120, height).contains("scroll"));
assert!(!view.scroll_mouse(wheel));
}
#[test]
fn warnings_dismiss_only_drawn_pages_after_navigation_skips_a_frame() {
let (tx, mut rx) = unbounded_channel();
@@ -13,4 +13,4 @@ expression: "super::helpers::render_bottom_popup(&chat, 80)"
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
@@ -14,7 +14,7 @@ local daemon: false
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
local daemon: true
Warnings · 1 of 2 · Startup
@@ -28,4 +28,4 @@ local daemon: true
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning · ↓ scroll
k keep & next · esc dismiss & close · ⌃o copy · ←/→ warning
+25 -2
View File
@@ -3,7 +3,9 @@
use crate::history_cell::HistoryCell;
use crate::history_cell::WarningEntry;
use crate::history_cell::WarningId;
use crate::transcript_view::TextLayout;
use crate::tui::TuiEvent;
use ratatui::layout::Rect;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::sync::Arc;
@@ -51,7 +53,19 @@ fn fallback_model_metadata_warning_slug(message: &str) -> Option<&str> {
}
impl super::ChatWidget {
pub(crate) fn take_warning_body(&self) -> Option<(Rect, TextLayout, usize)> {
let view = self.bottom_pane.active_warning()?;
let (layout, offset) = view.body_layout.take()?;
Some((view.body_area.get(), layout, offset))
}
pub(crate) fn record_warning_activity(&mut self) {
self.bottom_pane
.record_composer_activity_at(std::time::Instant::now());
}
pub(crate) fn open_warnings(&mut self, cells: &[Arc<dyn HistoryCell>]) {
*self.rendered_selection.borrow_mut() = Default::default();
self.empty_state_animation.borrow_mut().pause_clock();
let entries = self.warning_display_state.visible_entries(cells);
let daemon_restart_hint_at = self
@@ -116,8 +130,17 @@ impl super::ChatWidget {
)
{
// Chord routing may consume this key before it reaches the warning panel.
self.bottom_pane
.record_composer_activity_at(std::time::Instant::now());
self.record_warning_activity();
}
if let TuiEvent::Mouse(mouse) = event
&& self
.bottom_pane
.active_warning()
.is_some_and(|view| view.scroll_mouse(*mouse))
{
self.record_warning_activity();
self.request_redraw();
return true;
}
if let TuiEvent::Mouse(mouse) = event
&& mouse.kind
+17
View File
@@ -4,6 +4,23 @@
use crate::style::secondary_text_style;
use ratatui::style::Styled;
use ratatui::text::Line;
/// Show the configured scroll keys for directions with rows beyond the viewport.
pub(crate) fn scroll_keys(
offset: usize,
max_offset: usize,
up: Option<String>,
down: Option<String>,
) -> Option<String> {
match (
up.filter(|_| offset > 0),
down.filter(|_| offset < max_offset),
) {
(Some(up), Some(down)) => Some(format!("{up}/{down}")),
(up, down) => up.or(down),
}
}
/// A complete hint uses the same styled content for measurement and rendering.
pub(crate) fn shortcut(keys: &str, label: &str) -> Line<'static> {
let mut spans = crate::key_hint::key_label_spans(keys);
+5 -1
View File
@@ -67,7 +67,7 @@ fn rendered_selection_preserves_unicode_links_and_the_selected_revision() {
#[test]
fn release_copy_and_cross_surface_drags_use_only_visible_text() {
let status = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 7, /*height*/ 1,
/*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 1,
);
let footer = Rect { y: 1, ..status };
let mut buffer = Buffer::empty(status.union(footer));
@@ -79,6 +79,8 @@ fn release_copy_and_cross_surface_drags_use_only_visible_text() {
selection.register(footer, /*invalidate*/ false);
// Registration retains geometry only: select the final painted cells, not earlier content.
"commit…".render(status, &mut buffer);
let padding = (status.right() - 1, status.y);
buffer[padding].bg = ratatui::style::Color::DarkGray;
selection.handle_event(&mouse(Down(Left), /*column*/ 0, /*row*/ 0), &buffer);
selection.handle_event(&mouse(Drag(Left), /*column*/ 19, /*row*/ 1), &buffer);
let Some(RegionAction::Selection(ViewAction::CopyOnSelect(text))) =
@@ -87,6 +89,8 @@ fn release_copy_and_cross_surface_drags_use_only_visible_text() {
panic!("release must copy the selected status row");
};
assert_eq!(text, "commit…");
selection.render(&mut buffer);
assert_eq!(buffer[padding].bg, ratatui::style::Color::DarkGray);
assert!(selection.view.has_selection_range());
// Collapsing the range keeps keyboard ownership, including Enter, with the footer.
for _ in text.chars() {
+26 -3
View File
@@ -5,6 +5,7 @@
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::transcript_view::TextLayout;
use crate::transcript_view::TranscriptView;
use crate::transcript_view::ViewAction;
use crate::tui::TuiEvent;
@@ -25,6 +26,8 @@ pub(crate) struct RenderedSelection {
pub(crate) view: TranscriptView,
pub(crate) cells: Vec<Arc<dyn HistoryCell>>,
pub(crate) areas: Vec<Rect>,
/// Source-backed viewport when a widget registers its painted text layout.
source_layout: Option<(Rect, TextLayout, usize)>,
snapshot: Option<Buffer>,
pressed: Option<MouseEvent>,
}
@@ -36,7 +39,7 @@ pub(crate) enum RegionAction {
}
impl RenderedSelection {
fn clear(&mut self) {
pub(crate) fn clear(&mut self) {
self.view.jump_to_latest();
self.snapshot = None;
self.pressed = None;
@@ -57,6 +60,13 @@ impl RenderedSelection {
}
}
pub(crate) fn register_layout(&mut self, layout: Option<(Rect, TextLayout, usize)>) {
if let Some((area, _, _)) = &layout {
self.register(*area, /*invalidate*/ false);
}
self.source_layout = layout;
}
pub(crate) fn render(&mut self, buffer: &mut Buffer) {
if self
.snapshot
@@ -70,10 +80,11 @@ impl RenderedSelection {
}
if let Some(snapshot) = &self.snapshot {
self.view.render(snapshot.area, buffer, &self.cells);
// Keep native hyperlinks and forced glyph widths from the original cells.
// Preserve the painted surface, native hyperlinks and forced glyph widths.
for position in snapshot.area.positions() {
buffer[position].set_symbol(snapshot[position].symbol());
buffer[position].diff_option = snapshot[position].diff_option;
buffer[position].bg = snapshot[position].bg;
}
}
}
@@ -134,7 +145,19 @@ impl RenderedSelection {
for position in area.positions() {
surface[position] = completed[position].clone();
}
self.cells = vec![Arc::new(PlainHistoryCell::new(surface_lines(&surface)))];
let layout = match self.source_layout.take() {
Some((source, layout, offset)) if source == area => {
self.cells.clear();
Some(layout.visible_plaintext(offset, area.height))
}
_ => {
self.cells =
vec![Arc::new(PlainHistoryCell::new(surface_lines(&surface)))];
None
}
};
self.view
.sync_live_layout(area.width, /*key*/ None, |_| layout);
self.view.render(area, &mut surface.clone(), &self.cells);
self.snapshot = Some(surface);
}
+2 -2
View File
@@ -43,7 +43,7 @@ use search::Search;
use select_mode::SelectMode;
use selection::Selection;
use snapshot::ViewSnapshot;
use text::TextLayout;
pub(crate) use text::TextLayout;
pub(crate) use bookmark::TranscriptBookmark;
pub(crate) use input::JumpTarget;
@@ -365,7 +365,7 @@ impl TranscriptView {
})
}
fn sync_live_layout(
pub(crate) fn sync_live_layout(
&mut self,
width: u16,
key: Option<ActiveCellTranscriptKey>,
+28 -4
View File
@@ -41,7 +41,7 @@ use logical::logical_lines;
use ratatui::text::Line;
/// One cell's logical text and its width-dependent display rows.
pub(super) struct TextLayout {
pub(crate) struct TextLayout {
pub(super) image: Option<std::sync::Arc<crate::transcript_image::TranscriptImage>>,
logical: Vec<LogicalLine>,
text: String,
@@ -127,11 +127,35 @@ impl TextLayout {
ranges
}
pub(super) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
pub(crate) fn new(lines: Vec<HyperlinkLine>, width: u16) -> Self {
let logical = logical_lines(&lines);
Self::from_logical(logical, width)
}
/// Freeze the exact painted rows of a plain-text viewport, including tab stops.
/// Trim the selectable source so word and keyboard selection stay inside the viewport.
pub(crate) fn visible_plaintext(mut self, start: usize, height: u16) -> Self {
if start == 0 && self.rows.len() <= usize::from(height) {
return self;
}
self.rows.truncate(start + usize::from(height));
self.rows.drain(..start.min(self.rows.len()));
let from = self.rows.first().map_or(0, |row| row.source.start);
let to = self.rows.last().map_or(from, |row| row.source.end);
self.text = self.text[from..to].to_owned();
let lines = self
.text
.split('\n')
.map(|line| line.to_owned().into())
.collect::<Vec<_>>();
self.logical = logical_lines(&lines);
for row in &mut self.rows {
row.source = row.source.start - from..row.source.end - from;
row.line_end = row.line_end.map(|end| end.min(to) - from);
}
self
}
/// Reflow the same displayed content revision, retaining styles, links and disclosure label.
pub(super) fn rewrap(&self, width: u16) -> Self {
let mut layout = Self::from_logical(self.logical.clone(), width);
@@ -292,7 +316,7 @@ impl TextLayout {
&self.text
}
pub(super) fn row_count(&self) -> usize {
pub(crate) fn row_count(&self) -> usize {
// Images occupy visual space, but have no searchable or selectable text rows.
self.text_row_count()
+ self
@@ -327,7 +351,7 @@ impl TextLayout {
}
/// Paint visible rows, including inherited styles on empty rows and trailing columns.
pub(super) fn render(&self, area: Rect, buf: &mut Buffer, start_row: usize) {
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer, start_row: usize) {
for (screen_row, row) in self.visible_rows(area, start_row) {
let mut row_area =
Rect::new(area.x, area.y + screen_row, area.width, /*height*/ 1);
@@ -61,6 +61,31 @@ fn copying_wrapped_text_preserves_only_hard_newlines() {
assert_eq!(view.selected_text(&cells).as_deref(), Some(expected.text()));
}
#[test]
fn scrolled_plaintext_selection_matches_painted_tab_and_word_boundaries() {
let layout = TextLayout::new(
vec![
"hidden".into(),
" A\tBCD".into(),
"These few words will unfortunately not wrap nicely.".into(),
],
/*width*/ 15,
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 15, /*height*/ 3,
);
let mut buffer = Buffer::empty(area);
layout.render(area, &mut buffer, /*start_row*/ 1);
assert_eq!(buffer[(8, 0)].symbol(), "B");
assert_eq!(buffer[(0, 2)].symbol(), "w");
let visible = layout.visible_plaintext(/*start*/ 1, area.height);
let start = visible.position_at(/*row*/ 0, /*column*/ 2);
let end = visible.position_at(/*row*/ 2, /*column*/ 4);
assert_eq!(&visible.text()[start..end], "A\tBCD\nThese few words will");
assert_eq!(visible.text(), " A\tBCD\nThese few words will");
}
#[test]
fn hit_testing_uses_graphemes_and_does_not_select_padding() {
let layout = TextLayout::new(vec!["a界e\u{301}👩‍💻".into()], /*width*/ 12);