mirror of
https://github.com/openai/codex.git
synced 2026-10-21 05:22:21 +00:00
Enable text selection in the fullscreen TUI footer (#52270)
## What changed Allow mouse selection and plain-text copying of rendered status and footer text, honoring `tui.copy_on_select` and existing selection keyboard controls. Keep the selected region stable while other content updates, preserve Unicode and terminal hyperlinks, and clear selections when their region becomes invalid. Defer warning clicks until a stationary mouse release so dragging selects text without opening the warning viewer. Preserve the composer draft and cursor while selecting footer text. ## Testing Add unit and snapshot coverage for visible-text copying, Unicode and hyperlinks, selection invalidation, keyboard ownership, and stationary clicks versus drags. Add a fullscreen integration test for warning selection and draft preservation. GitOrigin-RevId: dd14d7ede52e97e507be24c4d6ac0299f7388a9b
This commit is contained in:
@@ -777,14 +777,14 @@ pub enum RightClickPaste {
|
||||
Off,
|
||||
}
|
||||
|
||||
/// When transcript mouse selections are copied on release.
|
||||
/// When mouse selections are copied on release.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CopyOnSelect {
|
||||
/// Use the terminal-specific default.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Copy every nonempty transcript mouse selection on release.
|
||||
/// Copy every nonempty transcript or footer mouse selection on release.
|
||||
Always,
|
||||
/// Require an explicit copy action.
|
||||
Never,
|
||||
@@ -889,7 +889,7 @@ pub struct Tui {
|
||||
#[schemars(schema_with = "crate::tui_mouse_scroll::schema")]
|
||||
pub mouse_scroll_speed: Option<f64>,
|
||||
|
||||
/// Copy selected transcript text when the mouse button is released.
|
||||
/// Copy selected transcript or footer text when the mouse button is released.
|
||||
/// Defaults to `auto`: enabled except in direct terminals known to forward their native
|
||||
/// copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows).
|
||||
/// Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying.
|
||||
|
||||
@@ -1382,7 +1382,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"CopyOnSelect": {
|
||||
"description": "When transcript mouse selections are copied on release.",
|
||||
"description": "When mouse selections are copied on release.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Use the terminal-specific default.",
|
||||
@@ -1392,7 +1392,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Copy every nonempty transcript mouse selection on release.",
|
||||
"description": "Copy every nonempty transcript or footer mouse selection on release.",
|
||||
"enum": [
|
||||
"always"
|
||||
],
|
||||
@@ -4611,7 +4611,7 @@
|
||||
}
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "Copy selected transcript text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying."
|
||||
"description": "Copy selected transcript or footer text when the mouse button is released. Defaults to `auto`: enabled except in direct terminals known to forward their native copy shortcut (Ghostty 1.2+, Kitty on macOS, Windows Terminal, and VS Code on Windows). Unknown terminals, Ghostty without a recognized version, and tmux/Zellij default to copying."
|
||||
},
|
||||
"disable_paste_burst": {
|
||||
"description": "When true, disables burst-paste detection for typed input entirely. All characters are inserted as they are received, and no buffering or placeholder replacement will occur for fast keypress bursts. Overrides the legacy top-level `disable_paste_burst` setting. Defaults to `false`.",
|
||||
|
||||
@@ -238,6 +238,7 @@ mod new_session;
|
||||
mod turn_tips;
|
||||
pub(crate) use new_session::has_launch_setting;
|
||||
mod clipboard;
|
||||
mod footer_selection;
|
||||
mod native_history;
|
||||
mod owned_transcript;
|
||||
mod pending_interactive_replay;
|
||||
@@ -855,6 +856,9 @@ impl App {
|
||||
self.finish_clipboard(tui, &event);
|
||||
let event = self.finish_right_click_paste(tui, event);
|
||||
let idle_draw = matches!(event, TuiEvent::Draw);
|
||||
if self.handle_rendered_selection_event(tui, &event)? {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if matches!(&event, TuiEvent::Key(_))
|
||||
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
|
||||
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
|
||||
|
||||
@@ -39,6 +39,12 @@ impl App {
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&completion.1, characters);
|
||||
}
|
||||
{
|
||||
let mut selection = self.chat_widget.rendered_selection.borrow_mut();
|
||||
let crate::rendered_selection::RenderedSelection { view, cells, .. } =
|
||||
&mut *selection;
|
||||
view.finish_copy(cells, completion, current);
|
||||
}
|
||||
let follow =
|
||||
self.transcript_view
|
||||
.finish_copy(&self.transcript_cells, completion, current);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Route registered read-only selections before copy, interrupt, clicks, and paste shortcuts.
|
||||
//! A stationary click is replayed to the existing widget handler after selection releases it.
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::rendered_selection::RegionAction;
|
||||
use crate::rendered_selection::RenderedSelection;
|
||||
use crate::transcript_view::ViewAction;
|
||||
|
||||
impl App {
|
||||
pub(super) fn handle_rendered_selection_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
event: &TuiEvent,
|
||||
) -> Result<bool> {
|
||||
if !tui.is_owned_screen()
|
||||
|| self.overlay.is_some()
|
||||
|| !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);
|
||||
}
|
||||
let refresh = matches!(event, TuiEvent::Mouse(mouse)
|
||||
if self.chat_widget.rendered_selection.borrow().needs_refresh(*mouse));
|
||||
if refresh {
|
||||
let size = tui.prepare_draw_size()?;
|
||||
self.render_owned_transcript(tui, size)?;
|
||||
}
|
||||
let action = {
|
||||
let mut selection = self.chat_widget.rendered_selection.borrow_mut();
|
||||
selection.view.copy_on_select = self
|
||||
.local_settings
|
||||
.copy_on_select(&codex_terminal_detection::terminal_info());
|
||||
selection.view.primary_selection = self.right_click_paste_environment.primary;
|
||||
selection.handle_event(event, tui.terminal.previous_buffer())
|
||||
};
|
||||
let Some(action) = action else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.cancel_pending_key_chord();
|
||||
match action {
|
||||
RegionAction::Click(mouse) => {
|
||||
self.chat_widget
|
||||
.handle_warning_event(&TuiEvent::Mouse(mouse), &self.transcript_cells);
|
||||
}
|
||||
RegionAction::Selection(action) => {
|
||||
self.transcript_view.end_selection(&self.transcript_cells);
|
||||
self.transcript_view.cancel_search();
|
||||
self.transcript_view.clear_activity_focus();
|
||||
self.chat_widget.clear_composer_selection();
|
||||
let copied = {
|
||||
let mut selection = self.chat_widget.rendered_selection.borrow_mut();
|
||||
let RenderedSelection { view, cells, .. } = &mut *selection;
|
||||
view.copy_action(tui, cells, &action, CopyFormat::PlainText)
|
||||
};
|
||||
if let Some((characters, result)) = copied {
|
||||
self.transcript_view.show_copy_feedback(&result, characters);
|
||||
} else if let ViewAction::OpenLink(url) = action {
|
||||
self.open_url_in_browser(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,7 @@ impl App {
|
||||
self.native_history = Default::default();
|
||||
self.cancel_pending_key_chord();
|
||||
self.transcript_view = Default::default();
|
||||
*self.chat_widget.rendered_selection.borrow_mut() = Default::default();
|
||||
self.last_rendered_history_tail = None;
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
|
||||
@@ -87,7 +87,21 @@ impl App {
|
||||
};
|
||||
self.sync_owned_transcript(screen_size.width);
|
||||
let transcript_width = self.chat_widget.history_wrap_width(screen_size.width);
|
||||
let composer_hint = self.composer_hint(transcript_width);
|
||||
let composer_hint = if self
|
||||
.chat_widget
|
||||
.rendered_selection
|
||||
.borrow()
|
||||
.view
|
||||
.has_active_interaction()
|
||||
{
|
||||
Some(
|
||||
ratatui::text::Line::from("Selecting footer · esc cancel")
|
||||
.dim()
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
self.composer_hint(transcript_width)
|
||||
};
|
||||
let now = Instant::now();
|
||||
let turn_tip = self.turn_tip(transcript_width, now, &tui.frame_requester());
|
||||
let working_tip = turn_tip
|
||||
@@ -160,6 +174,7 @@ impl App {
|
||||
let mut blossom_tick = None;
|
||||
let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none()));
|
||||
tui.draw(screen_size.height, |frame| {
|
||||
chat_widget.rendered_selection.borrow_mut().areas.clear();
|
||||
ratatui::widgets::Clear.render(
|
||||
Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available),
|
||||
frame.buffer,
|
||||
@@ -270,6 +285,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()
|
||||
.render(frame.buffer);
|
||||
chat_widget.note_rendered_width(screen_size.width);
|
||||
let dialog = chat_widget.centered_dialog();
|
||||
let (foreground, foreground_area): (&dyn Renderable, Rect) =
|
||||
@@ -576,27 +595,15 @@ impl App {
|
||||
}
|
||||
return self.handle_owned_backtrack_event(tui, event);
|
||||
};
|
||||
let resume_following = matches!(action, ViewAction::CopyAndFollow(_));
|
||||
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
|
||||
match action {
|
||||
ViewAction::Changed => {}
|
||||
ViewAction::PrimarySelection(text) => self.transcript_view.publish_primary(tui, &text),
|
||||
ViewAction::Copy(text)
|
||||
| ViewAction::CopyOnSelect(text)
|
||||
| ViewAction::CopyAndFollow(text) => {
|
||||
let result = self.transcript_view.copy_selected_text(
|
||||
tui,
|
||||
&self.transcript_cells,
|
||||
&text,
|
||||
!copy_on_select,
|
||||
);
|
||||
if resume_following
|
||||
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
|
||||
{
|
||||
self.transcript_view.follow_pending_copy();
|
||||
}
|
||||
}
|
||||
ViewAction::OpenLink(url) => self.open_url_in_browser(url),
|
||||
if let Some((characters, result)) = self.transcript_view.copy_action(
|
||||
tui,
|
||||
&self.transcript_cells,
|
||||
&action,
|
||||
crate::clipboard_copy::CopyFormat::Markdown,
|
||||
) {
|
||||
self.transcript_view.show_copy_feedback(&result, characters);
|
||||
} else if let ViewAction::OpenLink(url) = action {
|
||||
self.open_url_in_browser(url);
|
||||
}
|
||||
self.request_owned_history(tui, app_server);
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
||||
@@ -1780,6 +1780,72 @@ async fn fullscreen_composer_remote_images_leave_an_editable_prompt_row() -> Res
|
||||
"fullscreen_prompt_remote_images",
|
||||
normalize_snapshot_paths(frames.join("\n\n"))
|
||||
);
|
||||
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn footer_selection_defers_warning_clicks_and_preserves_the_draft() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.local_settings.tui.copy_on_select = CopyOnSelect::Never;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
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())),
|
||||
);
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
let draft = app.chat_widget.capture_thread_input_state();
|
||||
let cursor = tui.terminal.last_known_cursor_pos;
|
||||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
let badge = buffer
|
||||
.area
|
||||
.positions()
|
||||
.find(|position| buffer[*position].symbol() == "⚠")
|
||||
.unwrap();
|
||||
for event in [
|
||||
mouse(Down(Left), badge.x, badge.y),
|
||||
mouse(Drag(Left), badge.x + 11, badge.y),
|
||||
mouse(Up(Left), badge.x + 11, badge.y),
|
||||
] {
|
||||
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
|
||||
assert!(!app.chat_widget.keymap_contexts().is_warnings());
|
||||
}
|
||||
app.render_owned_transcript(&mut tui, size)?;
|
||||
assert_eq!(
|
||||
(
|
||||
app.chat_widget.capture_thread_input_state(),
|
||||
tui.terminal.last_known_cursor_pos
|
||||
),
|
||||
(draft, cursor)
|
||||
);
|
||||
{
|
||||
let mut selection = app.chat_widget.rendered_selection.borrow_mut();
|
||||
let crate::rendered_selection::RenderedSelection { view, cells, .. } = &mut *selection;
|
||||
assert!(view.selected_text(cells).unwrap().contains("warning"));
|
||||
}
|
||||
insta::assert_snapshot!(
|
||||
"fullscreen_footer_selection",
|
||||
normalize_snapshot_paths(buffer_text(
|
||||
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)
|
||||
))
|
||||
);
|
||||
assert!(app.handle_rendered_selection_event(
|
||||
&mut tui,
|
||||
&TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
|
||||
)?);
|
||||
// Dismissal consumes the first Escape, and a stationary click still opens the warning viewer.
|
||||
for event in [
|
||||
mouse(Down(Left), badge.x + 2, badge.y),
|
||||
mouse(Up(Left), badge.x + 2, badge.y),
|
||||
] {
|
||||
assert!(app.handle_rendered_selection_event(&mut tui, &event)?);
|
||||
}
|
||||
assert!(app.chat_widget.keymap_contexts().is_warnings());
|
||||
tui.set_owned_screen(/*owned*/ false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ impl App {
|
||||
let allowed = match source {
|
||||
PasteSource::Clipboard => {
|
||||
!self.transcript_view.has_selection_range()
|
||||
&& !self
|
||||
.chat_widget
|
||||
.rendered_selection
|
||||
.borrow()
|
||||
.view
|
||||
.has_selection_range()
|
||||
&& self
|
||||
.right_click_paste_environment
|
||||
.allows(self.local_settings.tui.right_click_paste)
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: tui/src/app/owned_transcript_tests.rs
|
||||
assertion_line: 1545
|
||||
expression: "normalize_snapshot_paths(buffer_text(crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal)))"
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Selecting footer · esc cancel
|
||||
|
||||
› draft stays here
|
||||
|
||||
GPT-5.6-Sol default · /tmp/project
|
||||
⚠ 1 warning · f2 to view
|
||||
@@ -248,7 +248,8 @@
|
||||
//! Submission flushes expired characters and buffers before classifying Enter, independent of
|
||||
//! UI flush ticks. `disable_paste_burst` bypasses detection; setting it flushes and clears in-flight state.
|
||||
//! Mouse edits flush pending typing; selection and copy behavior lives in [`mouse`]. Confirmed
|
||||
//! copies clear the selection while preserving the draft and cursor.
|
||||
//! copies clear the selection while preserving the draft and cursor. Footer text uses a separate
|
||||
//! read-only selection owner over its final rendered cells.
|
||||
//!
|
||||
//! See `codex-rs/tui/src/bottom_pane/paste_burst.rs` for the detailed state machine.
|
||||
//!
|
||||
@@ -752,6 +753,7 @@ impl ChatComposer {
|
||||
external_editor_key: default_keymap
|
||||
.primary_hint(KeymapContext::Global, "open_external_editor"),
|
||||
warning_notice_area: std::cell::Cell::default(),
|
||||
selection_revision: std::cell::Cell::default(),
|
||||
show_warnings_key: default_keymap
|
||||
.primary_hint(KeymapContext::Global, "open_warnings"),
|
||||
show_transcript_key: default_keymap
|
||||
@@ -4800,6 +4802,16 @@ impl ChatComposer {
|
||||
if let Some((warning_area, line)) = warning_notice {
|
||||
line.render(warning_area, buf);
|
||||
}
|
||||
if let Some(selection) = options.rendered_selection
|
||||
&& !self.popup_active()
|
||||
&& options.footer.is_none_or(|footer| !footer.is_interactive)
|
||||
{
|
||||
let revision = (options.warning_count, self.footer_props().is_task_running);
|
||||
let changed = self.footer.selection_revision.replace(revision) != revision;
|
||||
let mut selection = selection.borrow_mut();
|
||||
selection.register(status.intersection(buf.area), changed);
|
||||
selection.register(footer_rect.intersection(buf.area), changed);
|
||||
}
|
||||
let style = user_message_style();
|
||||
Block::default().style(style).render(composer_rect, buf);
|
||||
if !remote_images_rect.is_empty() {
|
||||
|
||||
@@ -70,6 +70,8 @@ pub(crate) enum CommandPopupPlacement {
|
||||
pub(crate) struct ComposerRenderOptions<'a> {
|
||||
/// Fullscreen limits the composer independently of activity and modal views.
|
||||
pub(crate) max_height: Option<u16>,
|
||||
pub(crate) rendered_selection:
|
||||
Option<&'a std::cell::RefCell<crate::rendered_selection::RenderedSelection>>,
|
||||
/// Reserve a shared hint row independently of whether it currently contains a notice.
|
||||
pub(crate) composer_gap: Option<&'a crate::bottom_pane::ComposerGap>,
|
||||
pub(crate) working_tip: Option<&'a crate::turn_tip::TurnTip>,
|
||||
@@ -365,6 +367,7 @@ pub(super) struct FooterState {
|
||||
pub(super) show_transcript_key: Option<ShortcutHint>,
|
||||
pub(super) show_warnings_key: Option<ShortcutHint>,
|
||||
pub(super) warning_notice_area: std::cell::Cell<Option<Rect>>,
|
||||
pub(super) selection_revision: std::cell::Cell<(usize, bool)>,
|
||||
pub(super) find_transcript_key: Option<ShortcutHint>,
|
||||
pub(super) focus_activity_key: Option<ShortcutHint>,
|
||||
pub(super) insert_newline_key: Option<ShortcutHint>,
|
||||
|
||||
@@ -62,6 +62,10 @@ impl ChatComposer {
|
||||
Some((char_count, result))
|
||||
}
|
||||
|
||||
pub(crate) fn clear_mouse_selection(&mut self) {
|
||||
self.draft.textarea.set_cursor(self.draft.textarea.cursor());
|
||||
}
|
||||
|
||||
pub(crate) fn end_mouse_drag(&mut self) {
|
||||
self.draft.textarea.end_mouse_drag();
|
||||
}
|
||||
|
||||
@@ -1802,6 +1802,10 @@ impl BottomPane {
|
||||
self.can_launch_external_editor()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_composer_selection(&mut self) {
|
||||
self.composer.clear_mouse_selection();
|
||||
}
|
||||
|
||||
pub(crate) fn end_composer_drag(&mut self) {
|
||||
self.composer.end_mouse_drag();
|
||||
}
|
||||
|
||||
@@ -585,6 +585,7 @@ pub(crate) struct ChatWidget {
|
||||
// Remember the account's Reserve entry notice across chats and transient banner refreshes.
|
||||
luna_reserve_notice_account_id: Option<String>,
|
||||
pub(crate) warning_display_state: WarningDisplayState,
|
||||
pub(crate) rendered_selection: std::cell::RefCell<crate::rendered_selection::RenderedSelection>,
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState,
|
||||
add_credits_nudge_email_in_flight: Option<rate_limits::PendingCreditsNudge>,
|
||||
adaptive_chunking: AdaptiveChunkingPolicy,
|
||||
|
||||
@@ -172,6 +172,7 @@ impl ChatWidget {
|
||||
backend_banner_notice_model: None,
|
||||
luna_reserve_notice_account_id: None,
|
||||
warning_display_state: WarningDisplayState::default(),
|
||||
rendered_selection: Default::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
add_credits_nudge_email_in_flight: None,
|
||||
adaptive_chunking: AdaptiveChunkingPolicy::default(),
|
||||
|
||||
@@ -15,6 +15,10 @@ pub(crate) enum KeyEventAction {
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn clear_composer_selection(&mut self) {
|
||||
self.bottom_pane.clear_composer_selection();
|
||||
}
|
||||
|
||||
pub(crate) fn end_composer_drag(&mut self) {
|
||||
self.bottom_pane.end_composer_drag();
|
||||
}
|
||||
|
||||
@@ -252,6 +252,7 @@ impl ChatWidget {
|
||||
} else {
|
||||
self.ambient_pet_wrap_reserved_cols()
|
||||
};
|
||||
options.rendered_selection = Some(&self.rendered_selection);
|
||||
options.warning_count = self.warning_display_state.count;
|
||||
options.textarea_right_reserve = right_reserve;
|
||||
options.separate_status_line = options.command_popup_placement
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Exercise the rendered-cell adapter; gesture and clipboard state machines have shared tests.
|
||||
|
||||
use super::*;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use crossterm::event::MouseButton::Left;
|
||||
use crossterm::event::MouseEventKind::Down;
|
||||
use crossterm::event::MouseEventKind::Drag;
|
||||
use crossterm::event::MouseEventKind::Up;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> TuiEvent {
|
||||
TuiEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_selection_preserves_unicode_links_and_the_selected_revision() {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 2, /*width*/ 30, /*height*/ 1,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
Line::from(vec![
|
||||
" ".into(),
|
||||
"café 界 👩💻".cyan().underlined(),
|
||||
" · 20%…".dim(),
|
||||
])
|
||||
.render(area, &mut buffer);
|
||||
crate::terminal_hyperlinks::mark_underlined_hyperlink(&mut buffer, area, "https://example.com");
|
||||
let original = buffer.clone();
|
||||
let mut selection = RenderedSelection::default();
|
||||
selection.areas.clear();
|
||||
selection.register(area, /*invalidate*/ false);
|
||||
selection.render(&mut buffer);
|
||||
for event in [
|
||||
mouse(Down(Left), /*column*/ 2, /*row*/ 2),
|
||||
mouse(Drag(Left), /*column*/ 12, /*row*/ 2),
|
||||
mouse(Up(Left), /*column*/ 12, /*row*/ 2),
|
||||
] {
|
||||
selection.handle_event(&event, &buffer);
|
||||
}
|
||||
assert_eq!(
|
||||
selection.view.selected_text(&selection.cells).as_deref(),
|
||||
Some("café 界 👩💻")
|
||||
);
|
||||
buffer.reset();
|
||||
Line::from(" changed · 40%").render(area, &mut buffer);
|
||||
selection.areas.clear();
|
||||
selection.register(area, /*invalidate*/ false);
|
||||
selection.render(&mut buffer);
|
||||
for position in area.positions() {
|
||||
assert_eq!(buffer[position].symbol(), original[position].symbol());
|
||||
}
|
||||
insta::assert_snapshot!(format!("{buffer:?}"));
|
||||
// A new warning must replace the held revision even while the turn keeps running.
|
||||
selection.register(area, /*invalidate*/ true);
|
||||
assert!(!selection.view.has_selection_range());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_copy_and_cross_surface_drags_use_only_visible_text() {
|
||||
let status = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 7, /*height*/ 1,
|
||||
);
|
||||
let footer = Rect { y: 1, ..status };
|
||||
let mut buffer = Buffer::empty(status.union(footer));
|
||||
"branch…".render(status, &mut buffer);
|
||||
"? shortcuts".render(footer, &mut buffer);
|
||||
let mut selection = RenderedSelection::default();
|
||||
selection.view.copy_on_select = true;
|
||||
selection.register(status, /*invalidate*/ false);
|
||||
selection.register(footer, /*invalidate*/ false);
|
||||
// Registration retains geometry only: select the final painted cells, not earlier content.
|
||||
"commit…".render(status, &mut buffer);
|
||||
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))) =
|
||||
selection.handle_event(&mouse(Up(Left), /*column*/ 19, /*row*/ 1), &buffer)
|
||||
else {
|
||||
panic!("release must copy the selected status row");
|
||||
};
|
||||
assert_eq!(text, "commit…");
|
||||
assert!(selection.view.has_selection_range());
|
||||
// Collapsing the range keeps keyboard ownership, including Enter, with the footer.
|
||||
for _ in text.chars() {
|
||||
selection.handle_event(&TuiEvent::Key(KeyCode::Left.into()), &buffer);
|
||||
}
|
||||
assert!(!selection.view.has_selection_range());
|
||||
assert!(matches!(
|
||||
selection.handle_event(&TuiEvent::Key(KeyCode::Enter.into()), &buffer),
|
||||
Some(RegionAction::Selection(ViewAction::Changed))
|
||||
));
|
||||
selection.handle_event(&TuiEvent::Key(KeyCode::Right.into()), &buffer);
|
||||
assert_eq!(
|
||||
selection.view.selected_text(&selection.cells).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
// Invalidating another visible region must not release this selection.
|
||||
selection.register(footer, /*invalidate*/ true);
|
||||
assert!(selection.view.has_active_interaction());
|
||||
// Find belongs to the app, not a hidden search inside the footer.
|
||||
assert!(
|
||||
selection
|
||||
.handle_event(
|
||||
&TuiEvent::Key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL)),
|
||||
&buffer
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(!selection.view.has_active_interaction());
|
||||
// Resize invalidates both the highlight and its pointer anchor.
|
||||
selection.handle_event(&mouse(Down(Left), /*column*/ 0, /*row*/ 1), &buffer);
|
||||
selection.handle_event(&mouse(Drag(Left), /*column*/ 3, /*row*/ 1), &buffer);
|
||||
selection.handle_event(
|
||||
&TuiEvent::Resize(ratatui::layout::Size::new(
|
||||
/*width*/ 10, /*height*/ 2,
|
||||
)),
|
||||
&buffer,
|
||||
);
|
||||
assert!(!selection.view.has_active_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_clicks_are_deferred_but_dragging_back_does_not_activate() {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 1,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
"⚠ 2 warnings".render(area, &mut buffer);
|
||||
let mut selection = RenderedSelection::default();
|
||||
selection.register(area, /*invalidate*/ false);
|
||||
assert!(matches!(
|
||||
selection.handle_event(&mouse(Down(Left), /*column*/ 2, /*row*/ 0), &buffer),
|
||||
Some(RegionAction::Selection(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
selection.handle_event(&mouse(Up(Left), /*column*/ 2, /*row*/ 0), &buffer),
|
||||
Some(RegionAction::Click(_))
|
||||
));
|
||||
selection.handle_event(&mouse(Down(Left), /*column*/ 5, /*row*/ 0), &buffer);
|
||||
selection.handle_event(&mouse(Drag(Left), /*column*/ 7, /*row*/ 0), &buffer);
|
||||
assert!(!matches!(
|
||||
selection.handle_event(&mouse(Up(Left), /*column*/ 5, /*row*/ 0), &buffer),
|
||||
Some(RegionAction::Click(_))
|
||||
));
|
||||
// A stationary click after a selection releases the frozen revision.
|
||||
for event in [
|
||||
mouse(Down(Left), /*column*/ 0, /*row*/ 0),
|
||||
mouse(Drag(Left), /*column*/ 3, /*row*/ 0),
|
||||
mouse(Up(Left), /*column*/ 3, /*row*/ 0),
|
||||
] {
|
||||
selection.handle_event(&event, &buffer);
|
||||
}
|
||||
assert!(selection.view.has_selection_range());
|
||||
selection.handle_event(&mouse(Down(Left), /*column*/ 10, /*row*/ 0), &buffer);
|
||||
assert!(matches!(
|
||||
selection.handle_event(&mouse(Up(Left), /*column*/ 10, /*row*/ 0), &buffer),
|
||||
Some(RegionAction::Click(_))
|
||||
));
|
||||
assert!(!selection.view.has_active_interaction());
|
||||
}
|
||||
@@ -173,6 +173,7 @@ mod keymap_setup;
|
||||
mod line_truncation;
|
||||
pub(crate) mod live_wrap;
|
||||
mod local_settings;
|
||||
mod rendered_selection;
|
||||
pub use live_wrap::RowBuilder;
|
||||
mod local_chatgpt_auth;
|
||||
mod managed_new_thread_defaults;
|
||||
|
||||
@@ -452,32 +452,20 @@ impl TranscriptOverlay {
|
||||
|
||||
fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) {
|
||||
self.notice = None;
|
||||
let resume_following = matches!(action, ViewAction::CopyAndFollow(_));
|
||||
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
|
||||
match action {
|
||||
ViewAction::Changed => {}
|
||||
ViewAction::PrimarySelection(text) => self.view.publish_primary(tui, &text),
|
||||
ViewAction::Copy(text)
|
||||
| ViewAction::CopyOnSelect(text)
|
||||
| ViewAction::CopyAndFollow(text) => {
|
||||
let result = self
|
||||
.view
|
||||
.copy_selected_text(tui, &self.cells, &text, !copy_on_select);
|
||||
if resume_following
|
||||
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
|
||||
{
|
||||
self.view.follow_pending_copy();
|
||||
}
|
||||
self.notice = Some(match result {
|
||||
Ok(status) => status.message("selection"),
|
||||
Err(error) => error,
|
||||
});
|
||||
}
|
||||
ViewAction::OpenLink(url) => {
|
||||
if let Err(error) = webbrowser::open(&url) {
|
||||
self.notice = Some(format!("Could not open link: {error}"));
|
||||
}
|
||||
}
|
||||
if let Some((_, result)) = self.view.copy_action(
|
||||
tui,
|
||||
&self.cells,
|
||||
&action,
|
||||
crate::clipboard_copy::CopyFormat::Markdown,
|
||||
) {
|
||||
self.notice = Some(match result {
|
||||
Ok(status) => status.message("selection"),
|
||||
Err(error) => error,
|
||||
});
|
||||
} else if let ViewAction::OpenLink(url) = action
|
||||
&& let Err(error) = webbrowser::open(&url)
|
||||
{
|
||||
self.notice = Some(format!("Could not open link: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Read-only selection for regions registered by the last owned-screen render.
|
||||
//! Widgets register painted rectangles, and may invalidate their own region when its meaning
|
||||
//! changes. Only the clicked rectangle is captured from the completed terminal frame; other
|
||||
//! regions remain live. Gestures, keyboard selection and clipboard tickets use TranscriptView.
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::transcript_view::TranscriptView;
|
||||
use crate::transcript_view::ViewAction;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::CellWidth;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RenderedSelection {
|
||||
pub(crate) view: TranscriptView,
|
||||
pub(crate) cells: Vec<Arc<dyn HistoryCell>>,
|
||||
pub(crate) areas: Vec<Rect>,
|
||||
snapshot: Option<Buffer>,
|
||||
pressed: Option<MouseEvent>,
|
||||
}
|
||||
|
||||
pub(crate) enum RegionAction {
|
||||
Selection(ViewAction),
|
||||
// Replay clickable decorations only after a stationary release.
|
||||
Click(MouseEvent),
|
||||
}
|
||||
|
||||
impl RenderedSelection {
|
||||
fn clear(&mut self) {
|
||||
self.view.jump_to_latest();
|
||||
self.snapshot = None;
|
||||
self.pressed = None;
|
||||
}
|
||||
|
||||
/// Register a painted, passive region. Invalidation affects only a selection in this region.
|
||||
pub(crate) fn register(&mut self, area: Rect, invalidate: bool) {
|
||||
if invalidate
|
||||
&& self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.area == area)
|
||||
{
|
||||
self.clear();
|
||||
}
|
||||
if !area.is_empty() {
|
||||
self.areas.push(area);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, buffer: &mut Buffer) {
|
||||
if self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| !self.areas.contains(&snapshot.area))
|
||||
{
|
||||
self.clear();
|
||||
}
|
||||
if !self.view.has_active_interaction() {
|
||||
self.snapshot = None;
|
||||
}
|
||||
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.
|
||||
for position in snapshot.area.positions() {
|
||||
buffer[position].set_symbol(snapshot[position].symbol());
|
||||
buffer[position].diff_option = snapshot[position].diff_option;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn needs_refresh(&self, event: MouseEvent) -> bool {
|
||||
event.kind != MouseEventKind::Moved
|
||||
&& self
|
||||
.areas
|
||||
.iter()
|
||||
.any(|area| area.contains(Position::new(event.column, event.row)))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(
|
||||
&mut self,
|
||||
event: &TuiEvent,
|
||||
completed: &Buffer,
|
||||
) -> Option<RegionAction> {
|
||||
match event {
|
||||
TuiEvent::Mouse(mouse) => return self.handle_mouse(*mouse, completed),
|
||||
TuiEvent::Key(key) if key.kind == KeyEventKind::Release => return None,
|
||||
TuiEvent::Key(key) if self.view.has_active_interaction() => {
|
||||
if let Some(action) = self.view.handle_selection_key(*key, &self.cells) {
|
||||
return Some(RegionAction::Selection(action));
|
||||
}
|
||||
}
|
||||
TuiEvent::Key(_)
|
||||
| TuiEvent::Paste(_)
|
||||
| TuiEvent::Resize(_)
|
||||
| TuiEvent::FocusLost
|
||||
| TuiEvent::Resume => {}
|
||||
TuiEvent::Draw | TuiEvent::FocusGained => return None,
|
||||
}
|
||||
self.clear();
|
||||
None
|
||||
}
|
||||
|
||||
fn handle_mouse(&mut self, mut event: MouseEvent, completed: &Buffer) -> Option<RegionAction> {
|
||||
match event.kind {
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
let position = Position::new(event.column, event.row);
|
||||
let Some(area) = self
|
||||
.areas
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|area| area.contains(position))
|
||||
else {
|
||||
self.clear();
|
||||
return None;
|
||||
};
|
||||
if self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_none_or(|snapshot| snapshot.area != area)
|
||||
{
|
||||
self.view.jump_to_latest();
|
||||
let area = area.intersection(completed.area);
|
||||
let mut surface = Buffer::empty(area);
|
||||
for position in area.positions() {
|
||||
surface[position] = completed[position].clone();
|
||||
}
|
||||
self.cells = vec![Arc::new(PlainHistoryCell::new(surface_lines(&surface)))];
|
||||
self.view.render(area, &mut surface.clone(), &self.cells);
|
||||
self.snapshot = Some(surface);
|
||||
}
|
||||
self.pressed = Some(event);
|
||||
}
|
||||
MouseEventKind::Drag(MouseButton::Left) => self.pressed = None,
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
if let Some(pressed) = self.pressed.take()
|
||||
&& (pressed.column, pressed.row) == (event.column, event.row)
|
||||
&& !self.view.has_selection_range()
|
||||
{
|
||||
self.view.end_selection(&self.cells);
|
||||
return Some(RegionAction::Click(pressed));
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
|
||||
self.clear();
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let snapshot = self.snapshot.as_ref()?;
|
||||
if matches!(event.kind, MouseEventKind::Drag(_) | MouseEventKind::Up(_)) {
|
||||
event.column = event.column.clamp(snapshot.area.x, snapshot.area.right());
|
||||
event.row = event.row.clamp(snapshot.area.y, snapshot.area.bottom() - 1);
|
||||
}
|
||||
self.view
|
||||
.handle_mouse(event, &self.cells)
|
||||
.map(RegionAction::Selection)
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_lines(surface: &Buffer) -> Vec<Line<'static>> {
|
||||
(surface.area.y..surface.area.bottom())
|
||||
.map(|row| {
|
||||
let mut spans = Vec::new();
|
||||
let mut column = surface.area.x;
|
||||
while column < surface.area.right() {
|
||||
let cell = &surface[(column, row)];
|
||||
spans.push(Span::styled(
|
||||
codex_ansi_escape::ansi_escape_line(cell.symbol()).to_string(),
|
||||
cell.style(),
|
||||
));
|
||||
column += cell.cell_width().max(/*other*/ 1);
|
||||
}
|
||||
while spans.last().is_some_and(|span| span.content == " ") {
|
||||
spans.pop();
|
||||
}
|
||||
Line::from(spans)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "footer_selection_tests.rs"]
|
||||
mod tests;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/footer_selection_tests.rs
|
||||
assertion_line: 38
|
||||
expression: "format!(\"{buffer:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 2, width: 30, height: 1 },
|
||||
content: [
|
||||
" ]8;;https://example.comc]8;;]8;;https://example.coma]8;;]8;;https://example.comf]8;;]8;;https://example.comé]8;; ]8;;https://example.com界]8;; ]8;;https://example.com👩💻]8;; · 20%… ", // hidden by multi-width symbols: [(8, " "), (11, " ")]
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: UNDERLINED | REVERSED,
|
||||
x: 8, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
|
||||
x: 9, y: 0, fg: Cyan, bg: Reset, underline: Reset, modifier: UNDERLINED | REVERSED,
|
||||
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
|
||||
x: 12, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 21, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -345,7 +345,7 @@ impl TranscriptView {
|
||||
Some(ViewAction::Changed)
|
||||
}
|
||||
|
||||
fn handle_selection_key(
|
||||
pub(crate) fn handle_selection_key(
|
||||
&mut self,
|
||||
key: KeyEvent,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
|
||||
@@ -284,38 +284,6 @@ impl TranscriptView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Both transcript surfaces honor the same automatic-copy and plaintext PRIMARY semantics.
|
||||
pub(crate) fn copy_selected_text(
|
||||
&mut self,
|
||||
tui: &mut Tui,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
text: &str,
|
||||
clear_selection: bool,
|
||||
) -> CopyResult {
|
||||
let publish_primary = !clear_selection && self.primary_selection;
|
||||
let mut primary_owner = None;
|
||||
let result =
|
||||
self.copy_selected_text_with(cells, text, clear_selection, |copy_text, format| {
|
||||
if publish_primary {
|
||||
let (result, owner) = tui.clipboard.select(
|
||||
copy_text.into(),
|
||||
format,
|
||||
CopyDestination::ClipboardAndPrimary(text.into()),
|
||||
tui.frame_requester(),
|
||||
);
|
||||
primary_owner = Some(owner);
|
||||
result
|
||||
} else {
|
||||
tui.clipboard
|
||||
.copy(copy_text.into(), format, tui.frame_requester())
|
||||
}
|
||||
});
|
||||
if publish_primary && let Some(selection) = &mut self.selection {
|
||||
selection.primary_owner = primary_owner;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Hide or cancel deferred publication without relinquishing the existing X11 selection.
|
||||
pub(crate) fn cancel_primary(&mut self) {
|
||||
if let Some(selection) = &mut self.selection {
|
||||
@@ -405,6 +373,66 @@ impl TranscriptView {
|
||||
result
|
||||
}
|
||||
|
||||
/// Apply selection copy keys consistently in owned, overlay and rendered-region views.
|
||||
/// Plain UI regions keep their visible text; transcript copies retain Markdown metadata.
|
||||
/// PRIMARY is always plaintext, and automatic copies update both X11 selections.
|
||||
pub(crate) fn copy_action(
|
||||
&mut self,
|
||||
tui: &mut Tui,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
action: &ViewAction,
|
||||
format: crate::clipboard_copy::CopyFormat,
|
||||
) -> Option<(usize, CopyResult)> {
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
|
||||
let text = match action {
|
||||
ViewAction::Copy(text)
|
||||
| ViewAction::CopyOnSelect(text)
|
||||
| ViewAction::CopyAndFollow(text) => text.as_str(),
|
||||
ViewAction::PrimarySelection(text) => {
|
||||
self.publish_primary(tui, text);
|
||||
return None;
|
||||
}
|
||||
ViewAction::Changed | ViewAction::OpenLink(_) => return None,
|
||||
};
|
||||
let clear_selection = !matches!(action, ViewAction::CopyOnSelect(_));
|
||||
let publish_primary = !clear_selection && self.primary_selection;
|
||||
let mut primary_owner = None;
|
||||
let result =
|
||||
self.copy_selected_text_with(cells, text, clear_selection, |rich, rich_format| {
|
||||
let (copy_text, format) = match format {
|
||||
CopyFormat::PlainText => (text, CopyFormat::PlainText),
|
||||
CopyFormat::Markdown => (rich, rich_format),
|
||||
CopyFormat::MarkdownSelection(source) => {
|
||||
(text, CopyFormat::MarkdownSelection(source))
|
||||
}
|
||||
};
|
||||
if publish_primary {
|
||||
let (result, owner) = tui.clipboard.select(
|
||||
copy_text.into(),
|
||||
format,
|
||||
CopyDestination::ClipboardAndPrimary(text.into()),
|
||||
tui.frame_requester(),
|
||||
);
|
||||
primary_owner = Some(owner);
|
||||
result
|
||||
} else {
|
||||
tui.clipboard
|
||||
.copy(copy_text.into(), format, tui.frame_requester())
|
||||
}
|
||||
});
|
||||
if publish_primary && let Some(selection) = &mut self.selection {
|
||||
selection.primary_owner = primary_owner;
|
||||
}
|
||||
if matches!(action, ViewAction::CopyAndFollow(_))
|
||||
&& matches!(result, Ok(CopyStatus::Pending(_)))
|
||||
{
|
||||
self.follow_pending_copy();
|
||||
}
|
||||
Some((text.chars().count(), result))
|
||||
}
|
||||
|
||||
pub(crate) fn follow_pending_copy(&mut self) {
|
||||
if let Some(pending) = self
|
||||
.selection
|
||||
|
||||
Reference in New Issue
Block a user