use crate::{
core::extensions::{self, OverlayEvent, OverlayKey, OverlaySize, OverlayView},
modes::interactive::{
app::App, editor::Editor, line_input::InputBox, render::messages::MessageCache,
},
utils::display::char_width,
};
use ratatui::{layout::Rect, text::Line};
pub(crate) const EDITOR_CHROME_ROWS: usize = 2;
pub(crate) const PANEL_BORDER_ROWS: usize = 2;
const DRAG_EDGE_STEP: usize = 2;
const EDGE_DWELL_TICKS: u8 = 3;
#[derive(Debug, Clone, Default)]
pub struct OverlaySel {
pub sel: Option<(usize, usize, usize, usize)>,
pub dragging: bool,
pub drag_row: Option<u16>,
pub edge_dwell: u8,
pub content_y: u16,
pub content_rows: u16,
pub text: Vec<String>,
pub scroll_x: Option<u16>,
pub scroll_thumb: Option<(usize, usize)>,
pub scroll_dragging: bool,
pub scroll_press: Option<(u16, usize)>,
}
pub struct OverlayState {
pub id: u64,
pub view: OverlayView,
pub scroll: usize,
pub input: InputBox,
pub editor: Editor,
editor_generation: Option<u64>,
pub input_active: bool,
pub area: Option<Rect>,
pub content_rows: usize,
last_selected: Option<usize>,
prev_focus_request: bool,
pub message_lines: Vec<Line<'static>>,
msg_cache: MessageCache,
pub sel: OverlaySel,
}
impl OverlayState {
fn new(id: u64, view: OverlayView) -> Self {
let mut input = InputBox::new();
if let Some(i) = &view.input {
input.set_value(&i.value);
}
let mut editor = Editor::new();
editor.clear();
OverlayState {
id,
view,
scroll: 0,
input,
editor,
editor_generation: None,
input_active: false,
area: None,
content_rows: 1,
last_selected: None,
prev_focus_request: false,
message_lines: Vec::new(),
msg_cache: MessageCache::default(),
sel: OverlaySel::default(),
}
}
pub fn len(&self) -> usize {
self.view.lines.len() + self.message_lines.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn viewport(&self) -> usize {
self.content_rows.max(1)
}
pub fn max_scroll(&self) -> usize {
self.len().saturating_sub(self.viewport())
}
pub fn scrollable(&self) -> bool {
self.len() > self.viewport()
}
pub fn input_text(&self) -> Option<String> {
self.view.input.as_ref().map(|_| self.input.value.clone())
}
pub fn has_editor(&self) -> bool {
self.view.editor.is_some()
}
fn content_text_at(&self, g: usize) -> Option<String> {
if g < self.view.lines.len() {
return Some(
self.view.lines[g]
.iter()
.map(|s| s.text.as_str())
.collect::<String>(),
);
}
let i = g - self.view.lines.len();
self.message_lines.get(i).map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
}
}
pub fn open(st: &mut App, id: u64, view: OverlayView) {
if let Some(old) = st.overlay.as_ref().map(|o| o.id)
&& old != id
{
extensions::overlay_event(old, OverlayEvent::Closed);
}
st.overlay = Some(OverlayState::new(id, view));
st.dirty = true;
}
pub fn close(st: &mut App) {
if let Some(o) = st.overlay.take() {
extensions::overlay_event(o.id, OverlayEvent::Closed);
st.dirty = true;
}
}
pub fn sync(st: &mut App, cols: u16) {
let Some(id) = st.overlay.as_ref().map(|o| o.id) else {
return;
};
let Some(view) = extensions::overlay_view(id, cols) else {
close(st);
return;
};
let avail = (cols as usize).saturating_sub(1).max(1);
let App {
overlay,
theme,
expand_all,
show_thinking,
dirty,
..
} = st;
let Some(state) = overlay.as_mut() else {
return;
};
let prev_len = state.len();
let was_at_bottom = prev_len > state.viewport() && state.scroll + state.viewport() >= prev_len;
let mut cache = std::mem::take(&mut state.msg_cache);
let msg_lines = cache.render(&view.messages, avail, theme, *expand_all, *show_thinking);
state.msg_cache = cache;
let rows = match view.size {
OverlaySize::Inline { max_rows } => {
inline_content_rows(&view, msg_lines.len(), max_rows, 0)
}
OverlaySize::Panel { max_rows } => {
inline_content_rows(&view, msg_lines.len(), max_rows, PANEL_BORDER_ROWS)
}
OverlaySize::Fullscreen => state.content_rows.max(1),
};
match view.editor.as_ref().map(|e| e.generation) {
Some(generation) if state.editor_generation != Some(generation) => {
if let Some(ed) = &view.editor {
state.editor.clear();
state.editor.insert_text(&ed.value);
state.editor.cursor_line = 0;
state.editor.cursor_col = 0;
}
state.editor_generation = Some(generation);
}
Some(_) => {}
None => state.editor_generation = None,
}
if let (Some(ext_input), true) = (&view.input, !state.input_active) {
let cur = state.input.value.clone();
if ext_input.value != cur {
state.input.set_value(&ext_input.value);
}
}
let gained_input = state.view.input.is_none() && view.input.is_some();
let lost_input = state.view.input.is_some() && view.input.is_none();
let focus_edge = view.input_focus != state.prev_focus_request;
let focus_request = view.input_focus;
state.view = view;
state.message_lines = msg_lines;
state.content_rows = rows.max(1);
if lost_input {
state.input_active = false;
state.input.clear();
}
if gained_input {
state.input_active = true;
}
if focus_edge {
state.input_active = focus_request;
state.prev_focus_request = focus_request;
}
update_scroll(state, was_at_bottom);
*dirty = true;
}
pub fn update_scroll(state: &mut OverlayState, was_at_bottom: bool) {
let viewport = state.viewport();
if was_at_bottom {
state.scroll = state.max_scroll();
} else if let Some(sel) = state.view.selected
&& state.last_selected != Some(sel)
{
if sel < state.scroll {
state.scroll = sel;
} else if sel >= state.scroll + viewport {
state.scroll = sel + 1 - viewport;
}
}
state.last_selected = state.view.selected;
state.scroll = state.scroll.min(state.max_scroll());
}
pub fn outer_rows(st: &App) -> usize {
let Some(o) = st.overlay.as_ref() else {
return 0;
};
match o.view.size {
OverlaySize::Fullscreen => 0,
OverlaySize::Inline { max_rows } => {
(o.len() + chrome_rows(&o.view, 0)).min(max_rows.max(3) as usize)
}
OverlaySize::Panel { max_rows } => {
(o.len() + chrome_rows(&o.view, PANEL_BORDER_ROWS)).min(max_rows.max(3) as usize)
}
}
}
fn chrome_rows(view: &OverlayView, border_rows: usize) -> usize {
let editor = view
.editor
.as_ref()
.map(|e| EDITOR_CHROME_ROWS + e.rows as usize)
.unwrap_or(0);
let title_or_blanks = if border_rows > 0 { 3 } else { 1 };
border_rows
+ title_or_blanks
+ usize::from(!view.footer.is_empty())
+ usize::from(view.input.is_some())
+ editor
}
fn inline_content_rows(
view: &OverlayView,
message_rows: usize,
max_rows: u16,
border_rows: usize,
) -> usize {
(max_rows.max(3) as usize)
.saturating_sub(chrome_rows(view, border_rows))
.max(1)
.min((view.lines.len() + message_rows).max(1))
}
pub fn submit_input(st: &mut App) {
let Some(state) = st.overlay.as_mut() else {
return;
};
let text = state.input.value.clone();
let id = state.id;
state.input.clear();
state.input_active = state.view.input_focus;
extensions::overlay_event(id, OverlayEvent::Submit { text });
st.dirty = true;
}
pub fn submit_editor(st: &mut App) {
let Some(state) = st.overlay.as_ref() else {
return;
};
let text = state.editor.expanded_text();
let id = state.id;
extensions::overlay_event(id, OverlayEvent::EditorSubmit { text });
st.dirty = true;
}
pub fn send_key(st: &mut App, key: OverlayKey) -> bool {
let Some((id, input)) = st.overlay.as_ref().map(|s| (s.id, s.input_text())) else {
return false;
};
let consumed = extensions::overlay_event(id, OverlayEvent::Key { key, input });
if consumed {
st.dirty = true;
}
consumed
}
pub fn scroll_by(st: &mut App, delta: isize) {
let Some(state) = st.overlay.as_mut() else {
return;
};
let max = state.max_scroll() as isize;
let next = (state.scroll as isize + delta).clamp(0, max);
if next as usize != state.scroll {
state.scroll = next as usize;
st.dirty = true;
}
}
pub fn captures_mouse(st: &App, col: u16, row: u16) -> bool {
let Some(o) = st.overlay.as_ref() else {
return false;
};
if o.view.size == OverlaySize::Fullscreen {
return true;
}
o.area
.is_some_and(|a| col >= a.x && col < a.x + a.width && row >= a.y && row < a.y + a.height)
}
pub fn left_down(st: &mut App, col: u16, row: u16) {
let Some(state) = st.overlay.as_mut() else {
return;
};
if state.has_editor() {
return;
}
state.sel.dragging = false;
state.sel.scroll_dragging = false;
state.sel.scroll_press = None;
state.sel.drag_row = None;
state.sel.edge_dwell = 0;
if state.sel.scroll_x == Some(col) {
state.sel.sel = None;
state.sel.scroll_dragging = true;
if scrollbar_press_on_thumb(state, row) {
let off = row
.saturating_sub(state.sel.content_y)
.saturating_sub(state.sel.scroll_thumb.map(|(s, _)| s as u16).unwrap_or(0));
state.sel.scroll_press = Some((row, off as usize));
} else {
scrollbar_click_jump(state, row);
}
st.dirty = true;
return;
}
match hit(state, col, row) {
Some((line, ch)) => {
state.sel.sel = Some((line, ch, line, ch));
state.sel.dragging = true;
}
None => state.sel.sel = None,
}
st.dirty = true;
}
pub fn left_drag(st: &mut App, col: u16, row: u16) {
let Some(state) = st.overlay.as_mut() else {
return;
};
if state.has_editor() {
return;
}
if state.sel.scroll_dragging {
if state.sel.scroll_press.is_some() {
scrollbar_drag_to(state, row);
}
st.dirty = true;
return;
}
if !state.sel.dragging {
return;
}
state.sel.drag_row = Some(row);
let y = state.sel.content_y;
let h = state.sel.content_rows.max(1);
let at_edge = row <= y || row.saturating_add(1) >= y + h;
if !at_edge {
state.sel.edge_dwell = 0;
}
if let Some((line, ch)) = hit(state, col, row) {
set_drag_point(state, line, ch);
st.dirty = true;
return;
}
if (row < y && scroll_edge(state, true)) || (row >= y + h && scroll_edge(state, false)) {
st.dirty = true;
}
}
pub fn left_up(st: &mut App) {
let Some(state) = st.overlay.as_mut() else {
return;
};
state.sel.dragging = false;
state.sel.scroll_dragging = false;
state.sel.scroll_press = None;
state.sel.drag_row = None;
state.sel.edge_dwell = 0;
st.dirty = true;
}
pub fn is_dragging(st: &App) -> bool {
st.overlay
.as_ref()
.is_some_and(|o| o.sel.dragging || o.sel.scroll_dragging)
}
pub fn clear_selection(st: &mut App) {
if let Some(state) = st.overlay.as_mut() {
state.sel.sel = None;
state.sel.dragging = false;
}
st.dirty = true;
}
pub fn input_focused(st: &App) -> bool {
st.overlay
.as_ref()
.is_some_and(|o| o.view.input.is_some() && o.input_active)
}
pub fn paste_into_input(st: &mut App, text: &str) {
let Some(state) = st.overlay.as_mut() else {
return;
};
if state.view.input.is_some() && state.input_active {
let flat = text.replace("\r\n", " ").replace(['\r', '\n'], " ");
state.input.insert_text(&flat);
st.dirty = true;
}
}
pub fn selected_text(st: &App) -> Option<String> {
let state = st.overlay.as_ref()?;
let (ls, cs, le, ce) = state.sel.sel?;
let (ls, cs, le, ce) = if (ls, cs) <= (le, ce) {
(ls, cs, le, ce)
} else {
(le, ce, ls, cs)
};
let mut out = String::new();
for g in ls..=le {
let Some(text) = state.content_text_at(g) else {
continue;
};
let chars: Vec<char> = text.chars().collect();
let a = if g == ls { cs.min(chars.len()) } else { 0 };
let b = if g == le {
ce.min(chars.len())
} else {
chars.len()
};
if a >= b {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.extend(chars[a..b].iter());
}
(!out.is_empty()).then_some(out)
}
pub fn auto_scroll_tick(st: &mut App) -> bool {
let Some(state) = st.overlay.as_mut() else {
return false;
};
if !state.sel.dragging {
state.sel.edge_dwell = 0;
return false;
}
let Some(row) = state.sel.drag_row else {
state.sel.edge_dwell = 0;
return false;
};
let y = state.sel.content_y;
let h = state.sel.content_rows.max(1);
let at_top = row <= y;
let at_bottom = row.saturating_add(1) >= y + h;
if !at_top && !at_bottom {
state.sel.edge_dwell = 0;
return false;
}
if state.sel.edge_dwell < EDGE_DWELL_TICKS {
state.sel.edge_dwell += 1;
return false;
}
scroll_edge(state, at_top)
}
fn hit(state: &OverlayState, col: u16, row: u16) -> Option<(usize, usize)> {
let y = state.sel.content_y;
let h = state.sel.content_rows.max(1);
if row < y || row >= y + h {
return None;
}
let line = (row - y) as usize;
let text = state.sel.text.get(line)?;
let mut w = 0usize;
for (i, c) in text.chars().enumerate() {
if col as usize <= w {
return Some((state.scroll + line, i));
}
w += char_width(c);
}
Some((state.scroll + line, text.chars().count()))
}
fn set_drag_point(state: &mut OverlayState, line: usize, ch: usize) {
if let Some((ls, cs, _, _)) = state.sel.sel {
state.sel.sel = Some((ls, cs, line, ch));
}
}
fn scroll_edge(state: &mut OverlayState, up: bool) -> bool {
let max = state.max_scroll();
if up {
if state.scroll == 0 {
return false;
}
state.scroll = state.scroll.saturating_sub(DRAG_EDGE_STEP);
let start = state.scroll;
set_drag_point(state, start, 0);
} else {
if state.scroll >= max {
return false;
}
state.scroll = (state.scroll + DRAG_EDGE_STEP).min(max);
let end_line = (state.scroll + state.viewport()).saturating_sub(1);
set_drag_point(state, end_line, usize::MAX);
}
true
}
fn scrollbar_press_on_thumb(state: &OverlayState, row: u16) -> bool {
let Some((start, len)) = state.sel.scroll_thumb else {
return false;
};
let rel = row
.saturating_sub(state.sel.content_y)
.min(state.sel.content_rows.saturating_sub(1)) as usize;
rel >= start && rel < start + len
}
fn scroll_for_thumb_top(state: &OverlayState, rel: usize) -> usize {
let track = state.sel.content_rows.max(1) as usize;
let total = state.len();
let max_scroll = state.max_scroll();
if max_scroll == 0 || total <= 1 {
return 0;
}
let denom = (total - 1 + track).max(1);
let thumb_len =
((track as f64 * track as f64 / denom as f64).round() as usize).clamp(1, track.max(1));
let max_start = track.saturating_sub(thumb_len);
let rel = rel.min(max_start);
if rel >= max_start {
return max_scroll;
}
let position = (rel * denom) as f64 / track as f64;
let scroll = (position * max_scroll as f64 / (total - 1) as f64).round() as usize;
scroll.min(max_scroll)
}
fn scrollbar_click_jump(state: &mut OverlayState, row: u16) {
let rel = row.saturating_sub(state.sel.content_y) as usize;
let scroll = scroll_for_thumb_top(state, rel);
state.scroll = scroll;
state.sel.scroll_press = Some((row, 0));
}
fn scrollbar_drag_to(state: &mut OverlayState, row: u16) {
let Some((_, grab_offset)) = state.sel.scroll_press else {
return;
};
let rel = row
.saturating_sub(state.sel.content_y)
.saturating_sub(grab_offset as u16) as usize;
let scroll = scroll_for_thumb_top(state, rel);
state.scroll = scroll;
}
pub fn record_content_geometry(
state: &mut OverlayState,
content_y: u16,
content_rows: u16,
text: Vec<String>,
) {
state.sel.content_y = content_y;
state.sel.content_rows = content_rows;
state.sel.text = text;
}
pub fn record_scrollbar(
state: &mut OverlayState,
scroll_x: Option<u16>,
thumb: Option<(usize, usize)>,
) {
state.sel.scroll_x = scroll_x;
state.sel.scroll_thumb = thumb;
}
pub fn selection_range(state: &OverlayState) -> Option<(usize, usize, usize, usize)> {
let (ls, cs, le, ce) = state.sel.sel?;
Some(if (ls, cs) <= (le, ce) {
(ls, cs, le, ce)
} else {
(le, ce, ls, cs)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::extensions::{DockSpan, OverlayEditor, OverlayInput, OverlayView};
fn view(lines: usize, selected: Option<usize>) -> OverlayView {
OverlayView {
title: "Agents".to_string(),
lines: (0..lines)
.map(|i| vec![DockSpan::plain(format!("line{i}"))])
.collect(),
footer: vec![("Esc".to_string(), "close".to_string())],
input: None,
editor: None,
size: OverlaySize::Inline { max_rows: 6 },
selected,
input_focus: false,
..OverlayView::inline("x", Vec::new(), 6)
}
}
fn with_overlay(id: u64, v: OverlayView) -> App {
let mut st = App::new();
open(&mut st, id, v);
st
}
#[test]
fn inline_rows_account_for_chrome_and_cap() {
let v = view(20, None);
assert_eq!(inline_content_rows(&v, 0, 6, 0), 4);
let mut no_footer = v.clone();
no_footer.footer.clear();
assert_eq!(inline_content_rows(&no_footer, 0, 6, 0), 5);
let mut with_input = no_footer.clone();
with_input.input = Some(OverlayInput {
label: "steer>".to_string(),
value: String::new(),
placeholder: String::new(),
});
assert_eq!(inline_content_rows(&with_input, 0, 6, 0), 4);
let short = view(2, None);
assert_eq!(inline_content_rows(&short, 0, 6, 0), 2);
assert_eq!(inline_content_rows(&short, 1, 6, 0), 3);
assert_eq!(inline_content_rows(&short, 3, 6, 0), 4);
assert_eq!(inline_content_rows(&v, 0, 6, PANEL_BORDER_ROWS), 1);
assert_eq!(inline_content_rows(&v, 0, 12, PANEL_BORDER_ROWS), 6);
}
#[test]
fn scroll_clamps_and_tracks_selection() {
let mut st = with_overlay(1, view(20, None));
{
let o = st.overlay.as_mut().unwrap();
o.content_rows = 4;
assert_eq!(o.max_scroll(), 16);
assert!(o.scrollable());
o.scroll = 99;
}
scroll_by(&mut st, 0);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 16, "滚轮越界夹回");
scroll_by(&mut st, -3);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 13);
}
#[test]
fn stick_to_bottom_only_when_already_at_bottom() {
let mut st = with_overlay(1, view(20, None));
{
let o = st.overlay.as_mut().unwrap();
o.content_rows = 5;
o.scroll = 15; }
update_scroll(st.overlay.as_mut().unwrap(), true);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 15);
{
let o = st.overlay.as_mut().unwrap();
o.view
.lines
.extend((0..5).map(|i| vec![DockSpan::plain(format!("extra{i}"))]));
}
update_scroll(st.overlay.as_mut().unwrap(), true);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 20, "贴底跟随新行");
let mut st = with_overlay(2, view(20, None));
st.overlay.as_mut().unwrap().content_rows = 6;
st.overlay.as_mut().unwrap().scroll = 3;
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 3, "上滚后不再跟随");
let mut st = with_overlay(3, view(20, Some(12)));
st.overlay.as_mut().unwrap().content_rows = 5;
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 8, "选中行贴底对齐");
st.overlay.as_mut().unwrap().scroll = 10;
st.overlay.as_mut().unwrap().view.selected = Some(3);
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 3);
}
#[test]
fn manual_scroll_is_not_yanked_back_to_unchanged_selection() {
let mut st = with_overlay(1, view(40, Some(20)));
st.overlay.as_mut().unwrap().content_rows = 5;
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 16);
st.overlay.as_mut().unwrap().scroll = 0;
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 0, "选中行未变,不跟随");
st.overlay.as_mut().unwrap().view.selected = Some(30);
update_scroll(st.overlay.as_mut().unwrap(), false);
assert_eq!(st.overlay.as_ref().unwrap().scroll, 26);
}
#[test]
fn outer_rows_accounts_for_the_editor_region() {
let mut v = view(3, None);
v.editor = Some(OverlayEditor {
label: "file".to_string(),
value: String::new(),
placeholder: String::new(),
rows: 4,
generation: 1,
});
let st = with_overlay(1, v);
assert_eq!(outer_rows(&st), 6);
}
#[test]
fn consumed_key_marks_the_app_dirty() {
use crate::core::extensions::{
Extension, ExtensionHook, register_extension, unregister_extension,
};
struct ConsumingExt;
impl Extension for ConsumingExt {
fn name(&self) -> &str {
"overlay-dirty-test-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::Overlay]
}
fn overlay_view(&self, id: u64, _cols: u16) -> Option<OverlayView> {
(id == 77).then(|| OverlayView::inline("t", Vec::new(), 5))
}
fn on_overlay_event(&self, id: u64, ev: &OverlayEvent) -> bool {
id == 77 && matches!(ev, OverlayEvent::Key { .. })
}
}
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
register_extension(ConsumingExt);
let mut st = App::new();
open(&mut st, 77, OverlayView::inline("t", Vec::new(), 5));
st.dirty = false;
assert!(send_key(&mut st, OverlayKey::Down), "扩展应消费按键");
assert!(st.dirty, "被消费的按键必须触发重绘");
let mut st2 = App::new();
open(&mut st2, 78, OverlayView::inline("t", Vec::new(), 5));
st2.dirty = false;
let consumed = send_key(&mut st2, OverlayKey::Down);
assert_eq!(st2.dirty, consumed, "dirty 只在被消费时置位");
unregister_extension("overlay-dirty-test-ext");
}
#[test]
fn submit_sends_current_input_text_and_resets_the_box() {
let mut v = view(3, None);
v.input = Some(OverlayInput {
label: "steer>".to_string(),
value: String::new(),
placeholder: "type…".to_string(),
});
let mut st = with_overlay(42, v);
st.overlay.as_mut().unwrap().input.insert_text("hello");
st.overlay.as_mut().unwrap().input_active = true;
assert_eq!(
st.overlay.as_ref().unwrap().input_text().as_deref(),
Some("hello")
);
submit_input(&mut st); assert_eq!(st.overlay.as_ref().unwrap().input.value, "");
assert!(!st.overlay.as_ref().unwrap().input_active);
}
#[test]
fn input_focus_request_is_applied_on_the_first_sync() {
use crate::core::extensions::{
Extension, ExtensionHook, register_extension, unregister_extension,
};
struct FocusExt;
impl Extension for FocusExt {
fn name(&self) -> &str {
"overlay-focus-test-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::Overlay]
}
fn overlay_view(&self, id: u64, _cols: u16) -> Option<OverlayView> {
(id == 1_000_077).then(|| OverlayView {
input: Some(OverlayInput {
label: "resume>".to_string(),
value: String::new(),
placeholder: String::new(),
}),
input_focus: true,
..OverlayView::inline("t", Vec::new(), 5)
})
}
}
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
register_extension(FocusExt);
let mut st = App::new();
let v = extensions::overlay_view(1_000_077, 80).expect("认领");
open(&mut st, 1_000_077, v);
sync(&mut st, 80);
assert!(
st.overlay.as_ref().unwrap().input_active,
"input_focus 应在首帧聚焦输入(直接打字就生效)"
);
unregister_extension("overlay-focus-test-ext");
}
#[test]
fn submit_keeps_focus_when_the_view_declares_input_focus() {
let mut v = view(3, None);
v.input = Some(OverlayInput {
label: "btw>".to_string(),
value: String::new(),
placeholder: String::new(),
});
v.input_focus = true;
let mut st = with_overlay(43, v);
st.overlay.as_mut().unwrap().input.insert_text("hi");
st.overlay.as_mut().unwrap().input_active = true;
submit_input(&mut st);
assert_eq!(st.overlay.as_ref().unwrap().input.value, "", "提交后清空");
assert!(
st.overlay.as_ref().unwrap().input_active,
"声明的聚焦态要在提交后保持(否则下一问要再按 i)"
);
}
#[test]
fn replaced_content_starts_at_the_top() {
use crate::core::extensions::{
Extension, ExtensionHook, register_extension, unregister_extension,
};
use std::sync::atomic::{AtomicBool, Ordering};
static READY: AtomicBool = AtomicBool::new(false);
struct GrowExt;
impl Extension for GrowExt {
fn name(&self) -> &str {
"overlay-grow-test-ext"
}
fn tools(&self) -> Vec<crate::core::extensions::ExtensionTool> {
Vec::new()
}
fn hooks(&self) -> Vec<ExtensionHook> {
vec![ExtensionHook::Overlay]
}
fn overlay_view(&self, id: u64, _cols: u16) -> Option<OverlayView> {
if id != 1_000_088 {
return None;
}
let rows = if READY.load(Ordering::SeqCst) { 40 } else { 1 };
Some(OverlayView {
lines: (0..rows)
.map(|i| vec![DockSpan::plain(format!("line-{i}"))])
.collect(),
size: OverlaySize::Panel { max_rows: 12 },
..OverlayView::inline("t", Vec::new(), 12)
})
}
}
let _auth = crate::test_support::AUTH_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
register_extension(GrowExt);
READY.store(false, Ordering::SeqCst);
let mut st = App::new();
let v = extensions::overlay_view(1_000_088, 80).expect("认领");
open(&mut st, 1_000_088, v);
sync(&mut st, 80); assert_eq!(st.overlay.as_ref().unwrap().scroll, 0);
READY.store(true, Ordering::SeqCst);
sync(&mut st, 80); let state = st.overlay.as_ref().unwrap();
assert!(state.max_scroll() > 0, "报告应超出视口(否则本用例无效)");
assert_eq!(state.scroll, 0, "内容换代后应停在报告开头");
unregister_extension("overlay-grow-test-ext");
}
#[test]
fn overlay_scrollbar_drag_maps_to_top_offset() {
let mut st = with_overlay(1, view(120, None));
{
let o = st.overlay.as_mut().unwrap();
o.content_rows = 10;
record_content_geometry(o, 0, 10, Vec::new());
}
let max = st.overlay.as_ref().unwrap().max_scroll();
{
let o = st.overlay.as_mut().unwrap();
scrollbar_click_jump(o, 0);
}
assert_eq!(st.overlay.as_ref().unwrap().scroll, 0, "track 顶 = 开头");
{
let o = st.overlay.as_mut().unwrap();
scrollbar_click_jump(o, 9);
}
assert_eq!(st.overlay.as_ref().unwrap().scroll, max, "track 底 = 结尾");
{
let o = st.overlay.as_mut().unwrap();
o.scroll = 50;
o.sel.scroll_press = Some((5, 0));
scrollbar_drag_to(o, 0);
}
assert_eq!(st.overlay.as_ref().unwrap().scroll, 0, "拖到顶 = 开头");
{
let o = st.overlay.as_mut().unwrap();
o.sel.scroll_press = Some((5, 0));
scrollbar_drag_to(o, 9);
}
assert_eq!(st.overlay.as_ref().unwrap().scroll, max, "拖到底 = 结尾");
}
#[test]
fn grabbing_the_thumb_does_not_jump() {
let mut st = with_overlay(1, view(1000, None));
{
let o = st.overlay.as_mut().unwrap();
o.content_rows = 30;
record_content_geometry(o, 0, 30, Vec::new());
}
let max = st.overlay.as_ref().unwrap().max_scroll();
let (total, track) = (1000usize, 30usize);
let denom = (total - 1 + track).max(1);
let thumb_len =
((track as f64 * track as f64 / denom as f64).round() as usize).clamp(1, track);
for scroll in [0usize, 100, 500, max] {
let position = if max == 0 {
0
} else {
((total - 1) as f64 * (scroll as f64 / max as f64)).round() as usize
};
let thumb_start = ((position as f64 * track as f64 / denom as f64).round() as usize)
.min(track - thumb_len);
{
let o = st.overlay.as_mut().unwrap();
o.scroll = scroll;
o.sel.scroll_press = Some((thumb_start as u16, 0));
scrollbar_drag_to(o, thumb_start as u16);
}
let after = st.overlay.as_ref().unwrap().scroll;
assert!(
after.abs_diff(scroll) <= 1,
"scroll={scroll} thumb_start={thumb_start} → after={after}"
);
}
}
#[test]
fn selected_text_slices_content_lines() {
let mut st = with_overlay(1, view(3, None));
{
let o = st.overlay.as_mut().unwrap();
o.sel.sel = Some((0, 5, 1, 3));
}
let text = selected_text(&st).unwrap_or_default();
assert!(text.contains("lin"), "{text:?}");
}
#[test]
fn captures_mouse_respects_overlay_size() {
let mut st = App::new();
open(
&mut st,
1,
OverlayView {
size: OverlaySize::Fullscreen,
..OverlayView::inline("t", Vec::new(), 5)
},
);
assert!(captures_mouse(&st, 0, 0), "全屏任意位置都归覆盖层");
assert!(captures_mouse(&st, 200, 200));
let mut st2 = App::new();
open(&mut st2, 1, OverlayView::inline("t", Vec::new(), 5));
st2.overlay.as_mut().unwrap().area = Some(Rect::new(0, 5, 40, 5));
assert!(captures_mouse(&st2, 10, 6), "inline 矩形内");
assert!(!captures_mouse(&st2, 10, 20), "inline 矩形外归主页");
}
}