use hjkl_engine::{Host, Query};
use ratatui::layout::Rect;
use std::time::{Duration, Instant};
use super::{App, window};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitOrientation {
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy)]
pub struct BorderHit {
pub orientation: SplitOrientation,
pub border_cell: (u16, u16),
pub split_origin: u16,
pub split_total: u16,
}
pub fn hit_test_border(app: &App, col: u16, row: u16) -> Option<BorderHit> {
let layout = app.layout();
hit_test_border_tree(layout, col, row)
}
fn hit_test_border_tree(layout: &window::LayoutTree, col: u16, row: u16) -> Option<BorderHit> {
match layout {
window::LayoutTree::Leaf(_) => None,
window::LayoutTree::Split {
dir,
ratio,
a,
b,
last_rect,
} => {
let area = (*last_rect)?;
use hjkl_layout::Axis;
let hit = match dir.axis() {
Axis::Col => {
let a_w = ((area.w as f32) * ratio).round() as u16;
let a_w = a_w.clamp(1, area.w.saturating_sub(1).max(1));
let sep_col = area.x + a_w.saturating_sub(1);
if col == sep_col && row >= area.y && row < area.y + area.h {
Some(BorderHit {
orientation: SplitOrientation::Vertical,
border_cell: (col, row),
split_origin: area.x,
split_total: area.w,
})
} else {
None
}
}
Axis::Row => {
let a_h = ((area.h as f32) * ratio).round() as u16;
let a_h = a_h.clamp(1, area.h.saturating_sub(1).max(1));
let sep_row = area.y + a_h.saturating_sub(1);
if row == sep_row && col >= area.x && col < area.x + area.w {
Some(BorderHit {
orientation: SplitOrientation::Horizontal,
border_cell: (col, row),
split_origin: area.y,
split_total: area.h,
})
} else {
None
}
}
};
if hit.is_some() {
hit
} else {
hit_test_border_tree(a, col, row).or_else(|| hit_test_border_tree(b, col, row))
}
}
_ => None,
}
}
pub fn hit_test_window(app: &App, col: u16, row: u16) -> Option<window::WindowId> {
let leaves = app.layout().leaves();
for win_id in leaves {
if let Some(Some(win)) = app.windows.get(win_id)
&& let Some(rect) = win.last_rect
&& rect_contains(rect, col, row)
{
return Some(win_id);
}
}
None
}
fn rect_contains(rect: window::LayoutRect, col: u16, row: u16) -> bool {
col >= rect.x && col < rect.x + rect.w && row >= rect.y && row < rect.y + rect.h
}
fn sign_column_width(
signcolumn: hjkl_engine::types::SignColumnMode,
has_visible_signs: bool,
) -> u16 {
match signcolumn {
hjkl_engine::types::SignColumnMode::Yes => 1,
hjkl_engine::types::SignColumnMode::No => 0,
hjkl_engine::types::SignColumnMode::Auto => {
if has_visible_signs {
1
} else {
0
}
}
}
}
fn text_start_offset(
lnum_width: u16,
signcolumn: hjkl_engine::types::SignColumnMode,
has_visible_signs: bool,
) -> u16 {
let sign_w = sign_column_width(signcolumn, has_visible_signs);
lnum_width + sign_w
}
pub fn cell_to_doc(
app: &App,
win_id: window::WindowId,
cell_x: u16,
cell_y: u16,
) -> Option<(usize, usize)> {
let win = app.windows.get(win_id)?.as_ref()?;
let rect = win.last_rect?;
if !rect_contains(rect, cell_x, cell_y) {
return None;
}
let slot_idx = win.slot;
let slot = app.slots().get(slot_idx)?;
let s = slot.editor.settings();
let line_count = slot.editor.buffer().line_count() as usize;
let vp = slot.editor.host().viewport();
let vp_top = vp.top_row;
let vp_bot = vp_top + rect.h as usize;
let has_visible_signs = slot
.diag_signs
.iter()
.chain(slot.diag_signs_lsp.iter())
.chain(slot.git_signs.iter())
.any(|sg| sg.row >= vp_top && sg.row < vp_bot);
let gw = text_start_offset(slot.editor.lnum_width(), s.signcolumn, has_visible_signs);
let rel_x = cell_x.saturating_sub(rect.x);
let rel_y = cell_y.saturating_sub(rect.y);
if rel_x < gw {
return None;
}
let text_rel_x = rel_x - gw; let visual_col = vp.top_col.saturating_add(text_rel_x as usize);
let doc_row = vp.top_row.saturating_add(rel_y as usize);
if doc_row >= line_count {
return None; }
let tab_width = vp.effective_tab_width();
let line_str = slot.editor.buffer().line(doc_row).unwrap_or_default();
let char_col = hjkl_buffer::visual_col_to_char_col(&line_str, visual_col, tab_width);
Some((doc_row, char_col))
}
#[derive(Debug, Default)]
pub struct MouseClickTracker {
last: Option<LastClick>,
}
#[derive(Debug)]
struct LastClick {
win_id: window::WindowId,
row: usize,
col: usize,
at: Instant,
count: u8,
}
const DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(500);
impl MouseClickTracker {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, win_id: window::WindowId, row: usize, col: usize) -> u8 {
let now = Instant::now();
let count = if let Some(ref last) = self.last {
if last.win_id == win_id
&& last.row == row
&& last.col == col
&& now.duration_since(last.at) <= DOUBLE_CLICK_WINDOW
{
let next = last.count + 1;
if next > 3 { 1 } else { next }
} else {
1
}
} else {
1
};
self.last = Some(LastClick {
win_id,
row,
col,
at: now,
count,
});
count
}
#[allow(dead_code)]
pub fn reset(&mut self) {
self.last = None;
}
}
pub fn word_bounds(line: &str, col: usize) -> (usize, usize) {
let chars: Vec<char> = line.chars().collect();
let len = chars.len();
if len == 0 {
return (0, 0);
}
let col = col.min(len.saturating_sub(1));
if !is_word_char(chars[col]) {
return (col, (col + 1).min(len));
}
let start = (0..=col)
.rev()
.find(|&i| !is_word_char(chars[i]))
.map(|i| i + 1)
.unwrap_or(0);
let end = (col..len).find(|&i| !is_word_char(chars[i])).unwrap_or(len);
(start, end)
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Zone {
Code {
win_id: window::WindowId,
doc_row: usize,
doc_col: usize,
},
Gutter {
win_id: window::WindowId,
doc_row: usize,
},
TabBar { tab_idx: usize },
BufferLine { slot_idx: usize },
StatusLine,
SplitBorder {
orientation: super::mouse::SplitOrientation,
border_cell: (u16, u16),
split_origin: u16,
split_total: u16,
},
PickerRow { row_idx: usize },
None,
}
pub fn tabs_total_width(app: &App) -> usize {
let mut total = 0usize;
for (i, tab) in app.tabs.iter().enumerate() {
let slot_idx = app.windows[tab.focused_window]
.as_ref()
.map(|w| w.slot)
.unwrap_or(0);
let slot = &app.slots()[slot_idx];
let base_name = slot
.filename
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("[No Name]");
let tab_dirty = tab.layout.leaves().iter().any(|&wid| {
app.windows[wid]
.as_ref()
.map(|w| app.slots()[w.slot].dirty)
.unwrap_or(false)
});
let label = if tab_dirty {
format!(" {}: {}+ ", i + 1, base_name)
} else {
format!(" {}: {} ", i + 1, base_name)
};
let sep_len = if i == 0 { 0 } else { 1 }; total += sep_len + label.len();
}
total
}
pub fn tab_x_ranges(app: &App, bar_width: u16) -> Vec<(u16, u16)> {
let total_tabs = tabs_total_width(app);
let start_x = (bar_width as usize).saturating_sub(total_tabs);
let mut ranges = Vec::new();
let mut used = start_x;
for (i, tab) in app.tabs.iter().enumerate() {
let slot_idx = app.windows[tab.focused_window]
.as_ref()
.map(|w| w.slot)
.unwrap_or(0);
let slot = &app.slots()[slot_idx];
let base_name = slot
.filename
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("[No Name]");
let tab_dirty = tab.layout.leaves().iter().any(|&wid| {
app.windows[wid]
.as_ref()
.map(|w| app.slots()[w.slot].dirty)
.unwrap_or(false)
});
let label = if tab_dirty {
format!(" {}: {}+ ", i + 1, base_name)
} else {
format!(" {}: {} ", i + 1, base_name)
};
let sep_len = if i == 0 { 0 } else { 1 }; let entry_width = sep_len + label.len();
let entry_start = (used + sep_len) as u16;
let entry_end = (used + entry_width) as u16;
ranges.push((entry_start, entry_end));
used += entry_width;
}
ranges
}
pub fn buffer_line_x_ranges(app: &App, bar_width: u16) -> Vec<(u16, u16)> {
let show_tabs = app.tabs.len() > 1;
let tabs_len = if show_tabs { tabs_total_width(app) } else { 0 };
let buf_budget = (bar_width as usize).saturating_sub(tabs_len);
let mut ranges = Vec::new();
let mut used = 0usize;
for (i, slot) in app.slots().iter().enumerate() {
let base_name = slot
.filename
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("[No Name]");
let label = if slot.dirty {
format!(" {}+ ", base_name)
} else {
format!(" {} ", base_name)
};
let sep_len = if i == 0 { 0 } else { 1 }; let entry_width = sep_len + label.len();
if used + entry_width > buf_budget {
break;
}
let start = (used + sep_len) as u16;
let end = (used + entry_width) as u16;
ranges.push((start, end));
used += entry_width;
}
ranges
}
pub fn picker_overlay_rect(app: &App) -> Option<Rect> {
app.picker.as_ref()?;
let vp = app.active().editor.host().viewport();
let show_top_bar = app.tabs.len() > 1 || app.slots().len() > 1;
let top_bar_h = if show_top_bar {
crate::app::TOP_BAR_HEIGHT
} else {
0
};
let buf_area = Rect {
x: 0,
y: top_bar_h,
width: vp.width,
height: vp.height,
};
let width = buf_area.width.saturating_mul(80) / 100;
let height = buf_area.height.saturating_mul(70) / 100;
let x = buf_area.x + (buf_area.width.saturating_sub(width)) / 2;
let y = buf_area.y + (buf_area.height.saturating_sub(height)) / 2;
Some(Rect {
x,
y,
width,
height,
})
}
pub fn hit_test_picker_row(app: &App, col: u16, row: u16) -> Option<usize> {
let area = picker_overlay_rect(app)?;
let picker = app.picker.as_ref()?;
let has_preview = picker.has_preview();
const PREVIEW_MIN_WIDTH: u16 = 80;
let left_area = if has_preview && area.width >= PREVIEW_MIN_WIDTH {
Rect {
x: area.x,
y: area.y,
width: area.width / 2,
height: area.height,
}
} else {
area
};
if !rect_contains(window::rect_to_layout(left_area), col, row) {
return None;
}
let input_h: u16 = 3;
if left_area.height <= input_h {
return None;
}
let list_y = left_area.y + input_h;
let list_h = left_area.height - input_h;
if row <= list_y || row >= list_y + list_h {
return None;
}
let item_idx = (row - list_y - 1) as usize;
let entry_count = picker.visible_entries().len();
if item_idx >= entry_count {
return None;
}
Some(item_idx)
}
pub fn hit_test_zone(app: &App, col: u16, row: u16) -> Zone {
if app.picker.is_some() {
return match hit_test_picker_row(app, col, row) {
Some(row_idx) => Zone::PickerRow { row_idx },
None => Zone::None,
};
}
let show_tab_bar = app.tabs.len() > 1;
let show_buffer_line = app.slots().len() > 1;
let show_top_bar = show_tab_bar || show_buffer_line;
let bar_width = app
.windows
.iter()
.filter_map(|w| w.as_ref())
.filter_map(|w| w.last_rect)
.map(|r| r.w)
.max()
.unwrap_or(80);
if show_top_bar && row == 0 {
if show_tab_bar {
let tab_ranges = tab_x_ranges(app, bar_width);
for (i, (start, end)) in tab_ranges.iter().enumerate() {
if col >= *start && col < *end {
return Zone::TabBar { tab_idx: i };
}
}
}
if show_buffer_line {
let buf_ranges = buffer_line_x_ranges(app, bar_width);
for (i, (start, end)) in buf_ranges.iter().enumerate() {
if col >= *start && col < *end {
return Zone::BufferLine { slot_idx: i };
}
}
}
return Zone::None;
}
let screen = app.screen_rect();
let terminal_height = screen.height;
let is_status_row = row + 1 == terminal_height; if is_status_row && !app.overlay_active() {
return Zone::StatusLine;
}
if let Some(bh) = hit_test_border(app, col, row) {
return Zone::SplitBorder {
orientation: bh.orientation,
border_cell: bh.border_cell,
split_origin: bh.split_origin,
split_total: bh.split_total,
};
}
let Some(win_id) = hit_test_window(app, col, row) else {
return Zone::None;
};
let Some(Some(win)) = app.windows.get(win_id) else {
return Zone::None;
};
let Some(rect) = win.last_rect else {
return Zone::None;
};
let slot_idx = win.slot;
let Some(slot) = app.slots().get(slot_idx) else {
return Zone::None;
};
let s = slot.editor.settings();
let line_count = slot.editor.buffer().line_count() as usize;
let vp = slot.editor.host().viewport();
let vp_top = vp.top_row;
let vp_bot = vp_top + rect.h as usize;
let has_visible_signs = slot
.diag_signs
.iter()
.chain(slot.diag_signs_lsp.iter())
.chain(slot.git_signs.iter())
.any(|sg| sg.row >= vp_top && sg.row < vp_bot);
let gw = text_start_offset(slot.editor.lnum_width(), s.signcolumn, has_visible_signs);
let rel_x = col.saturating_sub(rect.x);
let rel_y = row.saturating_sub(rect.y);
if rel_x < gw {
let doc_row = vp.top_row.saturating_add(rel_y as usize);
if doc_row < line_count {
return Zone::Gutter { win_id, doc_row };
}
return Zone::None;
}
if let Some((doc_row, doc_col)) = cell_to_doc(app, win_id, col, row) {
return Zone::Code {
win_id,
doc_row,
doc_col,
};
}
Zone::None
}
impl App {
pub(crate) fn middle_click_paste_primary(&mut self, col: u16, row: u16) {
use hjkl_clipboard::{Capabilities, MimeType, Selection};
let Some(win_id) = hit_test_window(self, col, row) else {
return;
};
let Some((doc_row, doc_col)) = cell_to_doc(self, win_id, col, row) else {
return;
};
let primary_text: Option<String> = {
let cb = self.active().editor.host().clipboard();
cb.filter(|cb| {
cb.capabilities().contains(Capabilities::PRIMARY)
&& cb.capabilities().contains(Capabilities::READ)
})
.and_then(|cb| {
cb.get(Selection::Primary, MimeType::Text)
.ok()
.and_then(|b| String::from_utf8(b).ok())
})
};
let current_focus = self.focused_window();
if win_id != current_focus {
self.switch_focus(win_id);
}
self.active_mut().editor.mouse_click_doc(doc_row, doc_col);
self.sync_after_engine_mutation();
if let Some(text) = primary_text {
self.active_mut().editor.set_yank(text);
self.active_mut().editor.paste_after(1);
self.sync_after_engine_mutation();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visual_col_ascii_exact() {
assert_eq!(hjkl_buffer::visual_col_to_char_col("hello", 2, 4), 2);
}
#[test]
fn visual_col_tab_in_middle() {
let line = "x\tyz";
assert_eq!(hjkl_buffer::visual_col_to_char_col(line, 1, 4), 1);
assert_eq!(hjkl_buffer::visual_col_to_char_col(line, 2, 4), 1);
assert_eq!(hjkl_buffer::visual_col_to_char_col(line, 3, 4), 1);
assert_eq!(hjkl_buffer::visual_col_to_char_col(line, 4, 4), 2);
}
#[test]
fn visual_col_past_eol_clamps_to_char_count() {
assert_eq!(hjkl_buffer::visual_col_to_char_col("hi", 99, 4), 2);
}
#[test]
fn click_tracker_same_pos_within_timeout_increments() {
let mut t = MouseClickTracker::new();
assert_eq!(t.register(0, 1, 2), 1);
assert_eq!(t.register(0, 1, 2), 2);
assert_eq!(t.register(0, 1, 2), 3);
}
#[test]
fn click_tracker_count_three_wraps_at_four() {
let mut t = MouseClickTracker::new();
t.register(0, 0, 0); t.register(0, 0, 0); t.register(0, 0, 0); assert_eq!(t.register(0, 0, 0), 1);
}
#[test]
fn click_tracker_different_pos_resets() {
let mut t = MouseClickTracker::new();
t.register(0, 1, 2);
assert_eq!(t.register(0, 3, 4), 1);
}
#[test]
fn click_tracker_different_window_resets() {
let mut t = MouseClickTracker::new();
t.register(0, 1, 2);
assert_eq!(t.register(1, 1, 2), 1);
}
#[test]
fn click_tracker_timeout_resets() {
let mut t = MouseClickTracker::new();
t.last = Some(LastClick {
win_id: 0,
row: 0,
col: 0,
at: Instant::now() - Duration::from_secs(1),
count: 2,
});
assert_eq!(t.register(0, 0, 0), 1);
}
#[test]
fn word_bounds_middle_of_word() {
assert_eq!(word_bounds("hello world", 1), (0, 5));
}
#[test]
fn word_bounds_on_space() {
assert_eq!(word_bounds("hello world", 5), (5, 6));
}
#[test]
fn word_bounds_empty_line() {
assert_eq!(word_bounds("", 0), (0, 0));
}
#[test]
fn word_bounds_past_eol_clamps() {
assert_eq!(word_bounds("hi", 99), (0, 2));
}
#[test]
fn rect_contains_basic() {
let r = window::LayoutRect::new(5, 10, 20, 5);
assert!(rect_contains(r, 5, 10)); assert!(rect_contains(r, 24, 14)); assert!(!rect_contains(r, 4, 10)); assert!(!rect_contains(r, 25, 10)); assert!(!rect_contains(r, 5, 9)); assert!(!rect_contains(r, 5, 15)); }
fn make_app_with_content(content: &str, area: Rect) -> App {
use hjkl_engine::BufferEdit;
let mut app = App::new(None, false, None, None).expect("App::new");
{
let buf = app.slots_mut()[0].editor.buffer_mut();
BufferEdit::replace_all(buf, content);
}
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::rect_to_layout(area));
win.top_row = 0;
win.top_col = 0;
}
{
let vp = app.slots_mut()[0].editor.host_mut().viewport_mut();
vp.width = area.width;
vp.height = area.height;
vp.text_width = area.width;
vp.top_row = 0;
vp.top_col = 0;
vp.tab_width = 4;
}
app
}
#[test]
fn cell_to_doc_no_signs_first_text_cell_is_col_zero() {
let app =
make_app_with_content("line1\nline2\nline3\nline4\nline5", Rect::new(0, 0, 80, 24));
let got = cell_to_doc(&app, 0, 4, 0);
assert_eq!(
got,
Some((0, 0)),
"click on the first text cell (col=4) of a 5-line buffer should map to (row=0, col=0); got {got:?}"
);
}
#[test]
fn cell_to_doc_with_visible_sign_first_text_cell_is_at_sign_w_plus_num_gw() {
use hjkl_buffer::Sign;
use ratatui::style::Style;
let mut app =
make_app_with_content("line1\nline2\nline3\nline4\nline5", Rect::new(0, 0, 80, 24));
app.slots_mut()[0].diag_signs.push(Sign {
row: 0,
ch: 'E',
style: Style::default(),
priority: 10,
});
let got_gutter = cell_to_doc(&app, 0, 4, 0);
assert_eq!(
got_gutter, None,
"cell 4 is in the gutter (sign col=0, num col=1..4, spacer=4); got {got_gutter:?}"
);
let got = cell_to_doc(&app, 0, 5, 0);
assert_eq!(
got,
Some((0, 0)),
"click on the first text cell (col=5 = sign_w+num_gw) should map to (row=0, col=0); got {got:?}"
);
let got2 = cell_to_doc(&app, 0, 6, 0);
assert_eq!(got2, Some((0, 1)), "click on cell 6 should map to col 1");
}
fn make_app_with_n_slots(n: usize) -> (App, Vec<std::path::PathBuf>) {
let mut paths = Vec::new();
for i in 0..n {
let p = std::env::temp_dir().join(format!("hjkl_mouse_bl_{i}_{}.txt", rand_suffix()));
std::fs::write(&p, "content\n").unwrap();
paths.push(p);
}
let mut app = App::new(Some(paths[0].clone()), false, None, None).unwrap();
for p in &paths[1..] {
app.dispatch_ex(&format!("e {}", p.display()));
}
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 200, 24));
}
(app, paths)
}
fn rand_suffix() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
format!("{nanos:x}")
}
fn cleanup_paths(paths: &[std::path::PathBuf]) {
for p in paths {
let _ = std::fs::remove_file(p);
}
}
#[test]
fn buffer_line_x_ranges_three_slots() {
let (app, paths) = make_app_with_n_slots(3);
let ranges = buffer_line_x_ranges(&app, 200);
cleanup_paths(&paths);
assert_eq!(ranges.len(), 3, "one range per slot: got {ranges:?}");
assert_eq!(ranges[0].0, 0, "first entry starts at col 0");
for i in 1..ranges.len() {
assert_eq!(
ranges[i].0,
ranges[i - 1].1 + 1,
"entry {i} must start one cell after the previous entry's end (separator gap)"
);
}
}
#[test]
fn hit_test_zone_buffer_line_at_row_zero_when_no_tabs() {
let (app, paths) = make_app_with_n_slots(3);
let ranges = buffer_line_x_ranges(&app, 200);
for (i, (start, _)) in ranges.iter().enumerate() {
let zone = hit_test_zone(&app, *start, 0);
assert_eq!(
zone,
Zone::BufferLine { slot_idx: i },
"click at col {start}, row 0 should be BufferLine {{ slot_idx: {i} }} (got {zone:?})"
);
}
cleanup_paths(&paths);
}
#[test]
fn hit_test_zone_no_buffer_line_with_single_slot() {
let mut app = App::new(None, false, None, None).unwrap();
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 80, 24));
}
{
let vp = app.slots_mut()[0].editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 24;
vp.text_width = 80;
}
let zone = hit_test_zone(&app, 10, 0);
if let Zone::BufferLine { .. } = zone {
panic!("expected no buffer line zone for single-slot app");
}
}
fn make_app_with_slots_and_tabs(
n_slots: usize,
n_extra_tabs: usize,
) -> (App, Vec<std::path::PathBuf>) {
assert!(n_slots >= 1);
let mut paths = Vec::new();
for i in 0..n_slots {
let p = std::env::temp_dir().join(format!("hjkl_unified_{i}_{}.txt", rand_suffix()));
std::fs::write(&p, "content\n").unwrap();
paths.push(p);
}
let mut app = App::new(Some(paths[0].clone()), false, None, None).unwrap();
for p in &paths[1..] {
app.dispatch_ex(&format!("e {}", p.display()));
}
for _ in 0..n_extra_tabs {
app.dispatch_ex("tabnew");
}
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 200, 24));
}
(app, paths)
}
#[test]
fn hit_test_zone_unified_bar_buffer_then_tab_horizontal() {
let (app, paths) = make_app_with_slots_and_tabs(3, 1);
assert!(app.slots().len() > 1, "expected multiple slots");
assert_eq!(app.tabs.len(), 2, "expected 2 tabs");
let zone0 = hit_test_zone(&app, 0, 0);
assert_eq!(
zone0,
Zone::BufferLine { slot_idx: 0 },
"col 0 row 0 should be BufferLine{{0}} (got {zone0:?})"
);
let tab_ranges = tab_x_ranges(&app, 200);
assert_eq!(tab_ranges.len(), 2, "expected 2 tab ranges");
let (last_start, last_end) = tab_ranges[1];
let click_col = last_start + (last_end - last_start) / 2;
let zone_tab = hit_test_zone(&app, click_col, 0);
assert_eq!(
zone_tab,
Zone::TabBar { tab_idx: 1 },
"click at col {click_col} row 0 should be TabBar{{1}} (got {zone_tab:?})"
);
cleanup_paths(&paths);
}
#[test]
fn hit_test_zone_unified_bar_only_tabs_no_buffers() {
use crate::app::window::{LayoutTree, Tab};
let mut app = App::new(None, false, None, None).unwrap();
let new_win_id = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(crate::app::window::Window::new(0)));
app.tabs
.push(Tab::new(LayoutTree::Leaf(new_win_id), new_win_id));
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 200, 24));
}
assert_eq!(app.slots().len(), 1, "expected 1 slot");
assert_eq!(app.tabs.len(), 2, "expected 2 tabs");
let zone_left = hit_test_zone(&app, 0, 0);
assert_eq!(
zone_left,
Zone::None,
"col 0 with no buffers should be Zone::None (got {zone_left:?})"
);
let tab_ranges = tab_x_ranges(&app, 200);
assert!(!tab_ranges.is_empty(), "tab_ranges must not be empty");
let (start0, end0) = tab_ranges[0];
let click_col = start0 + (end0 - start0) / 2;
let zone_tab = hit_test_zone(&app, click_col, 0);
assert_eq!(
zone_tab,
Zone::TabBar { tab_idx: 0 },
"click at col {click_col} row 0 should be TabBar{{0}} (got {zone_tab:?})"
);
}
#[test]
fn hit_test_zone_unified_bar_only_buffers_no_tabs() {
let (app, paths) = make_app_with_slots_and_tabs(3, 0);
assert_eq!(app.slots().len(), 3, "expected 3 slots");
assert_eq!(app.tabs.len(), 1, "expected 1 tab");
let buf_ranges = buffer_line_x_ranges(&app, 200);
assert_eq!(buf_ranges.len(), 3, "expected 3 buffer ranges");
for (i, (start, _)) in buf_ranges.iter().enumerate() {
let zone = hit_test_zone(&app, *start, 0);
assert_eq!(
zone,
Zone::BufferLine { slot_idx: i },
"col {start} row 0 should be BufferLine{{{i}}} (got {zone:?})"
);
}
cleanup_paths(&paths);
}
fn make_vsplit_app() -> App {
use crate::app::window::{LayoutRect, LayoutTree, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
let win1 = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(Window::new(0)));
let split_area = LayoutRect::new(0, 0, 80, 24);
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: crate::app::window::SplitDir::Vertical,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(split_area),
},
0,
);
if let Some(Some(w)) = app.windows.get_mut(0) {
w.last_rect = Some(LayoutRect::new(0, 0, 39, 24)); }
if let Some(Some(w)) = app.windows.get_mut(win1) {
w.last_rect = Some(LayoutRect::new(40, 0, 40, 24)); }
app
}
fn make_hsplit_app() -> App {
use crate::app::window::{LayoutRect, LayoutTree, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
let win1 = app.next_window_id;
app.next_window_id += 1;
app.windows.push(Some(Window::new(0)));
let split_area = LayoutRect::new(0, 0, 80, 24);
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: crate::app::window::SplitDir::Horizontal,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(split_area),
},
0,
);
if let Some(Some(w)) = app.windows.get_mut(0) {
w.last_rect = Some(LayoutRect::new(0, 0, 80, 11));
}
if let Some(Some(w)) = app.windows.get_mut(win1) {
w.last_rect = Some(LayoutRect::new(0, 12, 80, 12));
}
app
}
#[test]
fn hit_test_border_on_vertical_divider() {
let app = make_vsplit_app();
let hit = hit_test_border(&app, 39, 10);
assert!(
hit.is_some(),
"click on vertical divider (col=39) should return BorderHit"
);
let h = hit.unwrap();
assert_eq!(h.orientation, SplitOrientation::Vertical);
assert_eq!(h.border_cell, (39, 10));
assert_eq!(h.split_origin, 0);
assert_eq!(h.split_total, 80);
}
#[test]
fn hit_test_border_off_divider() {
let app = make_vsplit_app();
let hit = hit_test_border(&app, 41, 10);
assert!(
hit.is_none(),
"click 2 cells away from divider should return None"
);
}
#[test]
fn hit_test_border_on_horizontal_divider() {
let app = make_hsplit_app();
let hit = hit_test_border(&app, 20, 11);
assert!(
hit.is_some(),
"click on horizontal divider (row=11) should return BorderHit"
);
let h = hit.unwrap();
assert_eq!(h.orientation, SplitOrientation::Horizontal);
assert_eq!(h.border_cell, (20, 11));
assert_eq!(h.split_origin, 0);
assert_eq!(h.split_total, 24);
}
#[test]
fn hit_test_border_with_nested_splits() {
use crate::app::window::{LayoutRect, LayoutTree, SplitDir, Tab, Window};
let mut app = App::new(None, false, None, None).unwrap();
let win1 = app.next_window_id;
app.next_window_id += 1;
{
let mut w = Window::new(0);
w.last_rect = Some(LayoutRect::new(40, 0, 40, 11));
app.windows.push(Some(w));
}
let win2 = app.next_window_id;
app.next_window_id += 1;
{
let mut w = Window::new(0);
w.last_rect = Some(LayoutRect::new(0, 12, 80, 12));
app.windows.push(Some(w));
}
if let Some(Some(w)) = app.windows.get_mut(0) {
w.last_rect = Some(LayoutRect::new(0, 0, 39, 11));
}
app.tabs[0] = Tab::new(
LayoutTree::Split {
dir: SplitDir::Horizontal,
ratio: 0.5,
a: Box::new(LayoutTree::Split {
dir: SplitDir::Vertical,
ratio: 0.5,
a: Box::new(LayoutTree::Leaf(0)),
b: Box::new(LayoutTree::Leaf(win1)),
last_rect: Some(LayoutRect::new(0, 0, 80, 12)),
}),
b: Box::new(LayoutTree::Leaf(win2)),
last_rect: Some(LayoutRect::new(0, 0, 80, 24)),
},
0,
);
let hit_v = hit_test_border(&app, 39, 5);
assert!(
hit_v.is_some(),
"nested VSplit border at col=39 row=5 should be hittable"
);
assert_eq!(hit_v.unwrap().orientation, SplitOrientation::Vertical);
let hit_h = hit_test_border(&app, 20, 11);
assert!(
hit_h.is_some(),
"outer HSplit border at row=11 col=20 should be hittable"
);
assert_eq!(hit_h.unwrap().orientation, SplitOrientation::Horizontal);
}
#[test]
fn hit_test_zone_no_bar_at_all_when_single_tab_single_slot() {
let mut app = App::new(None, false, None, None).unwrap();
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 80, 24));
}
{
let vp = app.slots_mut()[0].editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 24;
vp.text_width = 80;
}
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.slots().len(), 1);
let zone = hit_test_zone(&app, 10, 0);
assert!(
!matches!(zone, Zone::TabBar { .. } | Zone::BufferLine { .. }),
"single tab + single slot: row 0 should be editor zone, got {zone:?}"
);
}
fn make_basic_app_80x24() -> App {
let mut app = App::new(None, false, None, None).unwrap();
if let Some(Some(win)) = app.windows.get_mut(0) {
win.last_rect = Some(window::LayoutRect::new(0, 0, 80, 24));
}
{
let vp = app.slots_mut()[0].editor.host_mut().viewport_mut();
vp.width = 80;
vp.height = 24;
vp.text_width = 80;
vp.top_row = 0;
vp.top_col = 0;
}
app
}
#[test]
fn hit_test_zone_status_line_at_bottom() {
let app = make_basic_app_80x24();
assert_eq!(app.tabs.len(), 1);
assert_eq!(app.slots().len(), 1);
let screen = app.screen_rect();
let status_row = screen.height.saturating_sub(1);
let zone = hit_test_zone(&app, 10, status_row);
assert_eq!(
zone,
Zone::StatusLine,
"click at row={status_row} (last row, no overlay) should be Zone::StatusLine; got {zone:?}"
);
}
#[test]
fn hit_test_zone_above_status_line_is_not_status_zone() {
let app = make_basic_app_80x24();
let screen = app.screen_rect();
let above_status = screen.height.saturating_sub(2);
let zone = hit_test_zone(&app, 10, above_status);
assert!(
!matches!(zone, Zone::StatusLine),
"row above status line must not be Zone::StatusLine; got {zone:?}"
);
}
#[test]
fn hit_test_zone_picker_is_exclusive() {
use crate::picker::Picker;
use hjkl_picker::{PickerAction, PickerLogic, RequeryMode};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
struct StubSource(Vec<String>);
impl PickerLogic for StubSource {
fn title(&self) -> &str {
"stub"
}
fn item_count(&self) -> usize {
self.0.len()
}
fn label(&self, i: usize) -> String {
self.0[i].clone()
}
fn match_text(&self, i: usize) -> String {
self.0[i].clone()
}
fn has_preview(&self) -> bool {
false
}
fn select(&self, _i: usize) -> PickerAction {
PickerAction::None
}
fn requery_mode(&self) -> RequeryMode {
RequeryMode::FilterInMemory
}
fn enumerate(
&mut self,
_q: Option<&str>,
_c: Arc<AtomicBool>,
) -> Option<std::thread::JoinHandle<()>> {
None
}
}
let mut app = make_basic_app_80x24();
let source = Box::new(StubSource(vec![
"a".into(),
"b".into(),
"c".into(),
"d".into(),
"e".into(),
]));
app.picker = Some(Picker::new(source));
let area = picker_overlay_rect(&app).expect("picker must be open");
let list_y = area.y + 3;
let list_inner_y = list_y + 1;
let col_inside = area.x + 2;
let row_inside = list_inner_y;
let zone = hit_test_zone(&app, col_inside, row_inside);
assert!(
matches!(zone, Zone::PickerRow { .. }),
"click inside picker list (col={col_inside}, row={row_inside}) should be Zone::PickerRow; got {zone:?}"
);
if area.x > 0 {
let col_outside = 0;
let row_outside = row_inside;
let zone_out = hit_test_zone(&app, col_outside, row_outside);
assert_eq!(
zone_out,
Zone::None,
"click outside picker overlay must be Zone::None; got {zone_out:?}"
);
}
}
}