The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
2899 lines
116 KiB
Rust
2899 lines
116 KiB
Rust
//! Scrollback text/block selection: click counting, word/line select,
|
|
//! drag latches, drag autoscroll ticks, and selection-highlight timers.
|
|
|
|
use super::{
|
|
AgentPane, AgentView, DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS, MULTI_CLICK_TIMEOUT_MS,
|
|
};
|
|
use crate::app::app_view::InputOutcome;
|
|
use crate::scrollback::table_geometry::{CellRef, TableGeometry};
|
|
use crate::scrollback::text_selection::{
|
|
ActiveBlockDrag, ActiveTextDrag, AutoScrollDirection, PendingBlockDrag, PendingTextDrag,
|
|
PersistentTextSelection, RangeHit, ResolvedSelectionModel, SelectionEndpoint, SelectionKind,
|
|
SelectionOrigin, TableSelectionGeometry, apply_selection_boundary,
|
|
block_drag_threshold_exceeded, compute_autoscroll, configured_word_separators,
|
|
drag_threshold_exceeded, reconstruct_full_selection_text_with_boundaries,
|
|
reconstruct_selection_text, reconstruct_selection_text_with_boundaries,
|
|
reconstruct_table_selection_text, resolve_table_drag_kind, url_range_at_col,
|
|
word_boundaries_at_col,
|
|
};
|
|
use crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX;
|
|
use crossterm::event::MouseEvent;
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Two fold/nav double-clicks on assistant text within this window count as a
|
|
/// repeated selection attempt and fire the word-select tip. Must be well over
|
|
/// [`MULTI_CLICK_TIMEOUT_MS`] so it measures separate gestures, and short
|
|
/// enough that the second gesture plausibly continues the first intent.
|
|
const WORD_SELECT_REPEAT_WINDOW: Duration = Duration::from_secs(10);
|
|
|
|
fn semantic_selection_at(
|
|
model: &ResolvedSelectionModel,
|
|
hit: &RangeHit,
|
|
separators: &str,
|
|
) -> Option<(std::ops::Range<u16>, String)> {
|
|
let line = model.line_for_hit(hit)?;
|
|
let range = url_range_at_col(&line.text, hit.col_within_range)
|
|
.unwrap_or_else(|| word_boundaries_at_col(&line.text, hit.col_within_range, separators));
|
|
if range.is_empty() {
|
|
return None;
|
|
}
|
|
let text = crate::scrollback::types::slice_display_cols(&line.text, range.start, range.end);
|
|
Some((range, text))
|
|
}
|
|
|
|
impl AgentView {
|
|
/// Tick the selection highlight timer. Returns true if the selection
|
|
/// was auto-dismissed (needs redraw). When `keep_text_selection` is on
|
|
/// (cache), never timer-dismisses — Esc / click / nav still clear it.
|
|
pub fn tick_selection_highlight(&mut self) -> bool {
|
|
if crate::appearance::cache::load_keep_text_selection().holds() {
|
|
return false;
|
|
}
|
|
if let Some(created) = self.selection_created_at
|
|
&& created.elapsed().as_millis() as u64 >= DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS
|
|
{
|
|
self.persistent_text_selection = None;
|
|
self.table_selection_geometry = None;
|
|
self.selection_created_at = None;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Side-car table geometry, if it was resolved for the given selection.
|
|
pub(in crate::app) fn table_geometry_for_selection(
|
|
&self,
|
|
entry_idx: usize,
|
|
range_id: u16,
|
|
) -> Option<&TableGeometry> {
|
|
self.table_selection_geometry
|
|
.as_ref()
|
|
.and_then(|t| t.for_selection(entry_idx, range_id))
|
|
}
|
|
|
|
/// Run `f` with a text source over the anchor entry's full block output
|
|
/// (so off-screen fragments are included). Lines outside the hit's
|
|
/// selection range yield `None`, which detection treats as a boundary.
|
|
///
|
|
/// `width_override` is the drag-start width snapshot, for callers whose
|
|
/// entry may have scrolled fully out of `visible_blocks` by now; other
|
|
/// callers pass `None` and use the current frame's geometry.
|
|
fn with_entry_output_text_source<R>(
|
|
&self,
|
|
entry_idx: usize,
|
|
range_id: u16,
|
|
width_override: Option<u16>,
|
|
f: impl FnOnce(&dyn Fn(usize) -> Option<String>) -> R,
|
|
) -> Option<R> {
|
|
let scrollback = if let Some(ref child_id) = self.active_subagent
|
|
&& let Some(child) = self.subagent_views.get(child_id)
|
|
{
|
|
&child.scrollback
|
|
} else {
|
|
&self.scrollback
|
|
};
|
|
let visible_start = scrollback.visible_entry_range().start;
|
|
let abs_idx = entry_idx + visible_start;
|
|
// The per-block content width the geometry/hits were captured
|
|
// against; see the width note in `reconstruct_drag_copy`.
|
|
let content_width = width_override.or_else(|| {
|
|
self.last_scrollback_selection_model
|
|
.visible_block_content_width(entry_idx)
|
|
})?;
|
|
let entry = scrollback.get(abs_idx)?;
|
|
let appearance = scrollback.appearance();
|
|
let effective = entry.effective_output(content_width, appearance, false, scrollback.cwd());
|
|
let lines = &effective.output().lines;
|
|
let source = |i: usize| -> Option<String> {
|
|
let line = lines.get(i)?;
|
|
if line.selection_range != Some(range_id) {
|
|
return None;
|
|
}
|
|
Some(crate::scrollback::types::derive_selection_text(line))
|
|
};
|
|
Some(f(&source))
|
|
}
|
|
|
|
/// Detect the table grid under a drag anchor (btw drags stay linear).
|
|
/// Pre-gated on the anchor line having a box-drawing glyph: prose drags
|
|
/// must not touch `effective_output`, which thrashes the render cache.
|
|
fn compute_drag_table_geometry(&self, anchor: &RangeHit) -> Option<TableGeometry> {
|
|
if anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX {
|
|
return None;
|
|
}
|
|
if let Some(line) = self.last_scrollback_selection_model.line_for_hit(anchor)
|
|
&& !line.text.contains(['│', '┌', '├', '└'])
|
|
{
|
|
return None;
|
|
}
|
|
self.with_entry_output_text_source(anchor.entry_idx, anchor.range_id, None, |src| {
|
|
TableGeometry::detect(src, anchor.block_line_idx)
|
|
})
|
|
.flatten()
|
|
}
|
|
|
|
/// Re-resolve the drag's [`SelectionKind`] after an endpoint change;
|
|
/// `prev` is the latch that keeps boundary touches from flipping it.
|
|
fn resolve_drag_kind(
|
|
&self,
|
|
anchor: &RangeHit,
|
|
head: &RangeHit,
|
|
prev: SelectionKind,
|
|
) -> SelectionKind {
|
|
let geom = self.table_geometry_for_selection(anchor.entry_idx, anchor.range_id);
|
|
resolve_table_drag_kind(geom, anchor, head, prev)
|
|
}
|
|
|
|
/// Whether drag autoscroll is currently active and needs ticking.
|
|
pub fn has_drag_autoscroll(&self) -> bool {
|
|
self.drag_autoscroll.is_some()
|
|
}
|
|
|
|
/// Advance drag autoscroll by one tick.
|
|
///
|
|
/// Scrolls the active scrollback (main or subagent) by `speed` rows in
|
|
/// the autoscroll direction. After scrolling, recomputes the drag head
|
|
/// from the stored mouse position using the current (soon-stale)
|
|
/// selection model; [`Self::reclamp_drag_head_post_render`] re-snaps the
|
|
/// head once the next render rebuilds the model.
|
|
///
|
|
/// For block drag, implements long-block snap: if the current head block
|
|
/// is no longer visible after scrolling, advances the head to the next
|
|
/// visible block in the scroll direction.
|
|
pub fn tick_drag_autoscroll(&mut self) -> bool {
|
|
let Some(autoscroll) = self.drag_autoscroll else {
|
|
return false;
|
|
};
|
|
|
|
// Scroll the active scrollback.
|
|
let scrollback = if let Some(ref child_id) = self.active_subagent {
|
|
if let Some(child) = self.subagent_views.get_mut(child_id) {
|
|
&mut child.scrollback
|
|
} else {
|
|
&mut self.scrollback
|
|
}
|
|
} else {
|
|
&mut self.scrollback
|
|
};
|
|
|
|
match autoscroll.direction {
|
|
AutoScrollDirection::Up => scrollback.scroll_up(autoscroll.speed),
|
|
AutoScrollDirection::Down => scrollback.scroll_down(autoscroll.speed),
|
|
}
|
|
|
|
// Recompute text drag head from stored mouse position, against the
|
|
// pre-scroll (stale) model: a within-tick estimate that keeps the
|
|
// overlay responsive; the post-render reclamp re-resolves it against
|
|
// the fresh model every frame.
|
|
// Skip for btw-anchored drags — they don't autoscroll the scrollback.
|
|
if let Some((col, row)) = self.last_drag_mouse
|
|
&& let Some(drag) = self.drag_selection
|
|
&& drag.anchor.entry_idx != BTW_OVERLAY_ENTRY_IDX
|
|
{
|
|
let head = self
|
|
.last_scrollback_selection_model
|
|
.hit_test_nearest_in_range(drag.anchor, col, row)
|
|
.unwrap_or(drag.head);
|
|
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
|
|
if let Some(ref mut drag) = self.drag_selection {
|
|
drag.head = head;
|
|
drag.kind = kind;
|
|
}
|
|
}
|
|
|
|
// Long-block snap for block drag: if the current head block scrolled
|
|
// out of the visible set, advance to the next/previous visible block.
|
|
if let Some(ref mut drag) = self.block_drag_selection {
|
|
let head_visible = self
|
|
.last_scrollback_selection_model
|
|
.visible_blocks
|
|
.iter()
|
|
.any(|b| b.entry_idx == drag.head_entry_idx);
|
|
|
|
if !head_visible {
|
|
match autoscroll.direction {
|
|
AutoScrollDirection::Down => {
|
|
if let Some(next) = self
|
|
.last_scrollback_selection_model
|
|
.visible_blocks
|
|
.iter()
|
|
.find(|b| b.entry_idx > drag.head_entry_idx)
|
|
{
|
|
drag.head_entry_idx = next.entry_idx;
|
|
}
|
|
}
|
|
AutoScrollDirection::Up => {
|
|
if let Some(prev) = self
|
|
.last_scrollback_selection_model
|
|
.visible_blocks
|
|
.iter()
|
|
.rev()
|
|
.find(|b| b.entry_idx < drag.head_entry_idx)
|
|
{
|
|
drag.head_entry_idx = prev.entry_idx;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
/// Snap an active drag head to the freshly rebuilt selection model.
|
|
///
|
|
/// Input handlers and autoscroll ticks hit-test against the previous
|
|
/// frame's model, which scrolling/streaming/resizing make stale.
|
|
/// `draw` calls this right after each model rebuild and before the
|
|
/// corresponding overlay paints, so the head re-resolves from the held
|
|
/// pointer position with at most one frame of lag. `btw_rebuilt` names
|
|
/// the surface that was just rebuilt: a drag anchored on the other
|
|
/// surface is left alone (its model is still last frame's). Keeps the
|
|
/// previous head when the anchor's range has no lines in the fresh model.
|
|
pub(in crate::app) fn reclamp_drag_head_post_render(&mut self, btw_rebuilt: bool) {
|
|
let Some((col, row)) = self.last_drag_mouse else {
|
|
return;
|
|
};
|
|
let Some(drag) = self.drag_selection else {
|
|
return;
|
|
};
|
|
if (drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX) != btw_rebuilt {
|
|
return;
|
|
}
|
|
let model = self.selection_model_for_hit(&drag.anchor);
|
|
let Some(head) = model.hit_test_nearest_in_range(drag.anchor, col, row) else {
|
|
return;
|
|
};
|
|
if head == drag.head {
|
|
return;
|
|
}
|
|
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
|
|
if let Some(ref mut drag) = self.drag_selection {
|
|
drag.head = head;
|
|
drag.kind = kind;
|
|
}
|
|
}
|
|
|
|
/// True when any scrollback drag/press latch is set (incl. a bare left press or scrollbar drag).
|
|
/// [`Self::clear_stuck_scrollback_drag`] also resets the derived autoscroll / last-drag-mouse state.
|
|
pub(super) fn scrollback_drag_latched(&self) -> bool {
|
|
self.left_mouse_down
|
|
|| self.scrollbar_dragging
|
|
|| self.pending_text_drag.is_some()
|
|
|| self.drag_selection.is_some()
|
|
|| self.pending_block_drag.is_some()
|
|
|| self.block_drag_selection.is_some()
|
|
|| self.deferred_text_press.is_some()
|
|
}
|
|
|
|
/// Discard any in-progress scrollback drag + mouse-down latch (recovery path).
|
|
pub(super) fn clear_stuck_scrollback_drag(&mut self) {
|
|
self.left_mouse_down = false;
|
|
self.scrollbar_dragging = false;
|
|
self.pending_text_drag = None;
|
|
self.drag_selection = None;
|
|
self.pending_block_drag = None;
|
|
self.block_drag_selection = None;
|
|
self.deferred_text_press = None;
|
|
self.drag_autoscroll = None;
|
|
self.last_drag_mouse = None;
|
|
}
|
|
|
|
/// Update [`Self::plan_prompt_mouse_drag`] for a left-button mouse event
|
|
/// during plan feedback and report whether the event should be forwarded
|
|
/// to the feedback prompt (for cursor placement / text selection).
|
|
///
|
|
/// `in_prompt` is whether the pointer currently sits in the prompt rect.
|
|
/// A left press arms the drag when it lands in the prompt; subsequent
|
|
/// `Drag`/`Up` events keep being routed to the prompt even after the
|
|
/// pointer leaves the rect (TextArea tracks drag state internally and
|
|
/// handles drag-beyond-edge), and the release disarms it. Shared by both
|
|
/// plan-feedback mouse paths (line-viewer-open and empty-plan) so the
|
|
/// drag semantics stay in sync.
|
|
pub(super) fn route_plan_prompt_mouse_drag(
|
|
&mut self,
|
|
mouse: &crossterm::event::MouseEvent,
|
|
in_prompt: bool,
|
|
) -> bool {
|
|
use crossterm::event::{MouseButton, MouseEventKind};
|
|
|
|
let was_dragging = self.plan_prompt_mouse_drag;
|
|
match mouse.kind {
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
self.plan_prompt_mouse_drag = in_prompt;
|
|
}
|
|
MouseEventKind::Up(MouseButton::Left) => {
|
|
self.plan_prompt_mouse_drag = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
let continue_drag = was_dragging
|
|
&& matches!(
|
|
mouse.kind,
|
|
MouseEventKind::Drag(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
|
|
);
|
|
in_prompt || continue_drag
|
|
}
|
|
|
|
pub(in crate::app) fn begin_pending_text_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
self.begin_pending_text_drag_on(mouse, false)
|
|
}
|
|
|
|
pub(in crate::app) fn begin_pending_btw_text_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
self.begin_pending_text_drag_on(mouse, true)
|
|
}
|
|
|
|
fn begin_pending_text_drag_on(&mut self, mouse: &MouseEvent, btw: bool) -> bool {
|
|
let model = if btw {
|
|
&self.last_btw_selection_model
|
|
} else {
|
|
&self.last_scrollback_selection_model
|
|
};
|
|
let hit = model.hit_test_selectable_range(mouse.column, mouse.row);
|
|
tracing::debug!(
|
|
event = "begin_pending_text_drag",
|
|
col = mouse.column,
|
|
row = mouse.row,
|
|
btw,
|
|
hit = ?hit,
|
|
"drag start hit-test"
|
|
);
|
|
let Some(hit) = hit else {
|
|
return false;
|
|
};
|
|
// Snapshot the anchor block's render width now (the btw model carries
|
|
// its own geometry): copy must survive the block scrolling fully out
|
|
// of `visible_blocks` before mouse-up.
|
|
let anchor_content_width = model.visible_block_content_width(hit.entry_idx);
|
|
self.pending_text_drag = Some(PendingTextDrag {
|
|
anchor: hit,
|
|
start_col: mouse.column,
|
|
start_row: mouse.row,
|
|
anchor_content_width,
|
|
});
|
|
self.drag_selection = None;
|
|
true
|
|
}
|
|
|
|
fn drag_model(&self) -> &ResolvedSelectionModel {
|
|
let is_btw = self
|
|
.pending_text_drag
|
|
.is_some_and(|p| p.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX)
|
|
|| self
|
|
.drag_selection
|
|
.as_ref()
|
|
.is_some_and(|d| d.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX);
|
|
if is_btw {
|
|
&self.last_btw_selection_model
|
|
} else {
|
|
&self.last_scrollback_selection_model
|
|
}
|
|
}
|
|
|
|
fn update_text_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
let Some(pending) = self.pending_text_drag else {
|
|
return false;
|
|
};
|
|
let threshold = drag_threshold_exceeded(&pending, mouse.column, mouse.row);
|
|
tracing::debug!(
|
|
event = "update_text_drag",
|
|
col = mouse.column,
|
|
row = mouse.row,
|
|
anchor = ?pending.anchor,
|
|
threshold,
|
|
"pending drag update"
|
|
);
|
|
if !threshold {
|
|
return false;
|
|
}
|
|
let model = self.drag_model();
|
|
let head = model
|
|
.hit_test_nearest_in_range(pending.anchor, mouse.column, mouse.row)
|
|
.unwrap_or(pending.anchor);
|
|
tracing::debug!(
|
|
event = "promote_text_drag",
|
|
head = ?head,
|
|
"pending drag promoted"
|
|
);
|
|
self.arm_text_drag(mouse, pending.anchor, head, pending.anchor_content_width);
|
|
true
|
|
}
|
|
|
|
/// Shared arming tail for every text drag — threshold promotion
|
|
/// ([`Self::update_text_drag`]) and deferred conversion
|
|
/// ([`Self::convert_deferred_text_press`]) both end here so their
|
|
/// bookkeeping cannot drift. Geometry resolves once per drag, at arming;
|
|
/// btw mouse-downs don't clear a held persistent selection, so a btw
|
|
/// drag must not wipe its side-car.
|
|
fn arm_text_drag(
|
|
&mut self,
|
|
mouse: &MouseEvent,
|
|
anchor: RangeHit,
|
|
head: RangeHit,
|
|
anchor_content_width: Option<u16>,
|
|
) {
|
|
if anchor.entry_idx != BTW_OVERLAY_ENTRY_IDX {
|
|
self.table_selection_geometry =
|
|
self.compute_drag_table_geometry(&anchor)
|
|
.map(|geometry| TableSelectionGeometry {
|
|
entry_idx: anchor.entry_idx,
|
|
range_id: anchor.range_id,
|
|
geometry,
|
|
});
|
|
}
|
|
let kind = self.resolve_drag_kind(&anchor, &head, SelectionKind::Linear);
|
|
self.drag_selection = Some(ActiveTextDrag {
|
|
anchor,
|
|
head,
|
|
kind,
|
|
anchor_content_width,
|
|
});
|
|
// Store the pointer at arming too, or an arm-then-hold-still drag
|
|
// would stay invisible to the post-render reclamp while content
|
|
// scrolls or streams underneath it.
|
|
self.last_drag_mouse = Some((mouse.column, mouse.row));
|
|
}
|
|
|
|
/// One-way deferred-anchor conversion. A scrollback press with no
|
|
/// selectable text under it arms [`AgentView::deferred_text_press`]
|
|
/// alongside the normal block-drag latch; the FIRST drag position that
|
|
/// hits selectable text (the same hit test the press ran) becomes both
|
|
/// anchor and head of an [`ActiveTextDrag`], and any block-drag state in
|
|
/// flight is cancelled — once text, the gesture stays text. A gesture
|
|
/// that never enters text leaves the block drag to run unchanged.
|
|
fn convert_deferred_text_press(&mut self, mouse: &MouseEvent) -> bool {
|
|
let Some((press_col, press_row)) = self.deferred_text_press else {
|
|
return false;
|
|
};
|
|
let Some(hit) = self
|
|
.last_scrollback_selection_model
|
|
.hit_test_selectable_range(mouse.column, mouse.row)
|
|
else {
|
|
return false;
|
|
};
|
|
tracing::debug!(
|
|
event = "convert_deferred_text_press",
|
|
press_col,
|
|
press_row,
|
|
col = mouse.column,
|
|
row = mouse.row,
|
|
hit = ?hit,
|
|
"deferred press entered text"
|
|
);
|
|
self.deferred_text_press = None;
|
|
self.pending_block_drag = None;
|
|
self.block_drag_selection = None;
|
|
// The width snapshot is taken at anchor placement (here, not at the
|
|
// press): copy must survive the block scrolling out before mouse-up.
|
|
let anchor_content_width = self
|
|
.last_scrollback_selection_model
|
|
.visible_block_content_width(hit.entry_idx);
|
|
self.arm_text_drag(mouse, hit, hit, anchor_content_width);
|
|
true
|
|
}
|
|
|
|
fn update_active_text_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
let Some(drag) = self.drag_selection else {
|
|
return false;
|
|
};
|
|
let is_btw = drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX;
|
|
let head = self
|
|
.selection_model_for_hit(&drag.anchor)
|
|
.hit_test_nearest_in_range(drag.anchor, mouse.column, mouse.row)
|
|
.unwrap_or(drag.head);
|
|
let kind = self.resolve_drag_kind(&drag.anchor, &head, drag.kind);
|
|
let changed = head != drag.head || kind != drag.kind;
|
|
if let Some(ref mut drag) = self.drag_selection {
|
|
drag.head = head;
|
|
drag.kind = kind;
|
|
}
|
|
self.last_drag_mouse = Some((mouse.column, mouse.row));
|
|
// Btw drags don't scroll the scrollback pane.
|
|
self.drag_autoscroll = if is_btw {
|
|
None
|
|
} else {
|
|
compute_autoscroll(mouse.row, self.pane_areas.scrollback)
|
|
};
|
|
changed
|
|
}
|
|
|
|
pub(in crate::app) fn handle_scrollback_drag_motion(
|
|
&mut self,
|
|
mouse: &MouseEvent,
|
|
) -> InputOutcome {
|
|
if self.scrollbar_dragging {
|
|
self.apply_scrollbar_click(mouse.row);
|
|
return InputOutcome::Changed;
|
|
}
|
|
if self.drag_selection.is_some() {
|
|
return if self.update_active_text_drag(mouse) {
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
};
|
|
}
|
|
// Anchor-less press: convert to a text drag the moment the pointer
|
|
// enters selectable text; a miss falls through to the block-drag
|
|
// branches unchanged.
|
|
if self.convert_deferred_text_press(mouse) {
|
|
return InputOutcome::Changed;
|
|
}
|
|
if self.block_drag_selection.is_some() {
|
|
return if self.update_active_block_drag(mouse) {
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
};
|
|
}
|
|
if self.pending_text_drag.is_some() {
|
|
return if self.update_text_drag(mouse) {
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
};
|
|
}
|
|
if self.pending_block_drag.is_some() {
|
|
return if self.promote_pending_block_drag(mouse) {
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
};
|
|
}
|
|
// An armed anchor-less press owns the rest of the gesture: strip
|
|
// presses deliberately keep focus where it was, so a conversion miss
|
|
// must not leak motion into the todo/viewer/prompt handlers below
|
|
// (a prompt-focused strip drag would otherwise edit the prompt).
|
|
if self.deferred_text_press.is_some() {
|
|
return InputOutcome::Unchanged;
|
|
}
|
|
if self.active_pane == AgentPane::Todo
|
|
&& self
|
|
.todo
|
|
.handle_mouse(mouse.kind, mouse.column, mouse.row, self.pane_areas.todo)
|
|
{
|
|
return InputOutcome::Changed;
|
|
}
|
|
if self.block_viewer.is_some() && self.active_pane == AgentPane::Scrollback {
|
|
self.handle_block_viewer_mouse(mouse);
|
|
return InputOutcome::Changed;
|
|
}
|
|
if self.active_pane == AgentPane::Prompt {
|
|
self.prompt.handle_mouse(mouse);
|
|
InputOutcome::Changed
|
|
} else {
|
|
InputOutcome::Unchanged
|
|
}
|
|
}
|
|
|
|
/// Clipboard text for a finished drag, tagged with the kind that
|
|
/// produced it — `Linear` when a table copy fell through — so the
|
|
/// persisted highlight mirrors what reached the clipboard.
|
|
fn reconstruct_drag_copy(&self, drag: &ActiveTextDrag) -> Option<(String, SelectionKind)> {
|
|
if drag.anchor.entry_idx == BTW_OVERLAY_ENTRY_IDX {
|
|
// Same width rule as scrollback anchors: the drag-start snapshot
|
|
// matches the wrap the drag's block_line_idx values came from,
|
|
// with the current panel width as fallback.
|
|
let content_width = drag
|
|
.anchor_content_width
|
|
.map(usize::from)
|
|
.unwrap_or_else(|| self.last_btw_area.width.saturating_sub(4) as usize);
|
|
if let Some(ref btw) = self.btw_state {
|
|
let full_model = btw.full_selection_model(content_width);
|
|
if let Some(text) = reconstruct_selection_text(&full_model, drag) {
|
|
return Some((text, SelectionKind::Linear));
|
|
}
|
|
}
|
|
return reconstruct_selection_text(&self.last_btw_selection_model, drag)
|
|
.map(|text| (text, SelectionKind::Linear));
|
|
}
|
|
if drag.kind != SelectionKind::Linear
|
|
&& let Some(geom) =
|
|
self.table_geometry_for_selection(drag.anchor.entry_idx, drag.anchor.range_id)
|
|
&& let Some(text) = self
|
|
.with_entry_output_text_source(
|
|
drag.anchor.entry_idx,
|
|
drag.anchor.range_id,
|
|
// Snapshot keeps the table copy alive after the block
|
|
// autoscrolls fully out of the viewport.
|
|
drag.anchor_content_width,
|
|
|src| {
|
|
// Geometry was frozen at promote; a streaming re-wrap
|
|
// since then shifts every block_line_idx, so re-detect
|
|
// and require an exact match before slicing.
|
|
if TableGeometry::detect(src, drag.anchor.block_line_idx).as_ref()
|
|
!= Some(geom)
|
|
{
|
|
return None;
|
|
}
|
|
reconstruct_table_selection_text(geom, drag, src)
|
|
},
|
|
)
|
|
.flatten()
|
|
{
|
|
return Some((text, drag.kind));
|
|
}
|
|
let scrollback = if let Some(ref child_id) = self.active_subagent
|
|
&& let Some(child) = self.subagent_views.get(child_id)
|
|
{
|
|
&child.scrollback
|
|
} else {
|
|
&self.scrollback
|
|
};
|
|
let visible_start = scrollback.visible_entry_range().start;
|
|
let abs_idx = drag.anchor.entry_idx + visible_start;
|
|
// Width must come from the same VisibleBlockGeometry the drag's
|
|
// block_line_idx values were captured against; the pane-wide width
|
|
// re-wraps timestamp-reserving blocks and shifts every index.
|
|
// Prefer the drag-start snapshot: by mouse-up the anchor block may
|
|
// have autoscrolled fully out of `visible_blocks`.
|
|
let entry_content_width = drag.anchor_content_width.or_else(|| {
|
|
self.last_scrollback_selection_model
|
|
.visible_block_content_width(drag.anchor.entry_idx)
|
|
});
|
|
if let Some(entry) = scrollback.get(abs_idx)
|
|
&& let Some(content_width) = entry_content_width
|
|
{
|
|
let appearance = scrollback.appearance();
|
|
entry.ensure_cached(content_width, appearance, false, scrollback.cwd());
|
|
let rendered = entry.cached_rendered_output_ref();
|
|
if let Some(text) = reconstruct_full_selection_text_with_boundaries(
|
|
&rendered.output.lines,
|
|
&rendered.boundaries,
|
|
drag,
|
|
) {
|
|
return Some((text, SelectionKind::Linear));
|
|
}
|
|
}
|
|
reconstruct_selection_text_with_boundaries(
|
|
&self.last_scrollback_selection_model,
|
|
&self.last_scrollback_selection_boundaries,
|
|
drag,
|
|
)
|
|
.map(|text| (text, SelectionKind::Linear))
|
|
}
|
|
|
|
/// Persist a finished drag as the highlight. `kind` is the copied
|
|
/// shape, not `drag.kind`: a degraded table drag persists `Linear` and
|
|
/// drops its orphaned side-car.
|
|
fn persist_drag_selection(&mut self, drag: &ActiveTextDrag, kind: SelectionKind) {
|
|
if kind == SelectionKind::Linear && drag.kind != SelectionKind::Linear {
|
|
self.table_selection_geometry = None;
|
|
}
|
|
self.persistent_text_selection = Some(PersistentTextSelection {
|
|
entry_idx: drag.anchor.entry_idx,
|
|
range_id: drag.anchor.range_id,
|
|
anchor: SelectionEndpoint {
|
|
block_line_idx: drag.anchor.block_line_idx,
|
|
col_within_range: drag.anchor.col_within_range,
|
|
},
|
|
head: SelectionEndpoint {
|
|
block_line_idx: drag.head.block_line_idx,
|
|
col_within_range: drag.head.col_within_range,
|
|
},
|
|
origin: SelectionOrigin::Drag,
|
|
kind,
|
|
});
|
|
self.selection_created_at = Some(Instant::now());
|
|
}
|
|
|
|
pub(in crate::app) fn finish_text_drag(&mut self) -> bool {
|
|
let drag = self.drag_selection;
|
|
let copied = drag.and_then(|d| self.reconstruct_drag_copy(&d));
|
|
self.pending_text_drag = None;
|
|
self.drag_selection = None;
|
|
self.drag_autoscroll = None;
|
|
self.last_drag_mouse = None;
|
|
if let Some((text, kind)) = copied
|
|
&& !text.is_empty()
|
|
{
|
|
// Capture drag geometry as a persistent selection only when the
|
|
// clipboard copy succeeds. Setting it unconditionally would leave
|
|
// a highlight with nothing in the clipboard if reconstruction fails.
|
|
if let Some(d) = drag {
|
|
self.persist_drag_selection(&d, kind);
|
|
}
|
|
self.copy_to_clipboard(&text);
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
pub(in crate::app) fn begin_pending_block_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
let block = self
|
|
.last_scrollback_selection_model
|
|
.hit_test_visible_block(mouse.column, mouse.row);
|
|
let Some(block) = block else {
|
|
return false;
|
|
};
|
|
if !block.drag_startable {
|
|
return false;
|
|
}
|
|
tracing::debug!(
|
|
event = "begin_pending_block_drag",
|
|
entry_idx = block.entry_idx,
|
|
col = mouse.column,
|
|
row = mouse.row,
|
|
"scrollback block drag start"
|
|
);
|
|
self.pending_block_drag = Some(PendingBlockDrag {
|
|
anchor_entry_idx: block.entry_idx,
|
|
start_col: mouse.column,
|
|
start_row: mouse.row,
|
|
});
|
|
self.drag_selection = None;
|
|
self.block_drag_selection = None;
|
|
true
|
|
}
|
|
|
|
fn promote_pending_block_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
let Some(pending) = self.pending_block_drag else {
|
|
return false;
|
|
};
|
|
if !block_drag_threshold_exceeded(&pending, mouse.column, mouse.row) {
|
|
return false;
|
|
}
|
|
let head_entry_idx = self
|
|
.last_scrollback_selection_model
|
|
.hit_test_visible_block(mouse.column, mouse.row)
|
|
.map(|b| b.entry_idx)
|
|
.unwrap_or(pending.anchor_entry_idx);
|
|
tracing::debug!(
|
|
event = "promote_block_drag",
|
|
anchor = pending.anchor_entry_idx,
|
|
head = head_entry_idx,
|
|
"scrollback block drag promoted"
|
|
);
|
|
self.pending_block_drag = None;
|
|
self.block_drag_selection = Some(ActiveBlockDrag {
|
|
anchor_entry_idx: pending.anchor_entry_idx,
|
|
head_entry_idx,
|
|
});
|
|
true
|
|
}
|
|
|
|
fn update_active_block_drag(&mut self, mouse: &MouseEvent) -> bool {
|
|
let Some(ref mut drag) = self.block_drag_selection else {
|
|
return false;
|
|
};
|
|
let new_head = self
|
|
.last_scrollback_selection_model
|
|
.hit_test_visible_block(mouse.column, mouse.row)
|
|
.map(|b| b.entry_idx)
|
|
.unwrap_or(drag.head_entry_idx);
|
|
let changed = new_head != drag.head_entry_idx;
|
|
drag.head_entry_idx = new_head;
|
|
self.last_drag_mouse = Some((mouse.column, mouse.row));
|
|
self.drag_autoscroll = compute_autoscroll(mouse.row, self.pane_areas.scrollback);
|
|
changed
|
|
}
|
|
|
|
pub(in crate::app) fn finish_block_drag(&mut self) -> bool {
|
|
let drag = self.block_drag_selection.take();
|
|
self.pending_block_drag = None;
|
|
self.drag_autoscroll = None;
|
|
self.last_drag_mouse = None;
|
|
let Some(drag) = drag else {
|
|
return false;
|
|
};
|
|
let start = std::cmp::min(drag.anchor_entry_idx, drag.head_entry_idx);
|
|
let end = std::cmp::max(drag.anchor_entry_idx, drag.head_entry_idx);
|
|
|
|
let appearance = self.scrollback.appearance().clone();
|
|
let mut parts: Vec<String> = Vec::new();
|
|
|
|
for idx in start..=end {
|
|
let Some(entry) = self.scrollback.entry(idx) else {
|
|
continue;
|
|
};
|
|
if !entry.block.is_drag_block_selectable() {
|
|
continue;
|
|
}
|
|
if self.scrollback.entry_content_hidden_by_group(idx) {
|
|
continue;
|
|
}
|
|
|
|
// BgTask: copy stdout from session state when available.
|
|
if let crate::scrollback::block::RenderBlock::BgTask(ref block) = entry.block {
|
|
let stdout = self
|
|
.session
|
|
.bg_tasks
|
|
.get(&block.task_id)
|
|
.map(|t| &t.stdout)
|
|
.filter(|s| !s.is_empty());
|
|
if let Some(stdout) = stdout {
|
|
parts.push(stdout.clone());
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Find the content width from the resolved model's visible block geometry.
|
|
let content_width = self
|
|
.last_scrollback_selection_model
|
|
.visible_block_content_width(idx)
|
|
.unwrap_or(80);
|
|
|
|
let ctx = crate::scrollback::types::BlockContext {
|
|
mode: entry.display_mode,
|
|
is_running: entry.is_running,
|
|
width: content_width,
|
|
raw: entry.raw,
|
|
max_lines: None,
|
|
appearance: appearance.clone(),
|
|
is_selected: self.scrollback.selected() == Some(idx),
|
|
cwd: Some(self.session.cwd.clone()),
|
|
};
|
|
|
|
if let Some(text) = entry.block.copy_visible_text_in_state(&ctx)
|
|
&& !text.is_empty()
|
|
{
|
|
parts.push(text);
|
|
}
|
|
}
|
|
|
|
let text = parts.join("\n\n");
|
|
tracing::debug!(
|
|
event = "finish_block_drag",
|
|
anchor = drag.anchor_entry_idx,
|
|
head = drag.head_entry_idx,
|
|
blocks_copied = parts.len(),
|
|
"scrollback block drag finished"
|
|
);
|
|
if !text.is_empty() {
|
|
self.copy_to_clipboard(&text);
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Handle a click on a scrollback entry with multi-click detection.
|
|
///
|
|
/// - Single click: select entry
|
|
/// - Double-click non-prompt: toggle fold in place
|
|
/// - Double-click prompt: toggle fold + scroll to top
|
|
/// - Triple-click non-prompt: toggle fold + scroll to top
|
|
///
|
|
/// Returns `(last_click_state, show_word_select_tip)`. The tip flag is set
|
|
/// on a REPEATED double-click on assistant text (second gesture within
|
|
/// [`WORD_SELECT_REPEAT_WINDOW`]) while Text selection is still fold/nav
|
|
/// (not `word_select`), so dispatch can teach `/settings → Text
|
|
/// selection`. A lone double-click, or one on fold-affordance surfaces
|
|
/// (headers, prompts, tool rows), never tips.
|
|
pub(in crate::app) fn handle_scrollback_click(
|
|
&mut self,
|
|
now: Instant,
|
|
idx: usize,
|
|
header_row_click: bool,
|
|
) -> (Option<(Instant, usize, u8)>, bool) {
|
|
let click_count = if let Some((last_time, last_idx, prev_count)) = self.last_click
|
|
&& last_idx == idx
|
|
&& now.duration_since(last_time).as_millis() < MULTI_CLICK_TIMEOUT_MS
|
|
{
|
|
prev_count + 1
|
|
} else {
|
|
1
|
|
};
|
|
|
|
let entry_block = self.scrollback.entry(idx).map(|e| &e.block);
|
|
let is_bg_task = entry_block
|
|
.is_some_and(|b| matches!(b, crate::scrollback::block::RenderBlock::BgTask(_)));
|
|
let is_subagent = entry_block
|
|
.is_some_and(|b| matches!(b, crate::scrollback::block::RenderBlock::Subagent(_)));
|
|
|
|
// Word-select tip probe (see WORD_SELECT_REPEAT_WINDOW): assistant
|
|
// messages only — headers / prompts / tool rows are fold-nav surfaces
|
|
// where double-click is the designed gesture, and bg-task / subagent
|
|
// double-clicks open a viewer that owns input.
|
|
let word_select_probe = click_count == 2
|
|
&& entry_block.is_some_and(|b| b.is_agent_message())
|
|
&& !super::is_text_selection_on_double_click();
|
|
let show_word_select_tip = word_select_probe
|
|
&& self
|
|
.last_word_select_probe
|
|
.is_some_and(|t| now.duration_since(t) <= WORD_SELECT_REPEAT_WINDOW);
|
|
if word_select_probe {
|
|
self.last_word_select_probe = Some(now);
|
|
}
|
|
|
|
self.scrollback.set_selected(Some(idx));
|
|
|
|
// Expanded verb-group slot, header row (`header_row_click`): the slot
|
|
// acts as member 0 everywhere else, so the group affordance lives
|
|
// here — double-click on the header row collapses the group; a
|
|
// single click just selects. Member rows fall through to the normal
|
|
// foldable path below.
|
|
if header_row_click {
|
|
if click_count == 2 {
|
|
self.scrollback.collapse_group_if_expanded();
|
|
return (None, show_word_select_tip);
|
|
}
|
|
if click_count >= 3 {
|
|
return (None, false);
|
|
}
|
|
}
|
|
|
|
// Double-click on a group header → expand/collapse the group.
|
|
// Both expand ("N more") and collapse ("▾ N tool calls") headers
|
|
// are standalone entries with their own index.
|
|
let is_group_header = self.scrollback.is_selected_group_header();
|
|
if is_group_header {
|
|
if click_count == 2 {
|
|
self.scrollback.toggle_group_expansion();
|
|
return (None, show_word_select_tip);
|
|
}
|
|
if click_count >= 3 {
|
|
return (None, false);
|
|
}
|
|
}
|
|
|
|
let foldable = self.scrollback.get(idx).is_some_and(|e| e.is_foldable());
|
|
let is_prompt = self
|
|
.scrollback
|
|
.entry(idx)
|
|
.is_some_and(|e| e.block.is_user_prompt());
|
|
|
|
// Single-click on plan mode tool call → show plan preview/toast.
|
|
let is_plan_tool = !is_group_header
|
|
&& self
|
|
.scrollback
|
|
.entry(idx)
|
|
.is_some_and(|e| e.block.is_plan_mode_tool());
|
|
|
|
// Credit-limit URL click is handled upstream (before this method)
|
|
// so only the URL line is clickable, not the whole block.
|
|
|
|
// Double-click on bg-task / subagent blocks (matched above) opens a
|
|
// viewer instead of folding.
|
|
match click_count {
|
|
1 if is_plan_tool => {
|
|
self.show_plan_preview();
|
|
}
|
|
2 if is_bg_task => {
|
|
// Double-click bg task: open block viewer (same as Enter).
|
|
if let Some(entry) = self.scrollback.entry(idx)
|
|
&& let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block
|
|
&& let Some(task) = self.session.bg_tasks.get(&bt.task_id)
|
|
{
|
|
let eid = task
|
|
.scrollback_entry_id
|
|
.unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0));
|
|
let is_running = task.status == crate::app::agent::BgTaskStatus::Running;
|
|
self.block_viewer =
|
|
Some(crate::views::block_viewer::BlockViewerPane::for_bg_task(
|
|
eid,
|
|
&bt.task_id,
|
|
&task.stdout,
|
|
is_running,
|
|
));
|
|
}
|
|
}
|
|
2 if is_subagent => {
|
|
// Double-click subagent: open subagent view (same as Enter)
|
|
if let Some(entry) = self.scrollback.entry(idx)
|
|
&& let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block
|
|
{
|
|
let child_sid = sb.child_session_id.clone();
|
|
if self.subagent_views.contains_key(&child_sid) {
|
|
self.open_subagent_fullscreen(child_sid);
|
|
}
|
|
}
|
|
}
|
|
2 if is_prompt => {
|
|
// Edit in place; bash/cron keep the old fold behavior.
|
|
if !self.enter_inline_edit(idx) {
|
|
if foldable {
|
|
self.scrollback.toggle_fold_selected();
|
|
}
|
|
self.scrollback.scroll_to_entry_top(idx);
|
|
}
|
|
}
|
|
2 => {
|
|
if foldable {
|
|
self.scrollback.toggle_fold_selected();
|
|
}
|
|
}
|
|
3.. if !is_prompt => {
|
|
if foldable {
|
|
self.scrollback.toggle_fold_selected();
|
|
}
|
|
self.scrollback.scroll_to_entry_top(idx);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let last_click = if click_count >= 3 {
|
|
None
|
|
} else {
|
|
Some((now, idx, click_count))
|
|
};
|
|
(last_click, show_word_select_tip)
|
|
}
|
|
|
|
/// Return the correct selection model for a hit, accounting for the
|
|
/// /btw overlay panel which has its own model.
|
|
fn selection_model_for_hit(&self, hit: &RangeHit) -> &ResolvedSelectionModel {
|
|
if hit.entry_idx == BTW_OVERLAY_ENTRY_IDX {
|
|
&self.last_btw_selection_model
|
|
} else {
|
|
&self.last_scrollback_selection_model
|
|
}
|
|
}
|
|
|
|
/// Count text-level multi-clicks, incrementing when the new click lands
|
|
/// on the same (entry, range, line) within [`MULTI_CLICK_TIMEOUT_MS`].
|
|
pub(in crate::app) fn count_text_click(&self, now: Instant, hit: &RangeHit) -> u8 {
|
|
if let Some(ref prev) = self.last_text_click
|
|
&& prev.entry_idx == hit.entry_idx
|
|
&& prev.range_id == hit.range_id
|
|
&& prev.block_line_idx == hit.block_line_idx
|
|
&& now.duration_since(prev.time).as_millis() < MULTI_CLICK_TIMEOUT_MS
|
|
{
|
|
prev.click_count.saturating_add(1)
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
/// Select the word (or URL) under the cursor described by `hit`.
|
|
pub(in crate::app) fn select_word_at(&mut self, hit: &RangeHit) {
|
|
let model = self.selection_model_for_hit(hit);
|
|
let separators = configured_word_separators();
|
|
let Some((selection_range, clipboard_text)) = semantic_selection_at(model, hit, separators)
|
|
else {
|
|
return;
|
|
};
|
|
self.persistent_text_selection = Some(PersistentTextSelection {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
anchor: SelectionEndpoint {
|
|
block_line_idx: hit.block_line_idx,
|
|
col_within_range: selection_range.start,
|
|
},
|
|
head: SelectionEndpoint {
|
|
block_line_idx: hit.block_line_idx,
|
|
col_within_range: selection_range.end.saturating_sub(1),
|
|
},
|
|
origin: SelectionOrigin::DoubleClick,
|
|
kind: SelectionKind::Linear,
|
|
});
|
|
self.selection_created_at = Some(Instant::now());
|
|
|
|
if !clipboard_text.is_empty() {
|
|
self.copy_to_clipboard_debounced(&clipboard_text);
|
|
}
|
|
|
|
if hit.entry_idx != BTW_OVERLAY_ENTRY_IDX {
|
|
self.scrollback.set_selected(Some(hit.entry_idx));
|
|
}
|
|
}
|
|
|
|
fn selection_text_for_full_line(&self, hit: &RangeHit) -> Option<String> {
|
|
let model = self.selection_model_for_hit(hit);
|
|
let line = model.line_for_hit(hit)?;
|
|
let boundary = if hit.entry_idx == BTW_OVERLAY_ENTRY_IDX {
|
|
None
|
|
} else {
|
|
self.last_scrollback_selection_boundaries
|
|
.boundary_for_hit(hit)
|
|
};
|
|
Some(apply_selection_boundary(
|
|
line.text.clone(),
|
|
boundary,
|
|
true,
|
|
true,
|
|
))
|
|
}
|
|
|
|
/// Select the full line at the cursor position described by `hit`.
|
|
pub(in crate::app) fn select_line_at(&mut self, hit: &RangeHit) {
|
|
let model = self.selection_model_for_hit(hit);
|
|
let Some(line) = model.line_for_hit(hit) else {
|
|
return;
|
|
};
|
|
let width = line
|
|
.selectable_cols
|
|
.end
|
|
.saturating_sub(line.selectable_cols.start);
|
|
|
|
if width == 0 {
|
|
return;
|
|
}
|
|
|
|
let Some(clipboard_text) = self.selection_text_for_full_line(hit) else {
|
|
return;
|
|
};
|
|
|
|
self.persistent_text_selection = Some(PersistentTextSelection {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
anchor: SelectionEndpoint {
|
|
block_line_idx: hit.block_line_idx,
|
|
col_within_range: 0,
|
|
},
|
|
head: SelectionEndpoint {
|
|
block_line_idx: hit.block_line_idx,
|
|
col_within_range: width.saturating_sub(1),
|
|
},
|
|
origin: SelectionOrigin::TripleClick,
|
|
kind: SelectionKind::Linear,
|
|
});
|
|
self.selection_created_at = Some(Instant::now());
|
|
|
|
if !clipboard_text.is_empty() {
|
|
self.copy_to_clipboard_debounced(&clipboard_text);
|
|
}
|
|
|
|
if hit.entry_idx != BTW_OVERLAY_ENTRY_IDX {
|
|
self.scrollback.set_selected(Some(hit.entry_idx));
|
|
}
|
|
}
|
|
|
|
/// Select and copy the whole table cell at `hit`, wrapped fragments
|
|
/// included. `false` when there is no cell there (no grid, or a column
|
|
/// outside it) — the caller falls back to line selection.
|
|
pub(in crate::app) fn select_cell_at(&mut self, hit: &RangeHit) -> bool {
|
|
let Some(geometry) = self.compute_drag_table_geometry(hit) else {
|
|
return false;
|
|
};
|
|
// Triple-click on a border/divider row is the whole-table shortcut
|
|
// (drags from grid lines stay linear).
|
|
let Some(cell) = geometry.cell_at(hit.block_line_idx, hit.col_within_range) else {
|
|
return self.select_whole_table_at(hit, geometry);
|
|
};
|
|
let Some(clipboard_text) =
|
|
self.with_entry_output_text_source(hit.entry_idx, hit.range_id, None, |src| {
|
|
geometry.cell_text(cell, src)
|
|
})
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
let lines = geometry.row_lines(cell.row);
|
|
let band = geometry.band(cell.col);
|
|
self.persistent_text_selection = Some(PersistentTextSelection {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
anchor: SelectionEndpoint {
|
|
block_line_idx: lines.start,
|
|
col_within_range: band.start,
|
|
},
|
|
head: SelectionEndpoint {
|
|
block_line_idx: lines.end.saturating_sub(1),
|
|
col_within_range: band.end.saturating_sub(1),
|
|
},
|
|
origin: SelectionOrigin::TripleClick,
|
|
kind: SelectionKind::TableCell,
|
|
});
|
|
self.table_selection_geometry = Some(TableSelectionGeometry {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
geometry,
|
|
});
|
|
self.selection_created_at = Some(Instant::now());
|
|
|
|
if !clipboard_text.is_empty() {
|
|
self.copy_to_clipboard_debounced(&clipboard_text);
|
|
}
|
|
self.scrollback.set_selected(Some(hit.entry_idx));
|
|
true
|
|
}
|
|
|
|
/// Select and copy the entire table containing `hit` — the grid-line
|
|
/// counterpart of [`Self::select_cell_at`].
|
|
fn select_whole_table_at(&mut self, hit: &RangeHit, geometry: TableGeometry) -> bool {
|
|
let anchor_cell = CellRef { row: 0, col: 0 };
|
|
let head_cell = CellRef {
|
|
row: geometry.n_rows() - 1,
|
|
col: geometry.n_cols() - 1,
|
|
};
|
|
let Some(clipboard_text) =
|
|
self.with_entry_output_text_source(hit.entry_idx, hit.range_id, None, |src| {
|
|
geometry.grid_tsv(anchor_cell, head_cell, src)
|
|
})
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
let first = geometry.row_lines(0);
|
|
let last = geometry.row_lines(head_cell.row);
|
|
self.persistent_text_selection = Some(PersistentTextSelection {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
anchor: SelectionEndpoint {
|
|
block_line_idx: first.start,
|
|
col_within_range: geometry.band(0).start,
|
|
},
|
|
head: SelectionEndpoint {
|
|
block_line_idx: last.end.saturating_sub(1),
|
|
col_within_range: geometry.band(head_cell.col).end.saturating_sub(1),
|
|
},
|
|
origin: SelectionOrigin::TripleClick,
|
|
kind: SelectionKind::TableGrid {
|
|
anchor: anchor_cell,
|
|
head: head_cell,
|
|
},
|
|
});
|
|
self.table_selection_geometry = Some(TableSelectionGeometry {
|
|
entry_idx: hit.entry_idx,
|
|
range_id: hit.range_id,
|
|
geometry,
|
|
});
|
|
self.selection_created_at = Some(Instant::now());
|
|
|
|
if !clipboard_text.is_empty() {
|
|
self.copy_to_clipboard_debounced(&clipboard_text);
|
|
}
|
|
self.scrollback.set_selected(Some(hit.entry_idx));
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::actions::ActionRegistry;
|
|
use crate::app::agent_view::test_fixtures::make_agent;
|
|
use crate::scrollback::table_geometry::CellRef;
|
|
use crate::scrollback::text_selection::{ResolvedSelectableLine, VisibleBlockGeometry};
|
|
use crossterm::event::{Event, MouseButton, MouseEventKind};
|
|
use ratatui::layout::Rect;
|
|
|
|
const TABLE: &[&str] = &[
|
|
"┌─────────┬────────┐",
|
|
"│ Name │ Role │",
|
|
"├─────────┼────────┤",
|
|
"│ Alice │ Eng │",
|
|
"└─────────┴────────┘",
|
|
];
|
|
|
|
fn table_geometry() -> TableGeometry {
|
|
TableGeometry::detect(|i| TABLE.get(i).map(|s| s.to_string()), 1).expect("grid detected")
|
|
}
|
|
|
|
/// Agent whose TABLE lines are in the visible model but with no
|
|
/// `visible_blocks`/scrollback entry, forcing the table copy to fall
|
|
/// through to the visible-model linear reconstruction.
|
|
fn agent_with_visible_table_lines_only() -> AgentView {
|
|
let mut agent = make_agent();
|
|
let mut model = ResolvedSelectionModel::default();
|
|
for (i, text) in TABLE.iter().enumerate() {
|
|
model.push_line(ResolvedSelectableLine {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: i,
|
|
screen_y: i as u16,
|
|
screen_x: 0,
|
|
selectable_cols: 0..20,
|
|
text: (*text).to_string(),
|
|
joiner_to_previous: None,
|
|
});
|
|
}
|
|
agent.update_scrollback_selection_state(model, Default::default());
|
|
agent.table_selection_geometry = Some(TableSelectionGeometry {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
geometry: table_geometry(),
|
|
});
|
|
agent
|
|
}
|
|
|
|
fn table_drag() -> ActiveTextDrag {
|
|
ActiveTextDrag {
|
|
anchor: RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 1,
|
|
col_within_range: 2,
|
|
},
|
|
head: RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 3,
|
|
col_within_range: 12,
|
|
},
|
|
kind: SelectionKind::TableGrid {
|
|
anchor: CellRef { row: 0, col: 0 },
|
|
head: CellRef { row: 1, col: 1 },
|
|
},
|
|
anchor_content_width: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn semantic_word_and_url_copy_ignore_hidden_edit_boundaries() {
|
|
let mut model = ResolvedSelectionModel::default();
|
|
let mut boundaries =
|
|
crate::scrollback::text_selection::ResolvedSelectionBoundaries::default();
|
|
for (entry_idx, text, hit_col, prefix, suffix, expected) in [
|
|
(0, "foo rest", 0, " ", "", "foo"),
|
|
(1, "rest https://kimi.com", 5, "", " ", "https://kimi.com"),
|
|
] {
|
|
let line = ResolvedSelectableLine {
|
|
entry_idx,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
screen_y: entry_idx as u16,
|
|
screen_x: 0,
|
|
selectable_cols: 0..text.len() as u16,
|
|
text: text.to_string(),
|
|
joiner_to_previous: None,
|
|
};
|
|
boundaries.push(
|
|
&line,
|
|
std::sync::Arc::new(crate::scrollback::types::SelectionBoundary::new(
|
|
prefix.to_string(),
|
|
suffix.to_string(),
|
|
)),
|
|
);
|
|
model.push_line(line);
|
|
let hit = RangeHit {
|
|
entry_idx,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: hit_col,
|
|
};
|
|
let (_, copied) = semantic_selection_at(
|
|
&model,
|
|
&hit,
|
|
crate::scrollback::text_selection::DEFAULT_WORD_SEPARATORS,
|
|
)
|
|
.expect("semantic range");
|
|
assert_eq!(copied, expected);
|
|
}
|
|
assert!(!boundaries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn installed_scrollback_companion_reaches_linear_and_full_line_copy() {
|
|
use crate::scrollback::block::RenderBlock;
|
|
use crate::scrollback::render::ScratchBuffer;
|
|
use crate::scrollback::scrollback_pane::ScrollbackPane;
|
|
use crate::scrollback::state::ScrollbackState;
|
|
use crate::scrollback::types::DisplayMode;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
|
|
let mut state = ScrollbackState::new();
|
|
let edit = state.push_block(RenderBlock::edit(" foo.rs ", None));
|
|
state
|
|
.get_by_id_mut(edit)
|
|
.expect("Edit entry")
|
|
.set_display_mode(DisplayMode::Expanded);
|
|
let area = Rect::new(0, 0, 8, 20);
|
|
state.prepare_layout(area.width, area.height);
|
|
let mut buffer = Buffer::empty(area);
|
|
let mut scratch = ScratchBuffer::new();
|
|
let rendered = ScrollbackPane::new().render_with_scratch_and_selection_boundaries(
|
|
area,
|
|
&mut buffer,
|
|
&state,
|
|
&mut scratch,
|
|
);
|
|
assert!(!rendered.selection_boundaries.is_empty());
|
|
|
|
let model = rendered.output.selection_model;
|
|
let (entry_idx, range_id, first_line_idx, last_line_idx, last_width) = {
|
|
let range = model
|
|
.ranges
|
|
.iter()
|
|
.find(|range| !range.lines.is_empty())
|
|
.expect("visible Edit path range");
|
|
let first = range.lines.first().expect("first path row");
|
|
let last = range.lines.last().expect("last path row");
|
|
(
|
|
range.entry_idx,
|
|
range.range_id,
|
|
first.block_line_idx,
|
|
last.block_line_idx,
|
|
last.selectable_cols
|
|
.end
|
|
.saturating_sub(last.selectable_cols.start),
|
|
)
|
|
};
|
|
let mut agent = make_agent();
|
|
agent.update_scrollback_selection_state(model, rendered.selection_boundaries);
|
|
let drag = ActiveTextDrag {
|
|
anchor: RangeHit {
|
|
entry_idx,
|
|
range_id,
|
|
block_line_idx: first_line_idx,
|
|
col_within_range: 0,
|
|
},
|
|
head: RangeHit {
|
|
entry_idx,
|
|
range_id,
|
|
block_line_idx: last_line_idx,
|
|
col_within_range: last_width.saturating_sub(1),
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: None,
|
|
};
|
|
assert_eq!(
|
|
agent.reconstruct_drag_copy(&drag),
|
|
Some((" foo.rs ".to_string(), SelectionKind::Linear))
|
|
);
|
|
let full_line_copies: Vec<String> = agent
|
|
.last_scrollback_selection_model
|
|
.range(entry_idx, range_id)
|
|
.expect("installed Edit range")
|
|
.lines
|
|
.iter()
|
|
.filter_map(|line| {
|
|
agent.selection_text_for_full_line(&RangeHit {
|
|
entry_idx: line.entry_idx,
|
|
range_id: line.range_id,
|
|
block_line_idx: line.block_line_idx,
|
|
col_within_range: 0,
|
|
})
|
|
})
|
|
.collect();
|
|
assert!(
|
|
full_line_copies.iter().any(|text| text.starts_with(" ")),
|
|
"prefix boundary did not reach full-line copy: {full_line_copies:?}"
|
|
);
|
|
assert!(
|
|
full_line_copies.iter().any(|text| text.ends_with(" ")),
|
|
"suffix boundary did not reach full-line copy: {full_line_copies:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn active_child_copy_uses_child_scrollback_cwd() {
|
|
use crate::scrollback::block::RenderBlock;
|
|
use crate::scrollback::render::ScratchBuffer;
|
|
use crate::scrollback::scrollback_pane::ScrollbackPane;
|
|
use crate::scrollback::types::{DisplayMode, derive_selection_text};
|
|
use ratatui::buffer::Buffer;
|
|
|
|
let parent_cwd = std::path::PathBuf::from("/parent/worktree");
|
|
let child_cwd = std::path::PathBuf::from("/child/worktree");
|
|
let mut child = make_agent();
|
|
child.session.cwd = child_cwd.clone();
|
|
child.scrollback.set_cwd(Some(child_cwd.clone()));
|
|
let read = child.scrollback.push_block(RenderBlock::read(
|
|
child_cwd.join("src/lib.rs").to_string_lossy(),
|
|
None,
|
|
));
|
|
child
|
|
.scrollback
|
|
.get_by_id_mut(read)
|
|
.expect("Read entry")
|
|
.set_display_mode(DisplayMode::Expanded);
|
|
|
|
let area = Rect::new(0, 0, 40, 8);
|
|
child.scrollback.prepare_layout(area.width, area.height);
|
|
let mut buffer = Buffer::empty(area);
|
|
let mut scratch = ScratchBuffer::new();
|
|
let rendered = ScrollbackPane::new().render_with_scratch_and_selection_boundaries(
|
|
area,
|
|
&mut buffer,
|
|
&child.scrollback,
|
|
&mut scratch,
|
|
);
|
|
let line = rendered
|
|
.output
|
|
.selection_model
|
|
.ranges
|
|
.iter()
|
|
.flat_map(|range| &range.lines)
|
|
.find(|line| line.text == "src/lib.rs")
|
|
.expect("child-relative Read header")
|
|
.clone();
|
|
let content_width = rendered
|
|
.output
|
|
.selection_model
|
|
.visible_block_content_width(line.entry_idx)
|
|
.expect("visible child block width");
|
|
|
|
let mut parent = make_agent();
|
|
parent.session.cwd = parent_cwd;
|
|
parent.update_scrollback_selection_state(
|
|
rendered.output.selection_model,
|
|
rendered.selection_boundaries,
|
|
);
|
|
let child_id = "child".to_string();
|
|
parent
|
|
.subagent_views
|
|
.insert(child_id.clone(), Box::new(child));
|
|
parent.active_subagent = Some(child_id.clone());
|
|
|
|
let source_text = parent
|
|
.with_entry_output_text_source(
|
|
line.entry_idx,
|
|
line.range_id,
|
|
Some(content_width),
|
|
|source| source(line.block_line_idx),
|
|
)
|
|
.flatten();
|
|
assert_eq!(source_text.as_deref(), Some("src/lib.rs"));
|
|
{
|
|
let child = parent.subagent_views.get(&child_id).expect("active child");
|
|
let entry = child.scrollback.get(0).expect("child Read entry");
|
|
let cached = entry.cached_output_ref();
|
|
assert_eq!(
|
|
derive_selection_text(&cached.lines[line.block_line_idx]),
|
|
"src/lib.rs",
|
|
"copy helper must not rebuild the child cache against parent cwd"
|
|
);
|
|
}
|
|
|
|
let path_width = line
|
|
.selectable_cols
|
|
.end
|
|
.saturating_sub(line.selectable_cols.start);
|
|
let drag = ActiveTextDrag {
|
|
anchor: RangeHit {
|
|
entry_idx: line.entry_idx,
|
|
range_id: line.range_id,
|
|
block_line_idx: line.block_line_idx,
|
|
col_within_range: 0,
|
|
},
|
|
head: RangeHit {
|
|
entry_idx: line.entry_idx,
|
|
range_id: line.range_id,
|
|
block_line_idx: line.block_line_idx,
|
|
col_within_range: path_width.saturating_sub(1),
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: Some(content_width),
|
|
};
|
|
assert_eq!(
|
|
parent.reconstruct_drag_copy(&drag),
|
|
Some(("src/lib.rs".to_string(), SelectionKind::Linear))
|
|
);
|
|
}
|
|
|
|
/// Regression: a table drag whose table copy can't run must return and
|
|
/// persist `Linear` (and drop the side-car) to match the linear text
|
|
/// that was copied.
|
|
#[test]
|
|
fn table_drag_falling_back_to_linear_copy_persists_linear_kind() {
|
|
let mut agent = agent_with_visible_table_lines_only();
|
|
let drag = table_drag();
|
|
|
|
let (text, kind) = agent
|
|
.reconstruct_drag_copy(&drag)
|
|
.expect("linear fallback must still produce text");
|
|
assert!(
|
|
text.contains('│'),
|
|
"fallback is the linear row sweep (border glyphs included): {text:?}"
|
|
);
|
|
assert_eq!(kind, SelectionKind::Linear);
|
|
|
|
agent.persist_drag_selection(&drag, kind);
|
|
let sel = agent.persistent_text_selection.expect("selection persists");
|
|
assert_eq!(
|
|
sel.kind,
|
|
SelectionKind::Linear,
|
|
"highlight must mirror the linear copy, not the drag's table shape"
|
|
);
|
|
assert!(
|
|
agent.table_selection_geometry.is_none(),
|
|
"side-car geometry of the abandoned cell selection must be dropped"
|
|
);
|
|
}
|
|
|
|
/// When the table-aware copy did produce the text, the drag's table kind
|
|
/// persists and the side-car geometry stays for the overlay to consume.
|
|
#[test]
|
|
fn table_copy_success_keeps_table_kind_and_side_car() {
|
|
let mut agent = agent_with_visible_table_lines_only();
|
|
let drag = table_drag();
|
|
|
|
agent.persist_drag_selection(&drag, drag.kind);
|
|
let sel = agent.persistent_text_selection.expect("selection persists");
|
|
assert_eq!(sel.kind, drag.kind);
|
|
assert!(agent.table_selection_geometry.is_some());
|
|
}
|
|
|
|
/// Run one double-click gesture (click + click, 100ms apart) at `t` on
|
|
/// `idx`, threading `last_click` the way the mouse caller does. Returns
|
|
/// the tip flag of the second click.
|
|
fn double_click_gesture(agent: &mut AgentView, t: Instant, idx: usize) -> bool {
|
|
let (last, tip1) = agent.handle_scrollback_click(t, idx, false);
|
|
assert!(!tip1, "a single click must never tip");
|
|
agent.last_click = last;
|
|
let (last, tip2) =
|
|
agent.handle_scrollback_click(t + Duration::from_millis(100), idx, false);
|
|
agent.last_click = last;
|
|
tip2
|
|
}
|
|
|
|
/// The word-select tip needs a REPEATED double-click on assistant text:
|
|
/// the first gesture is treated as intentional folding; only a second
|
|
/// gesture inside the repeat window tips. Fold-affordance surfaces
|
|
/// (tool rows etc.) never tip and never arm the probe.
|
|
#[test]
|
|
fn word_select_tip_requires_repeated_double_click_on_assistant_text() {
|
|
use crate::appearance::TextSelection;
|
|
crate::appearance::cache::set_keep_text_selection(TextSelection::Flash);
|
|
|
|
let mut agent = make_agent();
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
"assistant words to select",
|
|
));
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::tool_call(
|
|
"Tool", "info", true,
|
|
));
|
|
agent.scrollback.prepare_layout(80, 40);
|
|
|
|
let t0 = Instant::now();
|
|
assert!(
|
|
!double_click_gesture(&mut agent, t0, 0),
|
|
"first double-click gesture is intentional folding — no tip"
|
|
);
|
|
// Second, separate gesture (past the 300ms multi-click timeout,
|
|
// inside the 10s repeat window) → the repeated-attempt signal.
|
|
let t1 = t0 + Duration::from_secs(1);
|
|
assert!(
|
|
double_click_gesture(&mut agent, t1, 0),
|
|
"repeated double-click on assistant text must tip"
|
|
);
|
|
|
|
// Tool-call rows are fold affordances: repeated gestures never tip
|
|
// and must not arm the probe for a later assistant-text click.
|
|
agent.last_word_select_probe = None;
|
|
let t2 = t1 + Duration::from_secs(2);
|
|
assert!(!double_click_gesture(&mut agent, t2, 1));
|
|
let t3 = t2 + Duration::from_secs(1);
|
|
assert!(!double_click_gesture(&mut agent, t3, 1));
|
|
assert!(
|
|
agent.last_word_select_probe.is_none(),
|
|
"fold-affordance double-clicks must not arm the probe"
|
|
);
|
|
|
|
// A stale probe outside the window does not tip, but re-arms.
|
|
let t4 = t3 + Duration::from_secs(1);
|
|
agent.last_word_select_probe = Some(t4 - Duration::from_secs(11));
|
|
assert!(
|
|
!double_click_gesture(&mut agent, t4, 0),
|
|
"probe outside the repeat window must not tip"
|
|
);
|
|
let t5 = t4 + Duration::from_secs(1);
|
|
assert!(
|
|
double_click_gesture(&mut agent, t5, 0),
|
|
"the expired attempt still re-arms for the next gesture"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// reclamp_drag_head_post_render tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn mouse_down(col: u16, row: u16) -> MouseEvent {
|
|
MouseEvent {
|
|
kind: MouseEventKind::Down(MouseButton::Left),
|
|
column: col,
|
|
row,
|
|
modifiers: crossterm::event::KeyModifiers::empty(),
|
|
}
|
|
}
|
|
|
|
fn mouse_drag(col: u16, row: u16) -> MouseEvent {
|
|
MouseEvent {
|
|
kind: MouseEventKind::Drag(MouseButton::Left),
|
|
column: col,
|
|
row,
|
|
modifiers: crossterm::event::KeyModifiers::empty(),
|
|
}
|
|
}
|
|
|
|
/// A one-range model whose lines `first_bl..first_bl+count` sit on
|
|
/// consecutive rows starting at `first_y` — model B of a reclamp test is
|
|
/// the same range with shifted indices/rows (a scrolled frame).
|
|
fn stacked_lines_model(first_bl: usize, first_y: u16, count: usize) -> ResolvedSelectionModel {
|
|
let mut model = ResolvedSelectionModel::default();
|
|
for i in 0..count {
|
|
model.push_line(ResolvedSelectableLine {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: first_bl + i,
|
|
screen_y: first_y + i as u16,
|
|
screen_x: 0,
|
|
selectable_cols: 0..40,
|
|
text: format!("stacked line {}", first_bl + i),
|
|
joiner_to_previous: (first_bl + i > 0).then(|| "\n".to_string()),
|
|
});
|
|
}
|
|
model
|
|
}
|
|
|
|
/// Down→Drag→Drag through `handle_input` on `stacked_lines_model(0, 5, 3)`
|
|
/// so the drag is genuinely promoted and `last_drag_mouse` is (10, 7).
|
|
fn latch_drag_with_mouse_held(agent: &mut AgentView, reg: &ActionRegistry) {
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), reg);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), reg);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), reg);
|
|
let drag = agent.drag_selection.expect("drag promoted");
|
|
assert_eq!(drag.anchor.block_line_idx, 0, "setup: anchor on line 0");
|
|
assert_eq!(drag.head.block_line_idx, 2, "setup: head on line 2");
|
|
assert_eq!(agent.last_drag_mouse, Some((10, 7)), "setup: pointer held");
|
|
}
|
|
|
|
/// After a scroll the rebuilt model puts different lines under the held
|
|
/// pointer; the post-render reclamp must move the head there.
|
|
#[test]
|
|
fn reclamp_follows_held_pointer_onto_rebuilt_model() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
latch_drag_with_mouse_held(&mut agent, ®);
|
|
|
|
// Scrolled down by two rows: lines 2..=6 now occupy rows 5..=9.
|
|
agent.last_scrollback_selection_model = stacked_lines_model(2, 5, 5);
|
|
agent.reclamp_drag_head_post_render(false);
|
|
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(
|
|
drag.head.block_line_idx, 4,
|
|
"head must re-resolve to the line now under the pointer row"
|
|
);
|
|
assert_eq!(drag.head.col_within_range, 10);
|
|
assert_eq!(drag.anchor.block_line_idx, 0, "anchor never reclamps");
|
|
}
|
|
|
|
/// Promotion stores the pointer, so a promote-then-hold-still drag is
|
|
/// reclamped as soon as the next frame rebuilds the model (e.g. a wheel
|
|
/// scroll between mouse-down and the promoting move).
|
|
#[test]
|
|
fn promotion_stores_pointer_for_reclamp() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), ®);
|
|
assert!(agent.drag_selection.is_some(), "setup: drag promoted");
|
|
assert_eq!(
|
|
agent.last_drag_mouse,
|
|
Some((10, 6)),
|
|
"promotion must store the pointer"
|
|
);
|
|
|
|
// No further motion: the rebuilt (scrolled) model alone must move
|
|
// the head to the line now under the held pointer.
|
|
agent.last_scrollback_selection_model = stacked_lines_model(2, 5, 5);
|
|
agent.reclamp_drag_head_post_render(false);
|
|
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(drag.head.block_line_idx, 3);
|
|
}
|
|
|
|
/// Guard: without an active drag the reclamp must not fabricate one.
|
|
#[test]
|
|
fn reclamp_noop_without_active_drag() {
|
|
let mut agent = make_agent();
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
agent.last_drag_mouse = Some((10, 6));
|
|
|
|
agent.reclamp_drag_head_post_render(false);
|
|
|
|
assert!(agent.drag_selection.is_none());
|
|
}
|
|
|
|
/// Guard: without a held-pointer position (cleared by finish/recovery
|
|
/// paths) there is nothing to re-resolve against, so the head stays put.
|
|
#[test]
|
|
fn reclamp_noop_without_last_drag_mouse() {
|
|
let mut agent = make_agent();
|
|
let anchor = RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 2,
|
|
};
|
|
agent.drag_selection = Some(ActiveTextDrag {
|
|
anchor,
|
|
head: RangeHit {
|
|
block_line_idx: 1,
|
|
..anchor
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: None,
|
|
});
|
|
agent.last_drag_mouse = None;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
|
|
agent.reclamp_drag_head_post_render(false);
|
|
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(
|
|
drag.head.block_line_idx, 1,
|
|
"head untouched without pointer"
|
|
);
|
|
}
|
|
|
|
/// A btw-anchored drag reclamps only on the btw rebuild and only against
|
|
/// the btw model: the scrollback rebuild is gated off, scrollback lines
|
|
/// under the pointer never capture its head, and a btw model miss keeps
|
|
/// the previous head.
|
|
#[test]
|
|
fn reclamp_btw_drag_gated_to_btw_rebuild_and_model() {
|
|
let mut agent = make_agent();
|
|
let btw_anchor = RangeHit {
|
|
entry_idx: crate::views::btw_overlay::BTW_OVERLAY_ENTRY_IDX,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 0,
|
|
};
|
|
agent.drag_selection = Some(ActiveTextDrag {
|
|
anchor: btw_anchor,
|
|
head: btw_anchor,
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: None,
|
|
});
|
|
agent.last_drag_mouse = Some((10, 6));
|
|
// Scrollback model has a hit under the pointer; btw model is empty.
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
agent.last_btw_selection_model = ResolvedSelectionModel::default();
|
|
|
|
agent.reclamp_drag_head_post_render(false);
|
|
agent.reclamp_drag_head_post_render(true);
|
|
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(drag.head, btw_anchor, "btw head must not follow scrollback");
|
|
|
|
// Once the btw model has lines, only the btw rebuild moves the head.
|
|
let mut btw_model = ResolvedSelectionModel::default();
|
|
btw_model.push_line(ResolvedSelectableLine {
|
|
entry_idx: BTW_OVERLAY_ENTRY_IDX,
|
|
range_id: 0,
|
|
block_line_idx: 3,
|
|
screen_y: 6,
|
|
screen_x: 0,
|
|
selectable_cols: 0..40,
|
|
text: "btw line".to_string(),
|
|
joiner_to_previous: None,
|
|
});
|
|
agent.last_btw_selection_model = btw_model;
|
|
|
|
agent.reclamp_drag_head_post_render(false);
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(drag.head, btw_anchor, "scrollback rebuild is gated off");
|
|
|
|
agent.reclamp_drag_head_post_render(true);
|
|
let drag = agent.drag_selection.expect("drag still active");
|
|
assert_eq!(drag.head.block_line_idx, 3, "btw rebuild moves the head");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// anchor_content_width snapshot tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// The linear copy resolves the anchor entry's lines with the drag-start
|
|
/// width snapshot when the block is gone from `visible_blocks` (scrolled
|
|
/// fully out before mouse-up); without the snapshot that copy fails.
|
|
#[test]
|
|
fn reconstruct_drag_copy_uses_width_snapshot_when_anchor_block_scrolled_out() {
|
|
let mut agent = make_agent();
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
"SNAPWIDTH alpha beta",
|
|
));
|
|
// The rebuilt frame no longer contains the anchor block at all.
|
|
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
|
|
|
|
let drag = ActiveTextDrag {
|
|
anchor: RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 0,
|
|
},
|
|
head: RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 8,
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: Some(60),
|
|
};
|
|
|
|
let (text, kind) = agent
|
|
.reconstruct_drag_copy(&drag)
|
|
.expect("snapshot width must reach the full-output reconstruction");
|
|
assert_eq!(text, "SNAPWIDTH");
|
|
assert_eq!(kind, SelectionKind::Linear);
|
|
|
|
// Same drag without a snapshot: the mouse-up-time lookup misses and
|
|
// the visible-model fallback has nothing.
|
|
let no_snapshot = ActiveTextDrag {
|
|
anchor_content_width: None,
|
|
..drag
|
|
};
|
|
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
|
|
}
|
|
|
|
/// Mouse-down on a selectable line snapshots the anchor block's render
|
|
/// width and promotion carries it onto the active drag.
|
|
#[test]
|
|
fn drag_promotion_carries_anchor_width_snapshot() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
let mut model = stacked_lines_model(0, 5, 3);
|
|
model.visible_blocks.push(VisibleBlockGeometry {
|
|
entry_idx: 0,
|
|
area: Rect::new(0, 5, 46, 3),
|
|
content_area: Rect::new(0, 5, 46, 3),
|
|
selection_area: Rect::new(0, 5, 46, 3),
|
|
content_width: 46,
|
|
top_clipped: false,
|
|
bottom_clipped: false,
|
|
drag_startable: true,
|
|
});
|
|
agent.last_scrollback_selection_model = model;
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), ®);
|
|
assert_eq!(
|
|
agent.pending_text_drag.and_then(|p| p.anchor_content_width),
|
|
Some(46)
|
|
);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 6)), ®);
|
|
assert_eq!(
|
|
agent.drag_selection.and_then(|d| d.anchor_content_width),
|
|
Some(46)
|
|
);
|
|
}
|
|
|
|
/// Resolver miss at promotion (the anchor's range vanished from the
|
|
/// frame between press and threshold): the head collapses to the
|
|
/// anchor — the live successor of the deleted clamp-to-anchor helper.
|
|
#[test]
|
|
fn promotion_miss_collapses_head_to_anchor() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), ®);
|
|
assert!(agent.pending_text_drag.is_some(), "setup: pending armed");
|
|
|
|
// The rebuilt frame lost the range entirely.
|
|
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), ®);
|
|
|
|
let drag = agent.drag_selection.expect("promoted despite the miss");
|
|
assert_eq!(drag.head, drag.anchor, "head collapsed to the anchor");
|
|
}
|
|
|
|
/// Resolver miss mid-drag (the range scrolled fully out): the head
|
|
/// keeps its previous position instead of jumping — the live successor
|
|
/// of the deleted keep-previous-head helper.
|
|
#[test]
|
|
fn active_drag_motion_miss_keeps_previous_head() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 5, 3);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 7)), ®);
|
|
let head_before = agent.drag_selection.expect("setup: active").head;
|
|
assert_eq!(head_before.block_line_idx, 2, "setup: head extended");
|
|
|
|
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(30, 9)), ®);
|
|
|
|
let drag = agent.drag_selection.expect("still active");
|
|
assert_eq!(drag.head, head_before, "head kept across the miss");
|
|
}
|
|
|
|
/// The TABLE-shaped copy also survives full scroll-out via the width
|
|
/// snapshot: the side-car geometry frozen while the block was visible
|
|
/// re-detects against the snapshot-width output and the cell text is
|
|
/// copied, with no `visible_blocks` entry at mouse-up. Without the
|
|
/// snapshot the whole copy fails.
|
|
#[test]
|
|
fn table_copy_uses_width_snapshot_when_anchor_block_scrolled_out() {
|
|
const WIDTH: u16 = 40;
|
|
let mut agent = make_agent();
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
"| Name | Role |\n|------|------|\n| Alice | Eng |",
|
|
));
|
|
|
|
// Freeze the side-car the way promotion does, from the entry's own
|
|
// rendering at the snapshot width (probe scan finds a grid line).
|
|
let probe_geometry = (0..6)
|
|
.find_map(|probe| {
|
|
agent
|
|
.with_entry_output_text_source(0, 0, Some(WIDTH), |src| {
|
|
TableGeometry::detect(src, probe)
|
|
})
|
|
.flatten()
|
|
})
|
|
.expect("markdown table renders a detectable grid");
|
|
let anchor_line = probe_geometry.row_lines(0).start;
|
|
let anchor_col = probe_geometry.band(0).start;
|
|
// Head sweeps to the cell's far corner so the copied span covers the
|
|
// whole cell, not a single padding column.
|
|
let head_line = probe_geometry.row_lines(0).end.saturating_sub(1);
|
|
let head_col = probe_geometry.band(0).end.saturating_sub(1);
|
|
// Re-detect at the anchor line: the copy path requires the side-car
|
|
// to exactly match its own re-detect there.
|
|
let geometry = agent
|
|
.with_entry_output_text_source(0, 0, Some(WIDTH), |src| {
|
|
TableGeometry::detect(src, anchor_line)
|
|
})
|
|
.flatten()
|
|
.expect("grid detected at the anchor line");
|
|
agent.table_selection_geometry = Some(TableSelectionGeometry {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
geometry,
|
|
});
|
|
// The rebuilt frame no longer contains the anchor block at all.
|
|
agent.last_scrollback_selection_model = ResolvedSelectionModel::default();
|
|
|
|
let anchor = RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: anchor_line,
|
|
col_within_range: anchor_col,
|
|
};
|
|
let drag = ActiveTextDrag {
|
|
anchor,
|
|
head: RangeHit {
|
|
block_line_idx: head_line,
|
|
col_within_range: head_col,
|
|
..anchor
|
|
},
|
|
kind: SelectionKind::TableCell,
|
|
anchor_content_width: Some(WIDTH),
|
|
};
|
|
|
|
let (text, kind) = agent
|
|
.reconstruct_drag_copy(&drag)
|
|
.expect("snapshot width must keep the table copy alive");
|
|
assert_eq!(kind, SelectionKind::TableCell);
|
|
assert!(text.contains("Name"), "cell content copied: {text:?}");
|
|
assert!(!text.contains('│'), "no border glyphs: {text:?}");
|
|
assert!(!text.contains("Role"), "band-clamped to one cell: {text:?}");
|
|
|
|
// Without the snapshot neither the table nor the linear path has a
|
|
// width, so the copy fails outright.
|
|
let no_snapshot = ActiveTextDrag {
|
|
anchor_content_width: None,
|
|
..drag
|
|
};
|
|
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
|
|
}
|
|
|
|
/// The btw copy prefers the drag-start width snapshot; the current
|
|
/// panel-area fallback only applies without one (here the default area
|
|
/// makes the fallback width 0, so only the snapshot path can produce
|
|
/// the text).
|
|
#[test]
|
|
fn btw_copy_prefers_width_snapshot_over_panel_fallback() {
|
|
let mut agent = make_agent();
|
|
agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::done(
|
|
"q".to_string(),
|
|
"BTWSNAP alpha beta".to_string(),
|
|
));
|
|
|
|
let anchor = RangeHit {
|
|
entry_idx: BTW_OVERLAY_ENTRY_IDX,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 0,
|
|
};
|
|
let drag = ActiveTextDrag {
|
|
anchor,
|
|
head: RangeHit {
|
|
col_within_range: 6,
|
|
..anchor
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: Some(30),
|
|
};
|
|
|
|
let (text, kind) = agent
|
|
.reconstruct_drag_copy(&drag)
|
|
.expect("snapshot width must reach the btw full model");
|
|
assert_eq!(text, "BTWSNAP");
|
|
assert_eq!(kind, SelectionKind::Linear);
|
|
|
|
let no_snapshot = ActiveTextDrag {
|
|
anchor_content_width: None,
|
|
..drag
|
|
};
|
|
assert!(agent.reconstruct_drag_copy(&no_snapshot).is_none());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// deferred text-press (anchor on entry into text) tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn mouse_up(col: u16, row: u16) -> MouseEvent {
|
|
MouseEvent {
|
|
kind: MouseEventKind::Up(MouseButton::Left),
|
|
column: col,
|
|
row,
|
|
modifiers: crossterm::event::KeyModifiers::empty(),
|
|
}
|
|
}
|
|
|
|
/// Two message entries with chrome rows and a dead gap between them:
|
|
/// entry 0 area rows 4-6 (row 4 chrome, text on rows 5-6, width 46),
|
|
/// rows 7-8 dead gap, entry 1 area rows 9-10 (row 9 chrome, text on
|
|
/// row 10, width 52).
|
|
fn agent_with_chrome_and_gap() -> AgentView {
|
|
let mut agent = make_agent();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
let mut model = ResolvedSelectionModel::default();
|
|
for (bl, y) in [(0usize, 5u16), (1, 6)] {
|
|
model.push_line(ResolvedSelectableLine {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: bl,
|
|
screen_y: y,
|
|
screen_x: 0,
|
|
selectable_cols: 0..20,
|
|
text: format!("entry zero line {bl}"),
|
|
joiner_to_previous: (bl > 0).then(|| "\n".to_string()),
|
|
});
|
|
}
|
|
model.push_line(ResolvedSelectableLine {
|
|
entry_idx: 1,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
screen_y: 10,
|
|
screen_x: 0,
|
|
selectable_cols: 0..20,
|
|
text: "entry one line".to_string(),
|
|
joiner_to_previous: None,
|
|
});
|
|
let block = |entry_idx: usize, area: Rect, content_width: u16| VisibleBlockGeometry {
|
|
entry_idx,
|
|
area,
|
|
content_area: area,
|
|
selection_area: area,
|
|
content_width,
|
|
top_clipped: false,
|
|
bottom_clipped: false,
|
|
drag_startable: true,
|
|
};
|
|
model
|
|
.visible_blocks
|
|
.push(block(0, Rect::new(0, 4, 46, 3), 46));
|
|
model
|
|
.visible_blocks
|
|
.push(block(1, Rect::new(0, 9, 52, 2), 52));
|
|
agent.last_scrollback_selection_model = model;
|
|
agent
|
|
}
|
|
|
|
/// A chrome press arms the block drag AND the anchor-less latch; motion
|
|
/// that stays on chrome/gap rows promotes and extends the block drag in
|
|
/// both directions exactly as before, with the latch armed but idle.
|
|
#[test]
|
|
fn chrome_press_drag_within_chrome_is_block_drag_unchanged() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), ®);
|
|
assert_eq!(agent.deferred_text_press, Some((6, 4)));
|
|
let pending = agent.pending_block_drag.expect("block drag pends");
|
|
assert_eq!(pending.anchor_entry_idx, 0);
|
|
assert!(agent.pending_text_drag.is_none());
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), ®);
|
|
let drag = agent.block_drag_selection.expect("block drag promoted");
|
|
assert_eq!((drag.anchor_entry_idx, drag.head_entry_idx), (0, 1));
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 4)), ®);
|
|
let drag = agent.block_drag_selection.expect("still a block drag");
|
|
assert_eq!((drag.anchor_entry_idx, drag.head_entry_idx), (0, 0));
|
|
assert!(agent.drag_selection.is_none());
|
|
assert_eq!(agent.deferred_text_press, Some((6, 4)), "latch stays armed");
|
|
}
|
|
|
|
/// A chrome press whose drag enters text converts to a text drag
|
|
/// anchored at the ENTRY position (not the press, not nearest-to-press)
|
|
/// with the entry block's width snapshot, and cancels the block drag
|
|
/// already in flight.
|
|
#[test]
|
|
fn chrome_press_drag_into_text_converts_at_entry_point() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 9)), ®);
|
|
assert!(agent.block_drag_selection.is_some(), "setup: block active");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 10)), ®);
|
|
|
|
let drag = agent.drag_selection.expect("converted to text drag");
|
|
let entry_hit = RangeHit {
|
|
entry_idx: 1,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 10,
|
|
};
|
|
assert_eq!(drag.anchor, entry_hit, "anchor at the entry position");
|
|
assert_eq!(drag.head, entry_hit);
|
|
assert_eq!(drag.anchor_content_width, Some(52), "entry block's width");
|
|
assert_eq!(drag.kind, SelectionKind::Linear);
|
|
assert!(agent.block_drag_selection.is_none(), "block drag cancelled");
|
|
assert!(agent.pending_block_drag.is_none());
|
|
assert!(agent.deferred_text_press.is_none(), "latch consumed");
|
|
}
|
|
|
|
/// A press on a dead gap row (no block area, so nothing block-armed)
|
|
/// stays anchor-less through further dead rows and anchors at the exact
|
|
/// row/col where the pointer first enters text.
|
|
#[test]
|
|
fn gap_press_drag_through_gap_anchors_at_entry_row_and_col() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), ®);
|
|
assert_eq!(agent.deferred_text_press, Some((6, 7)));
|
|
assert!(agent.pending_block_drag.is_none(), "gap is outside areas");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 8)), ®);
|
|
assert!(agent.drag_selection.is_none(), "still dead: no conversion");
|
|
assert!(agent.block_drag_selection.is_none());
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
let drag = agent.drag_selection.expect("converted on text entry");
|
|
assert_eq!(
|
|
drag.anchor,
|
|
RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 1,
|
|
col_within_range: 14,
|
|
},
|
|
"anchored at the entry row/col"
|
|
);
|
|
assert_eq!(drag.anchor_content_width, Some(46));
|
|
}
|
|
|
|
/// Press + release with no motion is a plain click: no selection of any
|
|
/// kind, and the click cascade still consumes the press.
|
|
#[test]
|
|
fn deferred_press_release_without_motion_is_plain_click() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), ®);
|
|
assert!(
|
|
agent.pending_scrollback_click.is_some(),
|
|
"setup: click pends"
|
|
);
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_up(6, 7)), ®);
|
|
|
|
assert!(agent.deferred_text_press.is_none());
|
|
assert!(agent.drag_selection.is_none());
|
|
assert!(agent.block_drag_selection.is_none());
|
|
assert!(agent.persistent_text_selection.is_none());
|
|
assert!(
|
|
agent.pending_scrollback_click.is_none(),
|
|
"the click path consumed the press"
|
|
);
|
|
}
|
|
|
|
/// A gesture that never enters text finishes as a whole-block copy,
|
|
/// exactly as without the latch (payload content pinned by e2e).
|
|
#[test]
|
|
fn deferred_press_never_entering_text_finishes_block_copy() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
"BLOCKCOPY zero",
|
|
));
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
"BLOCKCOPY one",
|
|
));
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 4)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), ®);
|
|
assert!(agent.block_drag_selection.is_some(), "setup: block active");
|
|
|
|
let outcome = agent.handle_input(&Event::Mouse(mouse_up(6, 9)), ®);
|
|
|
|
assert!(
|
|
matches!(outcome, InputOutcome::Changed),
|
|
"block copy finished"
|
|
);
|
|
assert!(agent.block_drag_selection.is_none());
|
|
assert!(agent.drag_selection.is_none());
|
|
assert!(agent.persistent_text_selection.is_none());
|
|
assert!(agent.deferred_text_press.is_none());
|
|
}
|
|
|
|
/// Conversion is one-way: once the gesture is a text drag, motion back
|
|
/// over chrome/gap rows keeps extending it (the pre-existing head rule:
|
|
/// nearest line within the anchor's range) and never re-arms block drag.
|
|
#[test]
|
|
fn converted_drag_stays_text_over_chrome_and_gap() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
assert!(agent.drag_selection.is_some(), "setup: converted");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 4)), ®);
|
|
let drag = agent.drag_selection.expect("still a text drag");
|
|
assert_eq!(drag.head.block_line_idx, 0, "head snapped within range");
|
|
assert_eq!(drag.head.col_within_range, 6);
|
|
assert!(agent.block_drag_selection.is_none(), "no block re-arm");
|
|
assert!(agent.pending_block_drag.is_none());
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(6, 9)), ®);
|
|
let drag = agent.drag_selection.expect("still a text drag");
|
|
assert_eq!(drag.head.block_line_idx, 1, "head follows nearest line");
|
|
assert_eq!(drag.anchor.entry_idx, 0, "anchor pinned to entry 0");
|
|
}
|
|
|
|
/// The latch clears on the recovery path and on the stale-latch guard
|
|
/// (any non-drag event, e.g. Esc, while latched).
|
|
#[test]
|
|
fn deferred_latch_cleared_on_recovery_and_stale_event() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), ®);
|
|
assert!(agent.scrollback_drag_latched(), "latch counts as a drag");
|
|
agent.clear_stuck_scrollback_drag();
|
|
assert!(agent.deferred_text_press.is_none());
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 7)), ®);
|
|
assert!(agent.deferred_text_press.is_some());
|
|
let esc = Event::Key(crossterm::event::KeyEvent::new(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::empty(),
|
|
));
|
|
let _ = agent.handle_input(&esc, ®);
|
|
assert!(
|
|
agent.deferred_text_press.is_none(),
|
|
"stale-latch guard cleared the deferred press"
|
|
);
|
|
}
|
|
|
|
/// The chrome-and-gap agent with the panes shrunk so rows 16-19 form the
|
|
/// passive strip band between the scrollback pane (rows 0-15) and the
|
|
/// prompt box (rows 20-22) — turn status / banner / gap-row territory.
|
|
fn agent_with_above_prompt_strip() -> AgentView {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 16);
|
|
agent.pane_areas.prompt = Rect::new(0, 20, 80, 3);
|
|
agent
|
|
}
|
|
|
|
/// A press on the strip band arms ONLY the anchor-less latch: no block
|
|
/// or text drag pends, and no click latch is set (release keeps doing
|
|
/// nothing, as the band did before).
|
|
#[test]
|
|
fn strip_press_arms_deferred_latch_only() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let outcome = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
|
|
assert!(matches!(outcome, InputOutcome::Changed));
|
|
assert_eq!(agent.deferred_text_press, Some((10, 17)));
|
|
assert!(agent.pending_block_drag.is_none());
|
|
assert!(agent.pending_text_drag.is_none());
|
|
assert!(agent.pending_scrollback_click.is_none(), "no click latch");
|
|
}
|
|
|
|
/// Dragging from the strip band up into message text converts at the
|
|
/// entry point, exactly like the in-pane deferred press.
|
|
#[test]
|
|
fn strip_press_drag_into_text_converts_at_entry_point() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
|
|
let drag = agent.drag_selection.expect("converted on text entry");
|
|
assert_eq!(
|
|
drag.anchor,
|
|
RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 1,
|
|
col_within_range: 14,
|
|
},
|
|
"anchored at the entry row/col"
|
|
);
|
|
assert_eq!(drag.anchor_content_width, Some(46));
|
|
assert!(agent.deferred_text_press.is_none(), "latch consumed");
|
|
}
|
|
|
|
/// Press + release on the strip without motion does what the band did
|
|
/// before the latch existed: nothing.
|
|
#[test]
|
|
fn strip_press_release_without_motion_does_nothing() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_up(10, 17)), ®);
|
|
|
|
assert!(agent.deferred_text_press.is_none());
|
|
assert!(agent.drag_selection.is_none());
|
|
assert!(agent.block_drag_selection.is_none());
|
|
assert!(agent.persistent_text_selection.is_none());
|
|
assert_eq!(agent.scrollback.selected(), None, "no entry selected");
|
|
}
|
|
|
|
/// A strip drag that never enters text selects nothing and leaves no
|
|
/// state behind on release.
|
|
#[test]
|
|
fn strip_press_drag_never_entering_text_selects_nothing() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(30, 18)), ®);
|
|
assert!(agent.drag_selection.is_none(), "no text under the pointer");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_up(30, 18)), ®);
|
|
|
|
assert!(agent.deferred_text_press.is_none());
|
|
assert!(agent.drag_selection.is_none());
|
|
assert!(agent.persistent_text_selection.is_none());
|
|
}
|
|
|
|
/// Interactive controls in and around the band never arm the latch:
|
|
/// the scrollbar and the turn-status cancel button consume their press
|
|
/// first, and the prompt box routes to the prompt pane.
|
|
#[test]
|
|
fn interactive_rows_do_not_arm_deferred_latch() {
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
agent.hit_scrollbar.set(Some(Rect::new(79, 0, 1, 20)));
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(79, 17)), ®);
|
|
assert!(agent.scrollbar_dragging, "scrollbar owns the press");
|
|
assert!(agent.deferred_text_press.is_none());
|
|
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
agent.hit_cancel_button.set(Some(Rect::new(30, 17, 6, 1)));
|
|
let outcome = agent.handle_input(&Event::Mouse(mouse_down(32, 17)), ®);
|
|
assert!(
|
|
matches!(
|
|
outcome,
|
|
InputOutcome::Action(crate::app::actions::Action::CancelTurn)
|
|
),
|
|
"cancel button owns the press"
|
|
);
|
|
assert!(agent.deferred_text_press.is_none());
|
|
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(5, 21)), ®);
|
|
assert!(agent.deferred_text_press.is_none(), "prompt is a pane");
|
|
}
|
|
|
|
/// With the block viewer open the band must not arm: the modal owns the
|
|
/// screen while the scrollback model beneath keeps rebuilding, so an
|
|
/// armed latch would convert on (and copy) text hidden under it.
|
|
#[test]
|
|
fn strip_press_with_block_viewer_open_arms_nothing() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
|
|
"t", "content",
|
|
));
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
assert!(agent.deferred_text_press.is_none(), "viewer owns the press");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
assert!(agent.drag_selection.is_none(), "nothing converts");
|
|
assert!(agent.persistent_text_selection.is_none());
|
|
}
|
|
|
|
/// A strip press keeps focus where it was (deliberate), so with the
|
|
/// prompt focused the un-converted motion crosses the prompt box — the
|
|
/// armed gesture must own that motion: nothing may leak into the prompt
|
|
/// handlers, and the gesture must still convert once it reaches text.
|
|
#[test]
|
|
fn strip_gesture_does_not_leak_motion_into_prompt() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.active_pane = AgentPane::Prompt;
|
|
agent.prompt.set_text("draft text");
|
|
let cursor_before = agent.prompt.cursor();
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
assert!(agent.deferred_text_press.is_some(), "setup: latch armed");
|
|
|
|
let outcome = agent.handle_input(&Event::Mouse(mouse_drag(5, 21)), ®);
|
|
assert!(matches!(outcome, InputOutcome::Unchanged));
|
|
assert_eq!(agent.prompt.text(), "draft text", "prompt text untouched");
|
|
assert_eq!(agent.prompt.cursor(), cursor_before, "cursor untouched");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
assert!(
|
|
agent.drag_selection.is_some(),
|
|
"conversion survives crossing the prompt region"
|
|
);
|
|
}
|
|
|
|
/// A strip press replaces any held highlight immediately, exactly like
|
|
/// the in-pane press's eager clear.
|
|
#[test]
|
|
fn strip_press_clears_previous_persistent_selection() {
|
|
let mut agent = agent_with_above_prompt_strip();
|
|
let reg = ActionRegistry::defaults();
|
|
let anchor = RangeHit {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
block_line_idx: 0,
|
|
col_within_range: 0,
|
|
};
|
|
agent.persist_drag_selection(
|
|
&ActiveTextDrag {
|
|
anchor,
|
|
head: RangeHit {
|
|
col_within_range: 5,
|
|
..anchor
|
|
},
|
|
kind: SelectionKind::Linear,
|
|
anchor_content_width: None,
|
|
},
|
|
SelectionKind::Linear,
|
|
);
|
|
agent.table_selection_geometry = Some(TableSelectionGeometry {
|
|
entry_idx: 0,
|
|
range_id: 0,
|
|
geometry: table_geometry(),
|
|
});
|
|
agent.selection_created_at = Some(Instant::now());
|
|
assert!(agent.persistent_text_selection.is_some(), "setup: held");
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(10, 17)), ®);
|
|
|
|
assert!(agent.persistent_text_selection.is_none(), "highlight gone");
|
|
assert!(agent.table_selection_geometry.is_none());
|
|
assert!(agent.selection_created_at.is_none());
|
|
assert!(agent.deferred_text_press.is_some(), "latch still arms");
|
|
}
|
|
|
|
/// A press on a recap block's non-selectable rows rides the in-pane
|
|
/// deferred path (recap is a scrollback entry, not a strip): dragging
|
|
/// up into message text converts at the entry point, and a motionless
|
|
/// press still feeds the normal scrollback click cascade.
|
|
#[test]
|
|
fn recap_block_press_converts_on_text_entry_and_clicks_as_before() {
|
|
let mut agent = agent_with_chrome_and_gap();
|
|
let reg = ActionRegistry::defaults();
|
|
// Real entries so the click cascade has something to resolve; the
|
|
// recap's rows 12-14 register no selectable lines here, modeling a
|
|
// press on its non-selectable header chrome (the summary body IS
|
|
// selection-registered in production).
|
|
for _ in 0..2 {
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message("m"));
|
|
}
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::session_event(
|
|
crate::scrollback::blocks::SessionEvent::Recap {
|
|
summary: "did things".to_string(),
|
|
auto: true,
|
|
},
|
|
));
|
|
agent
|
|
.last_scrollback_selection_model
|
|
.visible_blocks
|
|
.push(VisibleBlockGeometry {
|
|
entry_idx: 2,
|
|
area: Rect::new(0, 12, 46, 3),
|
|
content_area: Rect::new(0, 12, 46, 3),
|
|
selection_area: Rect::new(0, 12, 46, 3),
|
|
content_width: 46,
|
|
top_clipped: false,
|
|
bottom_clipped: false,
|
|
drag_startable: true,
|
|
});
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(6, 13)), ®);
|
|
assert_eq!(agent.deferred_text_press, Some((6, 13)));
|
|
assert!(
|
|
agent.pending_scrollback_click.is_some(),
|
|
"click still pends"
|
|
);
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(14, 6)), ®);
|
|
let drag = agent.drag_selection.expect("converted on text entry");
|
|
assert_eq!(drag.anchor.entry_idx, 0);
|
|
assert_eq!(drag.anchor.block_line_idx, 1);
|
|
assert_eq!(drag.anchor.col_within_range, 14);
|
|
assert!(agent.pending_block_drag.is_none(), "block drag cancelled");
|
|
assert!(agent.block_drag_selection.is_none());
|
|
|
|
// Motionless press + release on the recap, against the REAL layout
|
|
// (prepare_layout populates entry_index_at_screen_row): the click
|
|
// cascade still selects the recap entry — the latch must not eat it.
|
|
let mut agent2 = make_agent();
|
|
agent2.pane_areas.scrollback = Rect::new(0, 0, 80, 24);
|
|
agent2.active_pane = AgentPane::Scrollback;
|
|
for _ in 0..2 {
|
|
agent2
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message("m"));
|
|
}
|
|
agent2
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::session_event(
|
|
crate::scrollback::blocks::SessionEvent::Recap {
|
|
summary: "did things".to_string(),
|
|
auto: true,
|
|
},
|
|
));
|
|
agent2.scrollback.prepare_layout(80, 24);
|
|
let (recap_area, _, _) = agent2
|
|
.scrollback
|
|
.entry_screen_area(2, agent2.pane_areas.scrollback)
|
|
.expect("recap entry laid out");
|
|
|
|
let _ = agent2.handle_input(&Event::Mouse(mouse_down(6, recap_area.y)), ®);
|
|
assert!(agent2.deferred_text_press.is_some(), "latch armed on recap");
|
|
let _ = agent2.handle_input(&Event::Mouse(mouse_up(6, recap_area.y)), ®);
|
|
|
|
assert_eq!(
|
|
agent2.scrollback.selected(),
|
|
Some(2),
|
|
"the recap click still selects the recap entry"
|
|
);
|
|
assert!(agent2.pending_scrollback_click.is_none(), "click consumed");
|
|
assert!(agent2.drag_selection.is_none());
|
|
assert!(agent2.deferred_text_press.is_none());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// drag-autoscroll bounce tests (tick + reclamp interplay)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Agent over real scrollback content taller than its viewport (30
|
|
/// one-line messages through the real layout; pane rows 0-9, prompt at
|
|
/// rows 14-16 so rows 10-13 are the strip band), so
|
|
/// `tick_drag_autoscroll` moves real offsets against real clamps.
|
|
fn agent_with_tall_scrollback() -> AgentView {
|
|
let mut agent = make_agent();
|
|
agent.pane_areas.scrollback = Rect::new(0, 0, 80, 10);
|
|
agent.pane_areas.prompt = Rect::new(0, 14, 80, 3);
|
|
agent.active_pane = AgentPane::Scrollback;
|
|
for i in 0..30 {
|
|
agent
|
|
.scrollback
|
|
.push_block(crate::scrollback::block::RenderBlock::agent_message(
|
|
format!("line {i}"),
|
|
));
|
|
}
|
|
agent.scrollback.prepare_layout(80, 10);
|
|
agent
|
|
}
|
|
|
|
/// A held pointer (no motion events) in the bottom edge zone — and
|
|
/// equally on a strip row below the pane — must scroll monotonically
|
|
/// down and stop dead at the clamp: the direction is only ever written
|
|
/// by motion handlers, so ticks alone can never flip it or oscillate
|
|
/// at the boundary. The per-tick reclamp must stay a pure head snap
|
|
/// (it never scrolls).
|
|
#[test]
|
|
fn held_pointer_autoscroll_is_monotonic_and_clamp_stable() {
|
|
let mut agent = agent_with_tall_scrollback();
|
|
let (_, viewport, total) = agent.scrollback.scroll_info();
|
|
let max_offset = total - viewport as usize;
|
|
|
|
// Row 9 = pane bottom edge zone; row 12 = strip band below it.
|
|
for row in [9u16, 12] {
|
|
agent.scrollback.scroll_up(10_000);
|
|
agent.drag_autoscroll = compute_autoscroll(row, agent.pane_areas.scrollback);
|
|
let armed = agent.drag_autoscroll.expect("edge zone arms autoscroll");
|
|
assert_eq!(armed.direction, AutoScrollDirection::Down);
|
|
|
|
let mut prev = agent.scrollback.scroll_info().0;
|
|
let mut clamped_ticks = 0;
|
|
for _ in 0..200 {
|
|
agent.tick_drag_autoscroll();
|
|
agent.reclamp_drag_head_post_render(false);
|
|
let now = agent.scrollback.scroll_info().0;
|
|
assert!(now >= prev, "offset regressed: {now} < {prev} (row {row})");
|
|
clamped_ticks = if now == prev { clamped_ticks + 1 } else { 0 };
|
|
prev = now;
|
|
assert_eq!(
|
|
agent.drag_autoscroll,
|
|
Some(armed),
|
|
"ticks must never rewrite the autoscroll state"
|
|
);
|
|
}
|
|
assert_eq!(prev, max_offset, "reached the bottom clamp (row {row})");
|
|
assert!(clamped_ticks >= 100, "held flat at the clamp, no wobble");
|
|
}
|
|
|
|
// Above the top edge: the mirror run scrolls monotonically up and
|
|
// holds at offset 0.
|
|
agent.scrollback.scroll_down(10_000);
|
|
agent.drag_autoscroll = compute_autoscroll(0, agent.pane_areas.scrollback);
|
|
assert_eq!(
|
|
agent.drag_autoscroll.map(|a| a.direction),
|
|
Some(AutoScrollDirection::Up)
|
|
);
|
|
let mut prev = agent.scrollback.scroll_info().0;
|
|
for _ in 0..200 {
|
|
agent.tick_drag_autoscroll();
|
|
agent.reclamp_drag_head_post_render(false);
|
|
let now = agent.scrollback.scroll_info().0;
|
|
assert!(now <= prev, "offset regressed upward: {now} > {prev}");
|
|
prev = now;
|
|
}
|
|
assert_eq!(prev, 0, "held at the top clamp");
|
|
}
|
|
|
|
/// The strip conversion landing on the bottommost text row (inside the
|
|
/// edge zone): with content already at-bottom the clamped offset must
|
|
/// not move and the reclamped head must not wobble; with room to
|
|
/// scroll, tick + per-frame reclamp advance offset and head
|
|
/// monotonically — the reclamp never amplifies the scroll into
|
|
/// oscillation.
|
|
#[test]
|
|
fn conversion_at_bottom_edge_ticks_without_oscillation() {
|
|
let mut agent = agent_with_tall_scrollback();
|
|
let reg = ActionRegistry::defaults();
|
|
|
|
// Content at-bottom; bottom two viewport rows carry text.
|
|
agent.scrollback.scroll_down(10_000);
|
|
let clamped = agent.scrollback.scroll_info().0;
|
|
agent.last_scrollback_selection_model = stacked_lines_model(0, 8, 2);
|
|
|
|
// Real gesture: strip press, drag up onto the bottom text row
|
|
// (conversion), then a held motion there arms Down autoscroll.
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 12)), ®);
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(2, 9)), ®);
|
|
let drag = agent.drag_selection.expect("converted on the bottom row");
|
|
assert_eq!(drag.anchor.block_line_idx, 1, "anchored on the edge row");
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_drag(2, 9)), ®);
|
|
assert_eq!(
|
|
agent.drag_autoscroll.map(|a| a.direction),
|
|
Some(AutoScrollDirection::Down),
|
|
"bottom-row anchor arms Down, same as main's bottom-row drags"
|
|
);
|
|
|
|
let head_before = agent.drag_selection.unwrap().head;
|
|
for _ in 0..50 {
|
|
agent.tick_drag_autoscroll();
|
|
agent.reclamp_drag_head_post_render(false);
|
|
assert_eq!(
|
|
agent.scrollback.scroll_info().0,
|
|
clamped,
|
|
"clamped content must not move under a held pointer"
|
|
);
|
|
assert_eq!(
|
|
agent.drag_selection.unwrap().head,
|
|
head_before,
|
|
"head must not wobble while nothing moved"
|
|
);
|
|
}
|
|
|
|
// With room to scroll: rebuild the model each tick the way render
|
|
// does (content shifted up under the held pointer) and require both
|
|
// offset and head to advance monotonically.
|
|
agent.scrollback.scroll_up(10_000);
|
|
let mut prev_offset = agent.scrollback.scroll_info().0;
|
|
let mut prev_head = 0usize;
|
|
if let Some(ref mut d) = agent.drag_selection {
|
|
d.anchor.block_line_idx = 0;
|
|
d.head.block_line_idx = 0;
|
|
}
|
|
for _ in 0..50 {
|
|
agent.tick_drag_autoscroll();
|
|
let offset = agent.scrollback.scroll_info().0;
|
|
assert!(offset >= prev_offset, "offset regressed mid-autoscroll");
|
|
prev_offset = offset;
|
|
// The frame under a held pointer: rows 0-9 now show block lines
|
|
// offset..offset+10.
|
|
agent.last_scrollback_selection_model = stacked_lines_model(offset, 0, 10);
|
|
agent.reclamp_drag_head_post_render(false);
|
|
let head = agent.drag_selection.unwrap().head.block_line_idx;
|
|
assert!(head >= prev_head, "head bounced back: {head} < {prev_head}");
|
|
prev_head = head;
|
|
}
|
|
assert!(prev_head > 0, "head advanced with the scrolled content");
|
|
}
|
|
|
|
/// A wheel-up mid-drag while Down autoscroll is armed: each writer
|
|
/// moves the offset once per its own event (the wheel never rewrites
|
|
/// the autoscroll state, ticks never re-apply the wheel), so there is
|
|
/// no feedback loop — after the wheel stops, ticks settle the offset
|
|
/// back at the clamp.
|
|
#[test]
|
|
fn wheel_up_during_autoscroll_down_settles_without_feedback() {
|
|
let mut agent = agent_with_tall_scrollback();
|
|
agent.scrollback.scroll_up(10_000);
|
|
agent.drag_autoscroll = compute_autoscroll(9, agent.pane_areas.scrollback);
|
|
let armed = agent.drag_autoscroll.expect("Down armed");
|
|
|
|
for _ in 0..10 {
|
|
agent.tick_drag_autoscroll();
|
|
}
|
|
let mid = agent.scrollback.scroll_info().0;
|
|
assert!(mid > 0, "setup: autoscroll moved off the top");
|
|
|
|
agent.handle_scroll(-3, 5, 5);
|
|
let after_wheel = agent.scrollback.scroll_info().0;
|
|
assert_eq!(after_wheel, mid - 3, "the wheel wrote exactly once");
|
|
assert_eq!(
|
|
agent.drag_autoscroll,
|
|
Some(armed),
|
|
"the wheel must not rewrite the autoscroll state"
|
|
);
|
|
|
|
let (_, viewport, total) = agent.scrollback.scroll_info();
|
|
let max_offset = total - viewport as usize;
|
|
let mut prev = after_wheel;
|
|
for _ in 0..200 {
|
|
agent.tick_drag_autoscroll();
|
|
agent.reclamp_drag_head_post_render(false);
|
|
let now = agent.scrollback.scroll_info().0;
|
|
assert!(
|
|
now >= prev,
|
|
"offset regressed after the wheel: {now} < {prev}"
|
|
);
|
|
prev = now;
|
|
}
|
|
assert_eq!(prev, max_offset, "ticks settle back at the clamp");
|
|
}
|
|
|
|
/// Btw presses never arm the deferred latch: the panel keeps its exact
|
|
/// hitbox (a press on its non-text area arms nothing).
|
|
#[test]
|
|
fn btw_press_does_not_arm_deferred_latch() {
|
|
let mut agent = make_agent();
|
|
let reg = ActionRegistry::defaults();
|
|
agent.last_btw_area = Rect::new(10, 10, 30, 6);
|
|
|
|
let _ = agent.handle_input(&Event::Mouse(mouse_down(12, 12)), ®);
|
|
|
|
assert!(agent.deferred_text_press.is_none(), "btw stays exact-only");
|
|
assert!(agent.pending_block_drag.is_none());
|
|
assert!(agent.pending_text_drag.is_none());
|
|
}
|
|
}
|