mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Wire keyboard selection into transcript overlays (#57223)
## What changed - Add `tui.keymap.global.select_transcript` with an implicit `leader [` shortcut that follows the configured leader, respects explicit remapping or unbinding, and yields to existing bindings. - Connect transcript overlays to keyboard selection, including Find, mouse selection, and navigation to the beginning of paged history. Give selection commands priority over remapped pager shortcuts. - Close selection overlays on exit or after a confirmed copy-and-follow operation, preserving the pending close decision while clipboard confirmation is asynchronous. - Show selection navigation and copy hints that adapt to the available footer width. ## Testing Add coverage for shortcut dispatch, remapping, unbinding, and conflicts; overlay exit behavior; selection across history paging; mouse and keyboard selection interaction; and cursor and footer rendering snapshots. GitOrigin-RevId: 8a920e5fa2b06ff0544d829ba30f8ecb654db0ee
This commit is contained in:
@@ -141,6 +141,8 @@ pub struct TuiGlobalKeymap {
|
||||
pub open_transcript: Option<KeybindingsSpec>,
|
||||
/// Find text in the full transcript.
|
||||
pub find_transcript: Option<KeybindingsSpec>,
|
||||
/// Enter keyboard selection mode for the transcript.
|
||||
pub select_transcript: Option<KeybindingsSpec>,
|
||||
/// Focus activity groups in the owned transcript to inspect their details.
|
||||
pub focus_activity: Option<KeybindingsSpec>,
|
||||
/// Open the external editor for the current draft.
|
||||
|
||||
@@ -5152,6 +5152,7 @@
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"select_transcript": null,
|
||||
"submit": null,
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
@@ -6007,6 +6008,14 @@
|
||||
],
|
||||
"description": "Queue the current composer draft while a task is running."
|
||||
},
|
||||
"select_transcript": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Enter keyboard selection mode for the transcript."
|
||||
},
|
||||
"submit": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6176,6 +6185,7 @@
|
||||
"open_external_editor": null,
|
||||
"open_transcript": null,
|
||||
"queue": null,
|
||||
"select_transcript": null,
|
||||
"submit": null,
|
||||
"toggle_fast_mode": null,
|
||||
"toggle_raw_output": null,
|
||||
|
||||
@@ -50,6 +50,10 @@ mod voice_tests;
|
||||
#[path = "keymap/global_find_tests.rs"]
|
||||
mod global_find_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "keymap/select_transcript_tests.rs"]
|
||||
mod select_transcript_tests;
|
||||
|
||||
pub(crate) use bindings::KeymapActionId;
|
||||
pub(crate) use bindings::KeymapContext;
|
||||
pub(crate) use bindings::bindings_for_action;
|
||||
@@ -102,6 +106,8 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) open_transcript: Vec<KeyBinding>,
|
||||
/// Find text in the full transcript.
|
||||
pub(crate) find_transcript: Vec<KeyBinding>,
|
||||
/// Enter keyboard selection mode for the transcript.
|
||||
pub(crate) select_transcript: Vec<KeyBinding>,
|
||||
/// Focus activity groups in the owned transcript to inspect their details.
|
||||
pub(crate) focus_activity: Vec<KeyBinding>,
|
||||
/// Open retained warnings without replacing the composer.
|
||||
@@ -723,6 +729,11 @@ impl RuntimeKeymap {
|
||||
&defaults.app.find_transcript,
|
||||
"tui.keymap.global.find_transcript",
|
||||
)?,
|
||||
select_transcript: resolve_bindings(
|
||||
keymap.global.select_transcript.as_ref(),
|
||||
&defaults.app.select_transcript,
|
||||
"tui.keymap.global.select_transcript",
|
||||
)?,
|
||||
focus_activity: resolve_bindings(
|
||||
keymap.global.focus_activity.as_ref(),
|
||||
&focus_activity_defaults,
|
||||
@@ -1456,6 +1467,10 @@ impl RuntimeKeymap {
|
||||
keymap.global.find_transcript.as_ref(),
|
||||
app.find_transcript.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.select_transcript.as_ref(),
|
||||
app.select_transcript.as_slice(),
|
||||
),
|
||||
(
|
||||
keymap.global.focus_activity.as_ref(),
|
||||
app.focus_activity.as_slice(),
|
||||
@@ -1647,7 +1662,7 @@ impl RuntimeKeymap {
|
||||
}
|
||||
}
|
||||
resolved.configure_search(keymap)?;
|
||||
chords::quick_dial_defaults::register(keymap, &mut resolved);
|
||||
chords::leader_defaults::register(keymap, &mut resolved);
|
||||
resolved.validate_conflicts()?;
|
||||
chords::validate_chord_conflicts(&resolved)?;
|
||||
chords::install_dispatch_bindings(&mut resolved)?;
|
||||
@@ -1676,6 +1691,7 @@ impl RuntimeKeymap {
|
||||
open_agents: default_bindings![],
|
||||
open_transcript: default_bindings![ctrl(KeyCode::Char('t'))],
|
||||
find_transcript: default_bindings![plain(KeyCode::F(3))],
|
||||
select_transcript: default_bindings![],
|
||||
focus_activity: default_bindings![plain(KeyCode::F(4))],
|
||||
open_warnings: default_bindings![plain(KeyCode::F(2))],
|
||||
open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))],
|
||||
@@ -2052,6 +2068,7 @@ impl RuntimeKeymap {
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("select_transcript", self.app.select_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
("open_warnings", self.app.open_warnings.as_slice()),
|
||||
(
|
||||
@@ -2159,6 +2176,7 @@ impl RuntimeKeymap {
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("select_transcript", self.app.select_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
("open_warnings", self.app.open_warnings.as_slice()),
|
||||
(
|
||||
@@ -2218,6 +2236,7 @@ impl RuntimeKeymap {
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
("select_transcript", self.app.select_transcript.as_slice()),
|
||||
("focus_activity", self.app.focus_activity.as_slice()),
|
||||
("open_warnings", self.app.open_warnings.as_slice()),
|
||||
(
|
||||
@@ -2336,6 +2355,30 @@ impl RuntimeKeymap {
|
||||
|
||||
validate_unique("pager", context_bindings(KeymapContext::Pager))?;
|
||||
|
||||
validate_unique(
|
||||
"transcript",
|
||||
[
|
||||
(
|
||||
"global.select_transcript",
|
||||
self.app.select_transcript.as_slice(),
|
||||
),
|
||||
("pager.scroll_up", self.pager.scroll_up.as_slice()),
|
||||
("pager.scroll_down", self.pager.scroll_down.as_slice()),
|
||||
("pager.page_up", self.pager.page_up.as_slice()),
|
||||
("pager.page_down", self.pager.page_down.as_slice()),
|
||||
("pager.half_page_up", self.pager.half_page_up.as_slice()),
|
||||
("pager.half_page_down", self.pager.half_page_down.as_slice()),
|
||||
("pager.jump_top", self.pager.jump_top.as_slice()),
|
||||
("pager.jump_bottom", self.pager.jump_bottom.as_slice()),
|
||||
("pager.close", self.pager.close.as_slice()),
|
||||
(
|
||||
"pager.close_transcript",
|
||||
self.pager.close_transcript.as_slice(),
|
||||
),
|
||||
("pager.find", self.pager.find.as_slice()),
|
||||
],
|
||||
)?;
|
||||
|
||||
validate_no_reserved(
|
||||
"pager",
|
||||
context_bindings(KeymapContext::Pager),
|
||||
@@ -2352,6 +2395,10 @@ impl RuntimeKeymap {
|
||||
"global.find_transcript",
|
||||
self.app.find_transcript.as_slice(),
|
||||
),
|
||||
(
|
||||
"global.select_transcript",
|
||||
self.app.select_transcript.as_slice(),
|
||||
),
|
||||
]),
|
||||
)?;
|
||||
|
||||
@@ -4140,9 +4187,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
(
|
||||
runtime.composer.history_search_previous,
|
||||
runtime.chat.open_presets
|
||||
runtime.chat.open_presets,
|
||||
runtime.app.select_transcript
|
||||
),
|
||||
(vec![key_hint::ctrl(KeyCode::Char('r'))], Vec::new())
|
||||
(
|
||||
vec![key_hint::ctrl(KeyCode::Char('r'))],
|
||||
Vec::new(),
|
||||
Vec::new()
|
||||
)
|
||||
);
|
||||
let defaults = RuntimeKeymap::defaults();
|
||||
for leader in ["page-up", "page-down"] {
|
||||
@@ -4153,11 +4205,13 @@ mod tests {
|
||||
(
|
||||
runtime.pager.page_up,
|
||||
runtime.pager.page_down,
|
||||
runtime.chat.open_presets
|
||||
runtime.chat.open_presets,
|
||||
runtime.app.select_transcript
|
||||
),
|
||||
(
|
||||
defaults.pager.page_up.clone(),
|
||||
defaults.pager.page_down.clone(),
|
||||
Vec::new(),
|
||||
Vec::new()
|
||||
)
|
||||
);
|
||||
|
||||
@@ -114,9 +114,15 @@ impl KeymapActionId {
|
||||
(Chat, List) => self.action != "open_presets",
|
||||
(List, Chat) => other.action != "open_presets",
|
||||
|
||||
// Activity focus and Find remain available during list navigation.
|
||||
(Global, List) => matches!(self.action, "focus_activity" | "find_transcript"),
|
||||
(List, Global) => matches!(other.action, "focus_activity" | "find_transcript"),
|
||||
// Activity focus, Find, and selection remain available during list navigation.
|
||||
(Global, List) => matches!(
|
||||
self.action,
|
||||
"focus_activity" | "find_transcript" | "select_transcript"
|
||||
),
|
||||
(List, Global) => matches!(
|
||||
other.action,
|
||||
"focus_activity" | "find_transcript" | "select_transcript"
|
||||
),
|
||||
|
||||
_ => self.context.overlaps(other.context),
|
||||
}
|
||||
@@ -281,6 +287,7 @@ define_runtime_action_bindings! {
|
||||
open_agents,
|
||||
open_transcript,
|
||||
find_transcript,
|
||||
select_transcript,
|
||||
focus_activity,
|
||||
open_warnings => runtime_only,
|
||||
open_external_editor,
|
||||
|
||||
@@ -34,7 +34,7 @@ const FIRST_DISPATCH_FUNCTION_KEY: u8 = codex_config::types::MAX_FUNCTION_KEY +
|
||||
const LAST_DISPATCH_FUNCTION_KEY: u8 = u8::MAX;
|
||||
const MAX_LEADER_CHORD_BINDINGS: usize = 1024;
|
||||
|
||||
pub(super) mod quick_dial_defaults;
|
||||
pub(super) mod leader_defaults;
|
||||
const LIST_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[
|
||||
("cancel", ctrl(KeyCode::Char('c'))),
|
||||
("resume_picker.toggle_transcript", ctrl(KeyCode::Char('t'))),
|
||||
@@ -52,6 +52,7 @@ const WARNINGS_FOCUS: u32 = 1 << 16;
|
||||
const VOICE_TOGGLE: u32 = 1 << 17;
|
||||
const RAW_KEY_CAPTURE: u32 = 1 << 18;
|
||||
const VOICE_TOGGLE_BLOCKED: u32 = 1 << 19;
|
||||
const TRANSCRIPT_ACTIONS: u32 = 1 << 20;
|
||||
|
||||
const TRANSCRIPT_BROWSING: u32 = 1 << 14;
|
||||
|
||||
@@ -81,6 +82,15 @@ impl KeymapContextSet {
|
||||
)
|
||||
}
|
||||
|
||||
/// Transcript overlays admit pager bindings plus the explicit selection and Find actions.
|
||||
pub(crate) const fn transcript() -> Self {
|
||||
Self(
|
||||
context_bit(KeymapContext::Pager)
|
||||
| context_bit(KeymapContext::Global)
|
||||
| TRANSCRIPT_ACTIONS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Warning pages admit navigation plus their own toggle and copy.
|
||||
pub(crate) const fn warnings() -> Self {
|
||||
Self(context_bit(KeymapContext::List) | context_bit(KeymapContext::Global) | WARNINGS_FOCUS)
|
||||
@@ -154,13 +164,19 @@ impl KeymapContextSet {
|
||||
self.contains(action.context)
|
||||
&& (self.0 & ACTIVITY_FOCUS == 0
|
||||
|| action.context != KeymapContext::Global
|
||||
|| matches!(action.action, "focus_activity" | "find_transcript"))
|
||||
|| matches!(
|
||||
action.action,
|
||||
"focus_activity" | "find_transcript" | "select_transcript"
|
||||
))
|
||||
&& (self.0 & TRANSCRIPT_CLOSE == 0
|
||||
|| action.context != KeymapContext::Pager
|
||||
|| action.action == "close_transcript")
|
||||
&& (self.0 & TRANSCRIPT_BROWSING == 0
|
||||
|| action.context != KeymapContext::Global
|
||||
|| action.action == "open_transcript")
|
||||
|| matches!(action.action, "open_transcript" | "select_transcript"))
|
||||
&& (self.0 & TRANSCRIPT_ACTIONS == 0
|
||||
|| action.context != KeymapContext::Global
|
||||
|| matches!(action.action, "find_transcript" | "select_transcript"))
|
||||
&& (!self.is_warnings()
|
||||
|| action.context != KeymapContext::List
|
||||
|| action.action != "accept")
|
||||
@@ -214,9 +230,17 @@ impl RuntimeChordBinding {
|
||||
self.spec.starts_with("leader ")
|
||||
}
|
||||
|
||||
/// Leader consumers also remain active beside pager actions on native transcript surfaces.
|
||||
/// Transcript selection remains active beside pager actions on native transcript surfaces.
|
||||
fn overlaps(&self, action: KeymapActionId) -> bool {
|
||||
self.action.overlaps(action)
|
||||
|| (matches!(
|
||||
(self.action.context, self.action.action),
|
||||
(KeymapContext::Global, "select_transcript")
|
||||
) || matches!(
|
||||
(action.context, action.action),
|
||||
(KeymapContext::Global, "select_transcript")
|
||||
)) && KeymapContextSet::transcript().contains_action(self.action)
|
||||
&& KeymapContextSet::transcript().contains_action(action)
|
||||
|| self.is_leader()
|
||||
&& [
|
||||
KeymapContextSet::browsing(),
|
||||
@@ -644,7 +668,7 @@ fn dispatch_binding(action: KeymapActionId) -> Option<KeyBinding> {
|
||||
))
|
||||
}
|
||||
|
||||
fn dispatch_event(action: KeymapActionId) -> Option<KeyEvent> {
|
||||
pub(crate) fn dispatch_event(action: KeymapActionId) -> Option<KeyEvent> {
|
||||
let (code, modifiers) = dispatch_binding(action)?.parts();
|
||||
Some(KeyEvent::new(code, modifiers))
|
||||
}
|
||||
@@ -745,6 +769,19 @@ Unbind or remap the existing shortcut before using it as a chord prefix.",
|
||||
if previous.action != binding.action
|
||||
&& (previous.overlaps(binding.action) || binding.overlaps(previous.action))
|
||||
&& previous.chord == binding.chord
|
||||
&& !matches!(
|
||||
(
|
||||
(previous.action.context, previous.action.action),
|
||||
(binding.action.context, binding.action.action),
|
||||
),
|
||||
(
|
||||
(KeymapContext::Global, "find_transcript"),
|
||||
(KeymapContext::Pager, "find")
|
||||
) | (
|
||||
(KeymapContext::Pager, "find"),
|
||||
(KeymapContext::Global, "find_transcript")
|
||||
)
|
||||
)
|
||||
{
|
||||
return Err(format!(
|
||||
"Ambiguous `{}` = `{}`: the same chord is already assigned to `{}`. \
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Install implicit preset and selection shortcuts only for free leader chords.
|
||||
//! Explicit bindings, including an explicit unbind, always take priority.
|
||||
//! Implicit chords fit within the shared leader expansion limit.
|
||||
|
||||
use super::KeyChord;
|
||||
use super::KeymapActionId;
|
||||
use super::KeymapContext;
|
||||
use super::MAX_LEADER_CHORD_BINDINGS;
|
||||
use super::RuntimeChordBinding;
|
||||
use super::RuntimeKeymap;
|
||||
use super::normalize_chord_binding;
|
||||
use super::runtime_action_bindings;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
|
||||
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
|
||||
resolved
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.is_leader())
|
||||
.count(),
|
||||
);
|
||||
for (configured, context, action, completion, spec) in [
|
||||
(
|
||||
keymap.global.select_transcript.as_ref(),
|
||||
KeymapContext::Global,
|
||||
"select_transcript",
|
||||
'[',
|
||||
"leader [",
|
||||
),
|
||||
(
|
||||
keymap.chat.open_presets.as_ref(),
|
||||
KeymapContext::Chat,
|
||||
"open_presets",
|
||||
'p',
|
||||
"leader p",
|
||||
),
|
||||
] {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
if configured.is_some() {
|
||||
continue;
|
||||
}
|
||||
let action = KeymapActionId { context, action };
|
||||
for prefix in resolved.chords.leader.clone() {
|
||||
let binding = RuntimeChordBinding {
|
||||
action,
|
||||
chord: KeyChord {
|
||||
prefix,
|
||||
completion: crate::key_hint::plain(KeyCode::Char(completion)),
|
||||
},
|
||||
spec: spec.into(),
|
||||
};
|
||||
if !resolved.chords.bindings.iter().any(|existing| {
|
||||
binding.overlaps(existing.action) && existing.chord == binding.chord
|
||||
}) && !runtime_action_bindings(resolved)
|
||||
// Owned transcript navigation also runs beside the composer.
|
||||
.filter(|single| {
|
||||
binding.overlaps(single.id) || single.id.context == KeymapContext::Pager
|
||||
})
|
||||
.any(|single| {
|
||||
single
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|key| normalize_chord_binding(*key) == prefix)
|
||||
})
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut resolved.chords)
|
||||
.bindings
|
||||
.push(binding);
|
||||
remaining -= 1;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//! Install the implicit preset shortcut only for free leader chords.
|
||||
//! Explicit bindings, including an explicit unbind, always take priority.
|
||||
//! Implicit chords fit within the shared leader expansion limit.
|
||||
|
||||
use super::KeyChord;
|
||||
use super::KeymapActionId;
|
||||
use super::KeymapContext;
|
||||
use super::MAX_LEADER_CHORD_BINDINGS;
|
||||
use super::RuntimeChordBinding;
|
||||
use super::RuntimeKeymap;
|
||||
use super::normalize_chord_binding;
|
||||
use super::runtime_action_bindings;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
pub(in crate::keymap) fn register(keymap: &TuiKeymap, resolved: &mut RuntimeKeymap) {
|
||||
let mut remaining = MAX_LEADER_CHORD_BINDINGS.saturating_sub(
|
||||
resolved
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.filter(|binding| binding.is_leader())
|
||||
.count(),
|
||||
);
|
||||
if keymap.chat.open_presets.is_some() || remaining == 0 {
|
||||
return;
|
||||
}
|
||||
let action = KeymapActionId {
|
||||
context: KeymapContext::Chat,
|
||||
action: "open_presets",
|
||||
};
|
||||
for prefix in resolved.chords.leader.clone() {
|
||||
let binding = RuntimeChordBinding {
|
||||
action,
|
||||
chord: KeyChord {
|
||||
prefix,
|
||||
completion: crate::key_hint::plain(KeyCode::Char('p')),
|
||||
},
|
||||
spec: "leader p".into(),
|
||||
};
|
||||
if !resolved
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|existing| binding.overlaps(existing.action) && existing.chord == binding.chord)
|
||||
&& !runtime_action_bindings(resolved)
|
||||
// Owned transcript navigation also runs beside the composer.
|
||||
.filter(|single| {
|
||||
binding.overlaps(single.id) || single.id.context == KeymapContext::Pager
|
||||
})
|
||||
.any(|single| {
|
||||
single
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|key| normalize_chord_binding(*key) == prefix)
|
||||
})
|
||||
{
|
||||
std::sync::Arc::make_mut(&mut resolved.chords)
|
||||
.bindings
|
||||
.push(binding);
|
||||
remaining -= 1;
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved.chat.chord_hints = std::sync::Arc::clone(&resolved.chords);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::*;
|
||||
use codex_config::types::KeybindingSpec;
|
||||
use codex_config::types::KeybindingsSpec;
|
||||
use codex_config::types::TuiKeymap;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
fn binding(spec: &str) -> KeybindingsSpec {
|
||||
KeybindingsSpec::One(KeybindingSpec(spec.to_string()))
|
||||
}
|
||||
|
||||
fn dispatch_select(
|
||||
runtime: &RuntimeKeymap,
|
||||
prefix: crossterm::event::KeyEvent,
|
||||
contexts: KeymapContextSet,
|
||||
) -> bool {
|
||||
let mut matcher = KeyChordMatcher::default();
|
||||
assert!(matches!(
|
||||
matcher.advance(prefix, &runtime.chords, contexts),
|
||||
KeyChordMatch::Pending(_)
|
||||
));
|
||||
let KeyChordMatch::Completed(key) =
|
||||
matcher.advance(KeyCode::Char('[').into(), &runtime.chords, contexts)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
runtime.app.select_transcript.is_pressed(key)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_transcript_uses_the_shared_leader_in_main_activity_and_overlay_contexts() {
|
||||
let runtime = RuntimeKeymap::defaults();
|
||||
for contexts in [
|
||||
KeymapContextSet::new(KeymapContext::Global),
|
||||
KeymapContextSet::activity(),
|
||||
KeymapContextSet::transcript(),
|
||||
] {
|
||||
assert!(dispatch_select(
|
||||
&runtime,
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
contexts
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_transcript_can_be_remapped_or_unbound() {
|
||||
let mut config = TuiKeymap::default();
|
||||
config.global.leader = Some(binding("f6"));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("remapped leader");
|
||||
assert!(dispatch_select(
|
||||
&runtime,
|
||||
KeyCode::F(6).into(),
|
||||
KeymapContextSet::new(KeymapContext::Global)
|
||||
));
|
||||
|
||||
config.global.select_transcript = Some(KeybindingsSpec::Many(Vec::new()));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("unbound selection action");
|
||||
assert!(runtime.app.select_transcript.is_empty());
|
||||
assert!(
|
||||
runtime
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.all(|binding| binding.action.action != "select_transcript")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_select_suffix_yields_but_explicit_conflicts_fail() {
|
||||
let mut config = TuiKeymap::default();
|
||||
config.global.open_transcript = Some(binding("leader ["));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("existing leader consumer wins");
|
||||
assert!(
|
||||
runtime
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.all(|binding| binding.action.action != "select_transcript")
|
||||
);
|
||||
|
||||
config.global.open_transcript = None;
|
||||
config.pager.scroll_up = Some(binding("ctrl-x"));
|
||||
let runtime = RuntimeKeymap::from_config(&config).expect("leader prefix consumer wins");
|
||||
assert!(
|
||||
runtime
|
||||
.chords
|
||||
.bindings
|
||||
.iter()
|
||||
.all(|binding| binding.action.action != "select_transcript")
|
||||
);
|
||||
|
||||
config.pager.scroll_up = None;
|
||||
config.global.open_transcript = Some(binding("leader ["));
|
||||
config.global.select_transcript = Some(binding("leader ["));
|
||||
let error = RuntimeKeymap::from_config(&config).expect_err("explicit conflict");
|
||||
assert!(error.contains("select_transcript") && error.contains("open_transcript"));
|
||||
}
|
||||
@@ -26,6 +26,10 @@ pub(crate) struct TranscriptOverlay {
|
||||
notice: Option<String>,
|
||||
pub(crate) key_chord_hint: Option<Vec<(String, String)>>,
|
||||
pub(crate) browsing_footer: Option<Line<'static>>,
|
||||
find_transcript: Vec<crate::key_hint::KeyBinding>,
|
||||
select_transcript: Vec<crate::key_hint::KeyBinding>,
|
||||
pub(super) pending_follow_close: Option<(u64, bool)>,
|
||||
pending_select_mode: bool,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
@@ -63,6 +67,10 @@ impl TranscriptOverlay {
|
||||
notice: None,
|
||||
key_chord_hint: None,
|
||||
browsing_footer: None,
|
||||
find_transcript: Vec::new(),
|
||||
select_transcript: Vec::new(),
|
||||
pending_follow_close: None,
|
||||
pending_select_mode: false,
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -81,6 +89,14 @@ impl TranscriptOverlay {
|
||||
content_height,
|
||||
);
|
||||
self.view.render(self.content_area, buf, &self.cells);
|
||||
if self.pending_select_mode {
|
||||
self.pending_select_mode = false;
|
||||
if self.view.begin_select_mode(&self.cells) {
|
||||
self.view.render(self.content_area, buf, &self.cells);
|
||||
} else {
|
||||
self.is_done = true;
|
||||
}
|
||||
}
|
||||
if let Some(index) = self.pending_highlight.take() {
|
||||
self.view.ensure_entry_visible(&self.cells, index);
|
||||
self.view.render(self.content_area, buf, &self.cells);
|
||||
@@ -175,26 +191,15 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
if let Some(completion) = tui.clipboard.poll()
|
||||
&& let Some(follow) =
|
||||
self.view
|
||||
.finish_copy(&self.cells, completion, /*current*/ true)
|
||||
{
|
||||
self.notice = Some(match &completion.1 {
|
||||
Ok(status) => status.message("selection"),
|
||||
Err(error) => format!("Copy failed: {error}"),
|
||||
});
|
||||
if follow {
|
||||
self.view.jump_to_latest();
|
||||
self.is_done = self.browsing_footer.is_some();
|
||||
}
|
||||
if let Some(completion) = tui.clipboard.poll() {
|
||||
self.finish_pending_copy(completion);
|
||||
}
|
||||
if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) {
|
||||
self.view.cancel_primary();
|
||||
self.view.end_drag();
|
||||
}
|
||||
// Apply a queued prompt jump before navigation can move away from it.
|
||||
if self.pending_highlight.is_some()
|
||||
if (self.pending_select_mode || self.pending_highlight.is_some())
|
||||
&& (matches!(&event, TuiEvent::Key(key) if key.kind != KeyEventKind::Release)
|
||||
|| matches!(&event, TuiEvent::Mouse(mouse) if mouse.kind != MouseEventKind::Moved))
|
||||
{
|
||||
@@ -213,6 +218,9 @@ impl TranscriptOverlay {
|
||||
self.view.handle_mouse(mouse, &self.cells)
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
if self.view.is_select_mode() && !self.view.is_search_editing() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.view.is_search_editing() {
|
||||
self.view.end_selection(&self.cells);
|
||||
}
|
||||
@@ -244,6 +252,9 @@ impl TranscriptOverlay {
|
||||
}
|
||||
|
||||
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.find_transcript.clone_from(&keymap.app.find_transcript);
|
||||
self.select_transcript
|
||||
.clone_from(&keymap.app.select_transcript);
|
||||
self.view.set_keymap_bindings(keymap);
|
||||
}
|
||||
|
||||
@@ -251,6 +262,10 @@ impl TranscriptOverlay {
|
||||
self.view.begin_search();
|
||||
}
|
||||
|
||||
pub(crate) fn begin_select_mode(&mut self) {
|
||||
self.pending_select_mode = !self.view.begin_select_mode(&self.cells);
|
||||
}
|
||||
|
||||
pub(crate) fn is_search_editing(&self) -> bool {
|
||||
self.view.is_search_editing()
|
||||
}
|
||||
@@ -279,7 +294,11 @@ impl TranscriptOverlay {
|
||||
if previous == TranscriptHistoryState::LoadingBeginning
|
||||
&& state == TranscriptHistoryState::Complete
|
||||
{
|
||||
self.view.jump_to_entry(&self.cells, /*index*/ 0);
|
||||
if self.view.is_select_mode() {
|
||||
self.view.complete_select_beginning(&self.cells);
|
||||
} else {
|
||||
self.view.jump_to_entry(&self.cells, /*index*/ 0);
|
||||
}
|
||||
}
|
||||
previous
|
||||
}
|
||||
@@ -421,12 +440,40 @@ impl TranscriptOverlay {
|
||||
return None;
|
||||
}
|
||||
if !self.view.is_search_editing()
|
||||
&& !self.view.is_select_mode()
|
||||
&& (!self.view.has_active_interaction() || !self.view.owns_interaction_key(key))
|
||||
&& self.keymap.find.is_pressed(key)
|
||||
{
|
||||
self.view.begin_search();
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if !self.view.is_search_editing()
|
||||
&& !self.view.is_select_mode()
|
||||
&& self.find_transcript.is_pressed(key)
|
||||
{
|
||||
self.view.begin_search();
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if !self.view.is_search_editing()
|
||||
&& !self.view.is_select_mode()
|
||||
&& self.select_transcript.is_pressed(key)
|
||||
{
|
||||
self.begin_select_mode();
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if self.view.is_select_mode() && !self.view.is_search_editing() {
|
||||
if let Some(action) = self.view.handle_select_mode_key(key, &self.cells) {
|
||||
return Some(action);
|
||||
}
|
||||
if self.keymap.find.is_pressed(key) || self.find_transcript.is_pressed(key) {
|
||||
self.view.begin_search();
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key) {
|
||||
self.is_done = true;
|
||||
}
|
||||
return Some(ViewAction::Changed);
|
||||
}
|
||||
if self.view.has_active_interaction()
|
||||
&& let Some(action) = self.view.handle_key(key, &self.cells)
|
||||
{
|
||||
@@ -446,14 +493,41 @@ impl TranscriptOverlay {
|
||||
self.view.cancel_beginning();
|
||||
}
|
||||
|
||||
fn finish_select_follow(&mut self, close_overlay: bool) {
|
||||
self.view.jump_to_latest();
|
||||
if close_overlay {
|
||||
self.is_done = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_action(&mut self, tui: &mut tui::Tui, action: ViewAction) {
|
||||
self.notice = None;
|
||||
if let Some((_, result)) = self.view.copy_action(
|
||||
let resume_following = matches!(action, ViewAction::CopyAndFollow(_));
|
||||
let close_overlay = self.view.is_select_mode() || self.browsing_footer.is_some();
|
||||
if matches!(action, ViewAction::ExitSelectMode) {
|
||||
self.is_done = true;
|
||||
} else if let Some((_, result)) = self.view.copy_action(
|
||||
tui,
|
||||
&self.cells,
|
||||
&action,
|
||||
crate::clipboard_copy::CopyFormat::Markdown,
|
||||
) {
|
||||
if resume_following {
|
||||
match result {
|
||||
Ok(crate::clipboard_copy::CopyStatus::Confirmed) => {
|
||||
self.pending_follow_close = None;
|
||||
self.finish_select_follow(close_overlay);
|
||||
}
|
||||
Ok(crate::clipboard_copy::CopyStatus::Pending(id)) => {
|
||||
self.pending_follow_close = Some((id, close_overlay));
|
||||
}
|
||||
Ok(
|
||||
crate::clipboard_copy::CopyStatus::Unconfirmed
|
||||
| crate::clipboard_copy::CopyStatus::Busy,
|
||||
)
|
||||
| Err(_) => {}
|
||||
}
|
||||
}
|
||||
self.notice = Some(match result {
|
||||
Ok(status) => status.message("selection"),
|
||||
Err(error) => error,
|
||||
@@ -465,6 +539,35 @@ impl TranscriptOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_pending_copy(
|
||||
&mut self,
|
||||
completion: &(u64, crate::clipboard_copy::worker::CopyResult),
|
||||
) {
|
||||
let close_overlay = match self.pending_follow_close {
|
||||
Some((id, close_overlay)) if id == completion.0 => {
|
||||
self.pending_follow_close = None;
|
||||
Some(close_overlay)
|
||||
}
|
||||
Some((id, _)) if id < completion.0 => {
|
||||
self.pending_follow_close = None;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(follow) = self
|
||||
.view
|
||||
.finish_copy(&self.cells, completion, /*current*/ true)
|
||||
{
|
||||
self.notice = Some(match &completion.1 {
|
||||
Ok(status) => status.message("selection"),
|
||||
Err(error) => format!("Copy failed: {error}"),
|
||||
});
|
||||
if follow {
|
||||
self.finish_select_follow(close_overlay.unwrap_or(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
let first = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area);
|
||||
render_navigation_hints(first, buf, &self.keymap);
|
||||
|
||||
@@ -28,6 +28,87 @@ fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
|
||||
TranscriptOverlay::new(cells, default_pager_keymap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pending_select_mode_closes_instead_of_retaining_overlay_focus() {
|
||||
let mut overlay = transcript_overlay(Vec::new());
|
||||
overlay.begin_select_mode();
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
|
||||
);
|
||||
|
||||
overlay.render(area, &mut Buffer::empty(area));
|
||||
|
||||
assert!(overlay.is_done());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn select_enter_closes_and_remapped_action_marks_in_the_inline_overlay() -> Result<()> {
|
||||
let cells = || {
|
||||
vec![Arc::new(TestCell {
|
||||
lines: vec!["copy me".into()],
|
||||
}) as Arc<dyn HistoryCell>]
|
||||
};
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 8,
|
||||
);
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
let mut remapped = transcript_overlay(cells());
|
||||
let config = serde_json::from_value(serde_json::json!({
|
||||
"global": {"find_transcript": "v"}
|
||||
}))?;
|
||||
let keymap =
|
||||
crate::keymap::RuntimeKeymap::from_config(&config).expect("configured select shortcut");
|
||||
remapped.set_keymap_bindings(&keymap);
|
||||
remapped.render(area, &mut Buffer::empty(area));
|
||||
remapped.begin_select_mode();
|
||||
remapped.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?;
|
||||
assert_eq!(
|
||||
remapped.view.selected_text(&remapped.cells).as_deref(),
|
||||
Some("c")
|
||||
);
|
||||
|
||||
let mut find = transcript_overlay(cells());
|
||||
find.set_keymap_bindings(&keymap);
|
||||
find.render(area, &mut Buffer::empty(area));
|
||||
find.begin_search();
|
||||
find.handle_event(&mut tui, TuiEvent::Key(KeyCode::Char('v').into()))?;
|
||||
assert!(find.view.is_search_editing());
|
||||
assert!(!find.view.is_select_mode());
|
||||
|
||||
let mut enter = transcript_overlay(cells());
|
||||
let mut pager_find = keymap.clone();
|
||||
pager_find.pager.find = vec![crate::key_hint::KeyBinding::from_event(
|
||||
KeyCode::Enter.into(),
|
||||
)];
|
||||
enter.set_keymap_bindings(&pager_find);
|
||||
enter.render(area, &mut Buffer::empty(area));
|
||||
enter.begin_select_mode();
|
||||
enter.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?;
|
||||
assert!(enter.is_done());
|
||||
|
||||
let mut ordinary = transcript_overlay(vec![Arc::new(history_cell::AgentMarkdownCell::new(
|
||||
"copy me".to_string(),
|
||||
std::path::Path::new("/"),
|
||||
))]);
|
||||
ordinary.render(area, &mut Buffer::empty(area));
|
||||
ordinary.browsing_footer = Some("browsing prompts".into());
|
||||
assert!(
|
||||
ordinary
|
||||
.view
|
||||
.begin_copy_mode(&ordinary.cells, /*guard*/ None)
|
||||
);
|
||||
ordinary.handle_event(&mut tui, TuiEvent::Key(KeyCode::Enter.into()))?;
|
||||
if let Some((id, close_overlay)) = ordinary.pending_follow_close {
|
||||
assert!(close_overlay);
|
||||
ordinary.browsing_footer = None;
|
||||
ordinary.finish_pending_copy(&(id, Ok(crate::clipboard_copy::CopyStatus::Confirmed)));
|
||||
}
|
||||
assert!(ordinary.is_done());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_prepend_and_resize_preserve_reading_content() {
|
||||
let cells = (0..30)
|
||||
|
||||
@@ -48,7 +48,40 @@ impl TranscriptView {
|
||||
},
|
||||
]
|
||||
.into(),
|
||||
cursor_column: self.selection.is_none().then_some(cursor),
|
||||
cursor_column: Some(cursor),
|
||||
is_interactive: true,
|
||||
});
|
||||
}
|
||||
if self.select_mode.is_some() {
|
||||
let hints = if has_selected_text {
|
||||
let copy = crate::key_hint::ctrl(KeyCode::Char('c')).display_label();
|
||||
[
|
||||
format!(
|
||||
"arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/{copy} copy · enter copy & latest · esc clear · q close"
|
||||
),
|
||||
format!(
|
||||
"arrows/hjkl move · o swap · y/{copy} copy · enter copy & latest · esc clear · q close"
|
||||
),
|
||||
format!("y/{copy} copy · enter copy & latest · esc clear · q close"),
|
||||
"enter copy · q close".to_owned(),
|
||||
]
|
||||
} else {
|
||||
[
|
||||
"arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close"
|
||||
.to_owned(),
|
||||
"arrows/hjkl/w/b/e move · 0/$/g/G ends · v/space/V mark · / find · enter/q/esc close"
|
||||
.to_owned(),
|
||||
"arrows/hjkl move · v/space/V mark · / find · enter/q/esc close".to_owned(),
|
||||
"v mark · enter/q close".to_owned(),
|
||||
]
|
||||
};
|
||||
return Some(TranscriptFooter {
|
||||
text: first_fitting_line(
|
||||
hints.iter().map(String::as_str).map(navigation_line),
|
||||
width,
|
||||
)
|
||||
.into(),
|
||||
cursor_column: None,
|
||||
is_interactive: true,
|
||||
});
|
||||
}
|
||||
@@ -238,8 +271,15 @@ pub(super) fn navigation_line(navigation: &str) -> Line<'static> {
|
||||
}
|
||||
let action = [
|
||||
" copy & follow",
|
||||
" copy & latest",
|
||||
" clear selection",
|
||||
" copy+↓",
|
||||
" move",
|
||||
" ends",
|
||||
" page",
|
||||
" mark",
|
||||
" find",
|
||||
" swap",
|
||||
" previous",
|
||||
" accept",
|
||||
" cancel",
|
||||
|
||||
@@ -262,7 +262,25 @@ impl TranscriptView {
|
||||
event: MouseEvent,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
) -> Option<ViewAction> {
|
||||
self.handle_mouse_with_mode(event, cells, MouseMode::Selection)
|
||||
let endpoint_changed = matches!(
|
||||
event.kind,
|
||||
MouseEventKind::Down(MouseButton::Left)
|
||||
| MouseEventKind::Drag(MouseButton::Left)
|
||||
| MouseEventKind::Up(MouseButton::Left)
|
||||
);
|
||||
let before = self
|
||||
.selection
|
||||
.as_ref()
|
||||
.map(|selection| (selection.start, selection.end, selection.unit()));
|
||||
let action = self.handle_mouse_with_mode(event, cells, MouseMode::Selection);
|
||||
let after = self
|
||||
.selection
|
||||
.as_ref()
|
||||
.map(|selection| (selection.start, selection.end, selection.unit()));
|
||||
if endpoint_changed && before != after {
|
||||
self.adopt_select_mouse_range();
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
fn handle_mouse_with_mode(
|
||||
|
||||
@@ -92,7 +92,6 @@ impl SelectMode {
|
||||
|
||||
impl TranscriptView {
|
||||
/// Enter at the accepted Find hit, preferring the bottommost visible selectable position.
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "used by a later stack layer"))]
|
||||
pub(crate) fn begin_select_mode(&mut self, cells: &[Arc<dyn HistoryCell>]) -> bool {
|
||||
if self.select_mode.is_some() {
|
||||
return true;
|
||||
@@ -136,6 +135,17 @@ impl TranscriptView {
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn is_select_mode(&self) -> bool {
|
||||
self.select_mode.is_some()
|
||||
}
|
||||
|
||||
/// Finish an explicit beginning traversal after the last older page is installed.
|
||||
pub(crate) fn complete_select_beginning(&mut self, cells: &[Arc<dyn HistoryCell>]) {
|
||||
if self.select_mode.is_some() {
|
||||
self.move_select_cursor(cells, CursorMotion::Beginning);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the mode while retaining its displayed revision at the paused reading position.
|
||||
pub(crate) fn end_select_mode(&mut self) {
|
||||
if self.select_mode.take().is_none() {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyStatus;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crossterm::event::MouseButton;
|
||||
use crossterm::event::MouseEvent;
|
||||
use crossterm::event::MouseEventKind;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
fn cell(lines: &[&str]) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(PlainHistoryCell::new(
|
||||
@@ -608,3 +613,194 @@ fn accepted_find_pins_an_expanded_match_before_select_mode_exits() {
|
||||
expanded
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn beginning_motion_finishes_at_the_first_grapheme_after_paging() {
|
||||
let mut cells = vec![cell(&["newer"])];
|
||||
let mut view = TranscriptView {
|
||||
history: TranscriptHistoryState::Partial,
|
||||
..Default::default()
|
||||
};
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('g'));
|
||||
assert_eq!(view.history, TranscriptHistoryState::LoadingBeginning);
|
||||
|
||||
cells.insert(/*index*/ 0, cell(&["older"]));
|
||||
view.history_loaded(&cells, 0..1);
|
||||
view.history = TranscriptHistoryState::Complete;
|
||||
view.complete_select_beginning(&cells);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_extends_from_the_keyboard_marks_fixed_anchor() {
|
||||
let cells = vec![cell(&["abcdef"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('g'));
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("cde"));
|
||||
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_mouse_selection_keeps_its_word_or_line_unit() {
|
||||
let word_cells = vec![cell(&["alpha beta"])];
|
||||
let mut word_view = TranscriptView::default();
|
||||
render(&mut word_view, &word_cells);
|
||||
word_view.begin_selection(
|
||||
&word_cells,
|
||||
/*column*/ 1,
|
||||
/*row*/ 0,
|
||||
/*clicks*/ 2,
|
||||
);
|
||||
assert!(word_view.begin_select_mode(&word_cells));
|
||||
press(&mut word_view, &word_cells, KeyCode::Char('h'));
|
||||
assert_eq!(
|
||||
word_view.selected_text(&word_cells).as_deref(),
|
||||
Some(&"alpha"[..4])
|
||||
);
|
||||
|
||||
let line_cells = vec![cell(&["first", "second"])];
|
||||
let mut line_view = TranscriptView::default();
|
||||
render(&mut line_view, &line_cells);
|
||||
line_view.begin_selection(
|
||||
&line_cells,
|
||||
/*column*/ 1,
|
||||
/*row*/ 1,
|
||||
/*clicks*/ 3,
|
||||
);
|
||||
assert!(line_view.begin_select_mode(&line_cells));
|
||||
press(&mut line_view, &line_cells, KeyCode::Char('k'));
|
||||
assert_eq!(
|
||||
line_view.selected_text(&line_cells).as_deref(),
|
||||
Some("first\nsecond")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_mouse_events_do_not_reanchor_a_reverse_line_mark() {
|
||||
let cells = vec![cell(&["first", "second", "third"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
press(&mut view, &cells, KeyCode::Char('g'));
|
||||
press(&mut view, &cells, KeyCode::Char('j'));
|
||||
press(&mut view, &cells, KeyCode::Char('V'));
|
||||
press(&mut view, &cells, KeyCode::Char('k'));
|
||||
|
||||
for kind in [MouseEventKind::Moved, MouseEventKind::ScrollDown] {
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind,
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
}
|
||||
view.handle_mouse(
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 80,
|
||||
row: 80,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
&cells,
|
||||
);
|
||||
press(&mut view, &cells, KeyCode::Char('j'));
|
||||
|
||||
assert_eq!(view.selected_text(&cells).as_deref(), Some("second\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_mode_renders_cursor_and_scannable_footer_hints() {
|
||||
let cells = vec![cell(&["alpha beta", "gamma"])];
|
||||
let mut view = TranscriptView::default();
|
||||
render(&mut view, &cells);
|
||||
assert!(view.begin_select_mode(&cells));
|
||||
let buffer = render(&mut view, &cells);
|
||||
let [unmarked_wide, footer, unmarked_narrow] = [240, 64, 32].map(|width| {
|
||||
view.footer_with_navigation(
|
||||
width,
|
||||
crate::motion::MotionMode::Reduced,
|
||||
/*latest_navigation*/ "",
|
||||
)
|
||||
.expect("select footer")
|
||||
});
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
/*width*/ 64,
|
||||
footer.text.height() as u16,
|
||||
);
|
||||
let mut footer_buffer = Buffer::empty(area);
|
||||
Paragraph::new(footer.text).render(area, &mut footer_buffer);
|
||||
|
||||
press(&mut view, &cells, KeyCode::Char('g'));
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
for _ in 0..5 {
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
}
|
||||
let marked_buffer = render(&mut view, &cells);
|
||||
let [marked_wide, marked] = [240, 64].map(|width| {
|
||||
view.footer_with_navigation(
|
||||
width,
|
||||
crate::motion::MotionMode::Reduced,
|
||||
/*latest_navigation*/ "",
|
||||
)
|
||||
.expect("select footer")
|
||||
});
|
||||
|
||||
press(&mut view, &cells, KeyCode::Esc);
|
||||
press(&mut view, &cells, KeyCode::Char('/'));
|
||||
assert!(view.paste_search("beta"));
|
||||
while view.advance_search(&cells) {}
|
||||
let find_buffer = render(&mut view, &cells);
|
||||
let find_footer = view
|
||||
.footer_with_navigation(
|
||||
/*width*/ 64,
|
||||
crate::motion::MotionMode::Reduced,
|
||||
/*latest_navigation*/ "",
|
||||
)
|
||||
.expect("find footer");
|
||||
let find_footer_text = find_footer
|
||||
.text
|
||||
.to_string()
|
||||
.lines()
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
press(&mut view, &cells, KeyCode::Enter);
|
||||
press(&mut view, &cells, KeyCode::Char('v'));
|
||||
press(&mut view, &cells, KeyCode::Right);
|
||||
let find_marked_buffer = render(&mut view, &cells);
|
||||
|
||||
insta::assert_snapshot!(format!(
|
||||
"{buffer:?}\nunmarked wide: {}\nunmarked:\n{footer_buffer:?}\nunmarked narrow: {}\nmarked:\n{marked_buffer:?}\nmarked wide: {}\nmarked: {}\nfind:\n{find_buffer:?}\nfind footer: {}\nfind cursor: {:?}\nfind marked:\n{find_marked_buffer:?}",
|
||||
unmarked_wide.text,
|
||||
unmarked_narrow.text,
|
||||
marked_wide.text,
|
||||
marked.text,
|
||||
find_footer_text,
|
||||
find_footer.cursor_column,
|
||||
));
|
||||
}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
source: tui/src/transcript_view/select_mode_tests.rs
|
||||
assertion_line: 627
|
||||
expression: "format!(\"{buffer:?}\\nunmarked wide: {}\\nunmarked:\\n{footer_buffer:?}\\nunmarked narrow: {}\\nmarked:\\n{marked_buffer:?}\\nmarked wide: {}\\nmarked: {}\\nfind:\\n{find_buffer:?}\\nfind footer: {}\\nfind cursor: {:?}\\nfind marked:\\n{find_marked_buffer:?}\",\nunmarked_wide.text, unmarked_narrow.text, marked_wide.text, marked.text,\nfind_footer.text, find_footer.cursor_column,)"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 32, height: 6 },
|
||||
content: [
|
||||
"alpha beta ",
|
||||
"gamma ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
|
||||
x: 1, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
unmarked wide: arrows/hjkl/w/b/e move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V/shift+arrows mark · / find · n/N next · enter/q/esc close
|
||||
unmarked:
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 64, height: 1 },
|
||||
content: [
|
||||
"arrows/hjkl move · v/space/V mark · / find · enter/q/esc close ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 11, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 19, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 28, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 36, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 37, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 56, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 62, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
unmarked narrow: v mark · enter/q close
|
||||
marked:
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 32, height: 6 },
|
||||
content: [
|
||||
"alpha beta ",
|
||||
"gamma ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
|
||||
x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
|
||||
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
marked wide: arrows/hjkl/w/b/e/shift+arrows move · home/end/0/$/g/G/⌃home/⌃end ends · pgup/pgdn/⌃b/⌃f/⌃u/⌃d page · v/space/V mark · o swap · / find · y/⌃c copy · enter copy & latest · esc clear · q close
|
||||
marked: y/⌃c copy · enter copy & latest · esc clear · q close
|
||||
find:
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 32, height: 6 },
|
||||
content: [
|
||||
"alpha beta ",
|
||||
"gamma ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 5, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
|
||||
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
|
||||
x: 10, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
find footer: Find: beta
|
||||
enter accept · ⌃p older · ⌃n newer · esc cancel
|
||||
find cursor: Some(10)
|
||||
find marked:
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 32, height: 6 },
|
||||
content: [
|
||||
"alpha beta ",
|
||||
"gamma ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 6, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: REVERSED,
|
||||
x: 7, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD | UNDERLINED | REVERSED,
|
||||
x: 8, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user