mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user