use crate::common::fixtures::TestFixture;
use crate::common::harness::EditorTestHarness;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use std::fs;
#[test]
fn test_scrollbar_renders() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
.with_test_writer()
.try_init();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=50)
.map(|i| format!("Line {i} with some content\n"))
.collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness.render().unwrap();
assert!(
harness.has_scrollbar_at_column(79),
"Scrollbar should be visible in rightmost column"
);
}
#[test]
fn test_scrollbar_in_multiple_splits() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
for i in 1..=30 {
harness.type_text(&format!("Left pane line {i}\n")).unwrap();
}
harness
.send_key(KeyCode::Char('v'), KeyModifiers::ALT)
.unwrap();
for i in 1..=30 {
harness
.type_text(&format!("Right pane line {i}\n"))
.unwrap();
}
harness.render().unwrap();
assert!(
harness.has_scrollbar_at_column(79),
"Scrollbar should be visible in rightmost split"
);
}
#[test]
fn test_scrollbar_click_jump() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
.with_test_writer()
.try_init();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100)
.map(|i| format!("Line {i} content here\n"))
.collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness
.send_key_repeat(KeyCode::PageUp, KeyModifiers::NONE, 10)
.unwrap();
harness.render().unwrap();
let initial_top_line = harness.top_line_number();
harness.mouse_click(79, 20).unwrap();
harness.render().unwrap();
let new_top_line = harness.top_line_number();
assert!(
new_top_line > initial_top_line + 10,
"Clicking near bottom of scrollbar should scroll down significantly (was {initial_top_line}, now {new_top_line})"
);
}
#[test]
fn test_scrollbar_drag() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
.with_test_writer()
.try_init();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100).map(|i| format!("Line {i} with text\n")).collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness
.send_key_repeat(KeyCode::PageUp, KeyModifiers::NONE, 10)
.unwrap();
harness.render().unwrap();
let initial_top_line = harness.top_line_number();
harness.mouse_drag(79, 2, 79, 12).unwrap();
harness.render().unwrap();
let new_top_line = harness.top_line_number();
assert!(
new_top_line > initial_top_line + 10,
"Dragging scrollbar should scroll content (was {initial_top_line}, now {new_top_line})"
);
}
#[test]
fn test_mouse_click_positions_cursor() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("First line\n").unwrap();
harness.type_text("Second line\n").unwrap();
harness.type_text("Third line\n").unwrap();
harness.render().unwrap();
let buffer_len = harness.buffer_len();
assert_eq!(harness.cursor_position(), buffer_len);
harness.mouse_click(10, 2).unwrap();
harness.render().unwrap();
let new_pos = harness.cursor_position();
assert!(
new_pos < 15,
"Cursor should be near start after clicking first line (position: {new_pos})"
);
}
#[test]
fn test_mouse_click_switches_split_focus() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("Left content").unwrap();
let first_buffer_content = harness.get_buffer_content().unwrap();
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.type_text("split vert").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
harness.type_text(" plus right").unwrap();
harness.render().unwrap();
let second_buffer_content = harness.get_buffer_content().unwrap();
assert!(second_buffer_content.contains("plus right"));
assert!(!first_buffer_content.contains("plus right"));
harness.mouse_click(10, 5).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.is_empty(),
"Editor should still be rendering after split click"
);
}
#[test]
fn test_mouse_click_file_explorer() {
let mut harness = EditorTestHarness::with_temp_project(80, 24).unwrap();
let project_dir = harness.project_dir().unwrap();
let test_file = project_dir.join("test.txt");
fs::write(&test_file, "Test file content").unwrap();
harness
.send_key(KeyCode::Char('e'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
for row in 3..8 {
harness.mouse_click(10, row).unwrap();
harness.render().unwrap();
}
let screen = harness.screen_to_string();
assert!(
!screen.is_empty(),
"Editor should still be rendering after file explorer clicks"
);
}
#[test]
fn test_mouse_open_file_from_explorer() {
let mut harness = EditorTestHarness::with_temp_project(80, 24).unwrap();
let project_dir = harness.project_dir().unwrap();
let test_file = project_dir.join("clickme.txt");
fs::write(&test_file, "I was opened by clicking!").unwrap();
harness
.send_key(KeyCode::Char('e'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.mouse_click(10, 4).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.is_empty(),
"Editor should still be functional after file explorer interaction"
);
}
#[test]
fn test_scrollbar_with_small_buffer() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("Line 1\n").unwrap();
harness.type_text("Line 2\n").unwrap();
harness.type_text("Line 3\n").unwrap();
harness.render().unwrap();
assert!(
harness.has_scrollbar_at_column(79),
"Scrollbar should be visible even with small buffers"
);
}
#[test]
fn test_mouse_click_outside_areas() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("Some content").unwrap();
harness.render().unwrap();
harness.mouse_click(40, 23).unwrap();
harness.render().unwrap();
harness.mouse_click(40, 0).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(!screen.is_empty(), "Editor should still be functional");
}
#[test]
fn test_scrollbar_horizontal_split() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
for i in 1..=30 {
harness.type_text(&format!("Top pane line {i}\n")).unwrap();
}
harness
.send_key(KeyCode::Char('h'), KeyModifiers::ALT)
.unwrap();
for i in 1..=30 {
harness
.type_text(&format!("Bottom pane line {i}\n"))
.unwrap();
}
harness.render().unwrap();
assert!(
harness.has_scrollbar_at_column(79),
"Should have scrollbar in horizontal splits"
);
}
#[test]
fn test_mouse_click_with_horizontal_scroll() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness
.type_text("This is a very long line that should extend beyond the visible width of the terminal and require horizontal scrolling to see all of it completely")
.unwrap();
harness.render().unwrap();
harness
.send_key_repeat(KeyCode::Right, KeyModifiers::NONE, 10)
.unwrap();
harness.mouse_click(40, 2).unwrap();
harness.render().unwrap();
let pos = harness.cursor_position();
assert!(
pos < 200,
"Cursor should be positioned in the line after click"
);
}
#[test]
fn test_mouse_click_in_gutter() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("Line 1\n").unwrap();
harness.type_text("Line 2\n").unwrap();
harness.type_text("Line 3\n").unwrap();
harness.render().unwrap();
let _initial_pos = harness.cursor_position();
harness.mouse_click(3, 3).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.is_empty(),
"Editor should still work after gutter click"
);
}
#[test]
fn test_mouse_click_past_end_of_line() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.type_text("hello\n").unwrap();
harness.type_text("world\n").unwrap();
harness.render().unwrap();
let buffer_len = harness.buffer_len();
assert_eq!(harness.cursor_position(), buffer_len);
harness.mouse_click(50, 2).unwrap();
harness.render().unwrap();
let new_pos = harness.cursor_position();
assert_eq!(
new_pos, 5,
"Clicking past end of line should position cursor at end of that line (expected 5, got {new_pos})"
);
}
#[test]
fn test_scrollbar_drag_to_top() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100).map(|i| format!("Line {i}\n")).collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness
.send_key(KeyCode::End, KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let scrolled_pos = harness.top_line_number();
assert!(scrolled_pos > 70, "Should be scrolled down initially");
harness.mouse_drag(79, 12, 79, 2).unwrap();
harness.render().unwrap();
let new_pos = harness.top_line_number();
assert!(
new_pos < scrolled_pos - 10,
"Dragging up should scroll up (was {scrolled_pos}, now {new_pos})"
);
}
#[test]
fn test_scrollbar_drag_on_large_file() {
use std::time::Instant;
let big_txt_path = TestFixture::big_txt_for_test("scrollbar_drag_large_file").unwrap();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
println!("\n=== Opening 61MB file for scrollbar drag test ===");
harness.open_file(&big_txt_path).unwrap();
harness.render().unwrap();
let initial_top_line = harness.top_line_number();
println!("Initial top line: {}", initial_top_line);
println!("\n=== Dragging scrollbar on 61MB file ===");
let start = Instant::now();
harness.mouse_drag(79, 2, 79, 12).unwrap();
let drag_time = start.elapsed();
harness.render().unwrap();
println!("✓ Scrollbar drag completed in: {:?}", drag_time);
let new_top_line = harness.top_line_number();
println!("New top line after drag: {}", new_top_line);
assert!(
new_top_line > initial_top_line,
"Dragging scrollbar should scroll content down (was line {}, now line {})",
initial_top_line,
new_top_line
);
println!("✓ Scrollbar drag on large file works without hang");
println!("\n=== Dragging scrollbar back up ===");
let start = Instant::now();
harness.mouse_drag(79, 12, 79, 4).unwrap();
let drag_back_time = start.elapsed();
harness.render().unwrap();
println!("✓ Scrollbar drag back completed in: {:?}", drag_back_time);
let final_top_line = harness.top_line_number();
println!("Final top line: {}", final_top_line);
assert!(
final_top_line < new_top_line,
"Dragging scrollbar up should scroll content up (was line {}, now line {})",
new_top_line,
final_top_line
);
}
#[test]
fn test_mouse_focus_after_file_explorer() {
let mut harness = EditorTestHarness::with_temp_project(80, 24).unwrap();
harness.type_text("Editor content").unwrap();
harness.render().unwrap();
harness
.send_key(KeyCode::Char('e'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.mouse_click(50, 10).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
assert!(
!screen.is_empty() && screen.contains("Editor content"),
"Editor should still be functional after clicking"
);
}
fn extract_scrollbar_thumb_info(
harness: &EditorTestHarness,
terminal_width: u16,
_terminal_height: u16,
) -> (usize, usize, usize) {
let scrollbar_col = terminal_width - 1; let (content_first_row, content_last_row) = harness.content_area_rows();
let mut thumb_start = None;
let mut thumb_end = None;
for row in content_first_row..=content_last_row {
if harness.is_scrollbar_thumb_at(scrollbar_col, row as u16) {
if thumb_start.is_none() {
thumb_start = Some(row);
}
thumb_end = Some(row);
}
}
match (thumb_start, thumb_end) {
(Some(start), Some(end)) => {
let thumb_size = end - start + 1;
(start, end, thumb_size)
}
_ => (0, 0, 0),
}
}
#[test]
fn test_scrollbar_drag_updates_cursor_position() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
.with_test_writer()
.try_init();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100).map(|i| format!("Line {i} content\n")).collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness
.send_key(KeyCode::Home, KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
let initial_cursor_pos = harness.cursor_position();
let initial_top_line = harness.top_line_number();
println!("\nInitial state:");
println!(" Cursor position: {initial_cursor_pos} bytes");
println!(" Top line: {initial_top_line}");
println!("\nDragging scrollbar from row 2 to row 18");
harness.mouse_drag(79, 2, 79, 18).unwrap();
harness.render().unwrap();
let cursor_pos_after_drag = harness.cursor_position();
let top_line_after_drag = harness.top_line_number();
let top_byte_after_drag = harness.top_byte();
println!("\nAfter scrollbar drag:");
println!(" Cursor position: {cursor_pos_after_drag} bytes");
println!(" Top line: {top_line_after_drag}");
println!(" Top byte: {top_byte_after_drag}");
println!(
" Viewport scrolled by: {} lines",
top_line_after_drag - initial_top_line
);
assert!(
top_line_after_drag > initial_top_line + 20,
"Viewport should have scrolled down significantly (was line {initial_top_line}, now line {top_line_after_drag})"
);
assert!(
cursor_pos_after_drag > initial_cursor_pos,
"Cursor should have moved from position {initial_cursor_pos} after scrollbar drag, but is still at {cursor_pos_after_drag}"
);
assert_eq!(
cursor_pos_after_drag, top_byte_after_drag,
"Cursor position {cursor_pos_after_drag} should be at the top of the viewport (top_byte={top_byte_after_drag})"
);
}
#[test]
fn test_scrollbar_drag_to_absolute_bottom() {
use tracing_subscriber::EnvFilter;
let _ = tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into()))
.with_test_writer()
.try_init();
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100).map(|i| format!("Line {i} content\n")).collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness
.send_key_repeat(KeyCode::PageUp, KeyModifiers::NONE, 20)
.unwrap();
harness.render().unwrap();
let buffer_len = harness.buffer_len();
println!("Buffer length: {buffer_len} bytes");
let initial_top_line = harness.top_line_number();
println!("Initial top line: {initial_top_line}");
assert!(initial_top_line <= 1, "Should be at top of document");
let (content_first_row, content_last_row) = harness.content_area_rows();
let viewport_height = harness.viewport_height();
let scrollbar_bottom_row = content_last_row;
println!("\nDragging scrollbar from row {content_first_row} to row {scrollbar_bottom_row}");
harness
.mouse_drag(
79,
content_first_row as u16,
79,
scrollbar_bottom_row as u16,
)
.unwrap();
harness.render().unwrap();
let (thumb_start, thumb_end, thumb_size) = extract_scrollbar_thumb_info(&harness, 80, 24);
let top_line_after_drag = harness.top_line_number();
println!("\nAfter drag to bottom:");
println!(" Thumb start row: {thumb_start}");
println!(" Thumb end row: {thumb_end}");
println!(" Thumb size: {thumb_size} chars");
println!(" Scrollbar bottom row: {scrollbar_bottom_row}");
println!(" Top line number: {top_line_after_drag}");
println!(" Total lines in file: 100");
println!(" Viewport height: {viewport_height} rows");
let expected_max_top_line = 100 - viewport_height + 1;
println!(" Expected max top line: {expected_max_top_line} (100 - {viewport_height} + 1)");
println!("\nChecking invariant: thumb_end ({thumb_end}) should equal scrollbar_bottom_row ({scrollbar_bottom_row})");
let cursor_pos = harness.cursor_position();
println!("Cursor position: {cursor_pos} bytes");
println!("Buffer length: {buffer_len} bytes");
let diff = (thumb_end as i32 - scrollbar_bottom_row as i32).abs();
assert!(
diff <= 1,
"Scrollbar thumb should reach near absolute bottom (row {scrollbar_bottom_row}) when dragged to bottom, but ended at row {thumb_end}"
);
assert_eq!(
top_line_after_drag, expected_max_top_line,
"Viewport should be scrolled to line {expected_max_top_line} (100 - {viewport_height}), but is at line {top_line_after_drag}"
);
assert!(
cursor_pos <= buffer_len,
"Cursor should not be beyond buffer end. Cursor at {cursor_pos}, buffer length {buffer_len}"
);
}
#[test]
fn test_horizontal_split_separator_drag_resize() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.type_text("split horiz").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
let separators = harness.editor().get_separator_areas().to_vec();
assert_eq!(
separators.len(),
1,
"Should have exactly one separator after creating horizontal split"
);
let (split_id, direction, sep_x, sep_y, sep_length) = separators[0];
assert_eq!(
direction,
fresh::model::event::SplitDirection::Horizontal,
"Should be a horizontal split"
);
let initial_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
(initial_ratio - 0.5).abs() < 0.1,
"Initial ratio should be close to 0.5, got {initial_ratio}"
);
let start_col = sep_x + sep_length / 2;
let start_row = sep_y;
let end_row = sep_y + 3;
harness
.mouse_drag(start_col, start_row, start_col, end_row)
.unwrap();
let new_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
new_ratio > initial_ratio,
"Ratio should increase after dragging separator down. Was {initial_ratio}, now {new_ratio}"
);
std::thread::sleep(double_click_delay);
let separators_after = harness.editor().get_separator_areas().to_vec();
let (_, _, sep_x_new, sep_y_new, sep_length_new) = separators_after[0];
let start_col = sep_x_new + sep_length_new / 2;
let start_row = sep_y_new;
let end_row = sep_y_new.saturating_sub(5);
harness
.mouse_drag(start_col, start_row, start_col, end_row)
.unwrap();
let final_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
final_ratio < new_ratio,
"Ratio should decrease after dragging separator up. Was {new_ratio}, now {final_ratio}"
);
}
#[test]
fn test_vertical_split_separator_drag_resize() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.type_text("split vert").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
let separators = harness.editor().get_separator_areas().to_vec();
assert_eq!(
separators.len(),
1,
"Should have exactly one separator after creating vertical split"
);
let (split_id, direction, sep_x, sep_y, sep_length) = separators[0];
assert_eq!(
direction,
fresh::model::event::SplitDirection::Vertical,
"Should be a vertical split"
);
let initial_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
(initial_ratio - 0.5).abs() < 0.1,
"Initial ratio should be close to 0.5, got {initial_ratio}"
);
let start_col = sep_x;
let start_row = sep_y + sep_length / 2;
let end_col = sep_x + 10;
harness
.mouse_drag(start_col, start_row, end_col, start_row)
.unwrap();
let new_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
new_ratio > initial_ratio,
"Ratio should increase after dragging separator right. Was {initial_ratio}, now {new_ratio}"
);
std::thread::sleep(double_click_delay);
let separators_after = harness.editor().get_separator_areas().to_vec();
let (_, _, sep_x_new, sep_y_new, sep_length_new) = separators_after[0];
let start_col = sep_x_new;
let start_row = sep_y_new + sep_length_new / 2;
let end_col = sep_x_new.saturating_sub(15);
harness
.mouse_drag(start_col, start_row, end_col, start_row)
.unwrap();
let final_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
final_ratio < new_ratio,
"Ratio should decrease after dragging separator left. Was {new_ratio}, now {final_ratio}"
);
}
#[test]
fn test_split_separator_drag_respects_limits() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
harness
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
.unwrap();
harness.render().unwrap();
harness.type_text("split horiz").unwrap();
harness
.send_key(KeyCode::Enter, KeyModifiers::NONE)
.unwrap();
harness.render().unwrap();
let separators = harness.editor().get_separator_areas().to_vec();
let (split_id, _, sep_x, sep_y, sep_length) = separators[0];
let start_col = sep_x + sep_length / 2;
harness
.mouse_drag(start_col, sep_y, start_col, sep_y + 100)
.unwrap();
let max_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
max_ratio <= 0.9,
"Ratio should not exceed 0.9, got {max_ratio}"
);
assert!(
max_ratio >= 0.8,
"Ratio should be close to maximum after extreme drag down, got {max_ratio}"
);
std::thread::sleep(double_click_delay);
let separators_after = harness.editor().get_separator_areas().to_vec();
let (_, _, _, sep_y_after, _) = separators_after[0];
harness
.mouse_drag(start_col, sep_y_after, start_col, 0)
.unwrap();
let min_ratio = harness.editor().get_split_ratio(split_id.into()).unwrap();
assert!(
min_ratio >= 0.1,
"Ratio should not be less than 0.1, got {min_ratio}"
);
assert!(
min_ratio <= 0.2,
"Ratio should be close to minimum after extreme drag up, got {min_ratio}"
);
}
#[test]
fn test_tab_close_button_hover_changes_color() {
use crate::common::harness::layout;
use ratatui::style::Color;
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.new_buffer().unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
println!("Tab row content: '{}'", tab_row);
println!("Tab row length: {}", tab_row.len());
let x_pos = tab_row
.find('×')
.expect("Could not find × close button in tab bar");
println!("Found × at position: {}", x_pos);
let style_before = harness.get_cell_style(x_pos as u16, layout::TAB_BAR_ROW as u16);
let fg_before = style_before.and_then(|s| s.fg);
println!("Color before hover: {:?}", fg_before);
harness
.mouse_move(x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
let style_after = harness.get_cell_style(x_pos as u16, layout::TAB_BAR_ROW as u16);
let fg_after = style_after.and_then(|s| s.fg);
println!("Color after hover: {:?}", fg_after);
assert_ne!(
fg_before, fg_after,
"Tab close button color should change on hover. Before: {:?}, After: {:?}",
fg_before, fg_after
);
match fg_after {
Some(Color::Rgb(r, g, b)) => {
assert!(
r > 200 && g < 150 && b < 150,
"Expected red-ish hover color, got RGB({}, {}, {})",
r,
g,
b
);
}
other => panic!("Expected RGB color for hover, got {:?}", other),
}
}
#[test]
fn test_tab_hover_position_accuracy() {
use crate::common::harness::layout;
use ratatui::style::Color;
let mut harness = EditorTestHarness::new(80, 24).unwrap();
harness.new_buffer().unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
println!("Tab row: '{}'", tab_row);
let x_positions: Vec<usize> = tab_row.match_indices('×').map(|(i, _)| i).collect();
println!("× positions: {:?}", x_positions);
assert_eq!(
x_positions.len(),
2,
"Expected 2 close buttons (one per tab)"
);
for (idx, &x_pos) in x_positions.iter().enumerate() {
println!(
"\n--- Testing close button {} at position {} ---",
idx, x_pos
);
harness.mouse_move(0, 0).unwrap();
let colors_before: Vec<_> = x_positions
.iter()
.map(|&pos| {
harness
.get_cell_style(pos as u16, layout::TAB_BAR_ROW as u16)
.and_then(|s| s.fg)
})
.collect();
println!("Colors before hover: {:?}", colors_before);
harness
.mouse_move(x_pos as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
let colors_after: Vec<_> = x_positions
.iter()
.map(|&pos| {
harness
.get_cell_style(pos as u16, layout::TAB_BAR_ROW as u16)
.and_then(|s| s.fg)
})
.collect();
println!("Colors after hover: {:?}", colors_after);
let hovered_color = colors_after[idx];
match hovered_color {
Some(Color::Rgb(r, g, b)) => {
assert!(
r > 200 && g < 150 && b < 150,
"Close button {} at position {} should be red when hovered, got RGB({}, {}, {})",
idx, x_pos, r, g, b
);
}
other => panic!(
"Expected RGB color for hovered button {}, got {:?}",
idx, other
),
}
for (other_idx, &other_color) in colors_after.iter().enumerate() {
if other_idx != idx {
if let Some(Color::Rgb(r, g, b)) = other_color {
assert!(
!(r > 200 && g < 150 && b < 150),
"Close button {} should NOT be red when button {} is hovered, but got RGB({}, {}, {})",
other_idx, idx, r, g, b
);
}
}
}
}
}
#[test]
fn test_drag_to_select_text() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "Hello World\nSecond line here\nThird line\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
assert!(
!harness.has_selection(),
"Should have no selection initially"
);
let (content_first_row, _) = harness.content_area_rows();
let start_col = 9;
let end_col = 19;
let row = content_first_row as u16;
harness.mouse_drag(start_col, row, end_col, row).unwrap();
harness.render().unwrap();
assert!(harness.has_selection(), "Should have selection after drag");
let selected = harness.get_selected_text();
println!("Selected text: '{}'", selected);
assert!(!selected.is_empty(), "Selected text should not be empty");
let range = harness.get_selection_range();
assert!(range.is_some(), "Should have a selection range");
let range = range.unwrap();
println!("Selection range: {} to {}", range.start, range.end);
assert!(
range.end > range.start,
"Selection end ({}) should be greater than start ({})",
range.end,
range.start
);
}
#[test]
fn test_drag_to_select_multiple_lines() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "Line one\nLine two\nLine three\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let start_col = 9;
let start_row = content_first_row as u16;
let end_col = 14;
let end_row = content_first_row as u16 + 2;
println!(
"Dragging from ({}, {}) to ({}, {})",
start_col, start_row, end_col, end_row
);
harness
.mouse_drag(start_col, start_row, end_col, end_row)
.unwrap();
harness.render().unwrap();
assert!(
harness.has_selection(),
"Should have selection after multi-line drag"
);
let selected = harness.get_selected_text();
println!("Selected text: '{}'", selected);
let range = harness.get_selection_range();
assert!(range.is_some(), "Should have selection range");
let range = range.unwrap();
println!("Selection range: {} to {}", range.start, range.end);
assert!(
range.end - range.start > 5,
"Multi-line selection should span more than 5 bytes"
);
}
#[test]
fn test_click_clears_selection() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
let content = "Some text to select\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
harness.mouse_drag(9, row, 17, row).unwrap();
harness.render().unwrap();
assert!(harness.has_selection(), "Should have selection after drag");
std::thread::sleep(double_click_delay);
harness.mouse_click(12, row).unwrap();
harness.render().unwrap();
let range = harness.get_selection_range();
if let Some(range) = range {
assert_eq!(
range.start, range.end,
"After click, selection should be empty (start={}, end={})",
range.start, range.end
);
}
}
#[test]
fn test_shift_click_extends_selection() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
let content = "hello world test content\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
harness.mouse_click(gutter_width, row).unwrap();
harness.render().unwrap();
let pos_after_click = harness.cursor_position();
assert_eq!(
pos_after_click, 0,
"Cursor should be at start after clicking at gutter edge"
);
std::thread::sleep(double_click_delay);
harness.mouse_shift_click(gutter_width + 12, row).unwrap();
harness.render().unwrap();
assert!(
harness.has_selection(),
"Should have selection after shift+click"
);
let selection_range = harness.get_selection_range();
assert!(
selection_range.is_some(),
"Selection range should be available"
);
let range = selection_range.unwrap();
assert_eq!(
range.start, 0,
"Selection should start at original click position"
);
assert!(
range.end > range.start,
"Selection should extend to shift+click position"
);
}
#[test]
fn test_shift_click_can_shrink_selection() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 2);
let content = "hello world test content\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
harness
.mouse_drag(gutter_width + 5, row, gutter_width + 15, row)
.unwrap();
harness.render().unwrap();
assert!(harness.has_selection(), "Should have selection after drag");
let initial_range = harness.get_selection_range().unwrap();
let initial_size = initial_range.end - initial_range.start;
assert!(initial_size > 0, "Initial selection should have size > 0");
std::thread::sleep(double_click_delay);
harness.mouse_shift_click(gutter_width + 10, row).unwrap();
harness.render().unwrap();
assert!(
harness.has_selection(),
"Should still have selection after shift+click"
);
}
#[test]
fn test_tab_hover_with_real_files() {
use crate::common::harness::layout;
use ratatui::style::Color;
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let fixture1 = TestFixture::new("test1.txt", "Hello from file 1\nLine 2\n").unwrap();
let fixture2 = TestFixture::new("test2.txt", "Hello from file 2\nLine 2\n").unwrap();
harness.open_file(&fixture1.path).unwrap();
harness.open_file(&fixture2.path).unwrap();
harness.render().unwrap();
let screen = harness.screen_to_string();
println!("Full screen:\n{}", screen);
let tab_row: String = screen
.lines()
.nth(layout::TAB_BAR_ROW)
.unwrap_or("")
.to_string();
println!("\nTab row: '{}'", tab_row);
let x_positions: Vec<usize> = tab_row.match_indices('×').map(|(i, _)| i).collect();
println!("× positions: {:?}", x_positions);
assert_eq!(x_positions.len(), 2, "Expected 2 close buttons");
let first_x = x_positions[0];
println!("\nHovering at position {} (first ×)", first_x);
let style_before = harness.get_cell_style(first_x as u16, layout::TAB_BAR_ROW as u16);
let fg_before = style_before.and_then(|s| s.fg);
println!("Color before: {:?}", fg_before);
harness
.mouse_move(first_x as u16, layout::TAB_BAR_ROW as u16)
.unwrap();
let style_after = harness.get_cell_style(first_x as u16, layout::TAB_BAR_ROW as u16);
let fg_after = style_after.and_then(|s| s.fg);
println!("Color after: {:?}", fg_after);
assert_ne!(fg_before, fg_after, "Color should change on hover");
match fg_after {
Some(Color::Rgb(r, g, b)) => {
assert!(
r > 200 && g < 150 && b < 150,
"Expected red-ish hover color, got RGB({}, {}, {})",
r,
g,
b
);
}
other => panic!("Expected RGB color, got {:?}", other),
}
}
#[test]
fn test_mouse_hover_tracks_position() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "fn main() {\n let x = 42;\n}\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
assert!(
harness.editor().get_mouse_hover_state().is_none(),
"Should have no hover state initially"
);
let text_col = 12; let text_row = content_first_row as u16;
harness.mouse_move(text_col, text_row).unwrap();
let hover_state = harness.editor().get_mouse_hover_state();
assert!(
hover_state.is_some(),
"Should have hover state after moving mouse over text"
);
let (byte_pos, screen_x, screen_y) = hover_state.unwrap();
assert_eq!(screen_x, text_col, "Screen X should match mouse position");
assert_eq!(screen_y, text_row, "Screen Y should match mouse position");
assert!(
byte_pos < content.len(),
"Byte position {} should be within buffer (len {})",
byte_pos,
content.len()
);
}
#[test]
fn test_mouse_hover_clears_when_leaving_editor() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "Hello World\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
harness.mouse_move(12, content_first_row as u16).unwrap();
assert!(
harness.editor().get_mouse_hover_state().is_some(),
"Should have hover state over text"
);
harness.mouse_move(40, 23).unwrap();
assert!(
harness.editor().get_mouse_hover_state().is_none(),
"Hover state should be cleared when mouse leaves editor content"
);
}
#[test]
fn test_mouse_hover_updates_on_position_change() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "First line of text\nSecond line here\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
harness.mouse_move(12, content_first_row as u16).unwrap();
let state1 = harness.editor().get_mouse_hover_state();
assert!(state1.is_some(), "Should have hover state");
let (pos1, _, _) = state1.unwrap();
harness
.mouse_move(12, content_first_row as u16 + 1)
.unwrap();
let state2 = harness.editor().get_mouse_hover_state();
assert!(state2.is_some(), "Should still have hover state");
let (pos2, _, _) = state2.unwrap();
assert_ne!(
pos1, pos2,
"Byte position should change when moving to different line"
);
}
#[test]
fn test_mouse_hover_clears_in_gutter() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "Some code here\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
harness.mouse_move(15, content_first_row as u16).unwrap();
assert!(
harness.editor().get_mouse_hover_state().is_some(),
"Should have hover state over text"
);
harness.mouse_move(3, content_first_row as u16).unwrap();
assert!(
harness.editor().get_mouse_hover_state().is_none(),
"Hover state should be cleared when mouse is in gutter"
);
}
#[test]
fn test_force_check_mouse_hover() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "let variable = 123;\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
harness.mouse_move(12, content_first_row as u16).unwrap();
assert!(
harness.editor().get_mouse_hover_state().is_some(),
"Should have hover state"
);
let triggered = harness.editor_mut().force_check_mouse_hover();
assert!(
!triggered,
"force_check_mouse_hover should return false when no LSP server is available"
);
}
#[test]
fn test_mouse_hover_same_position_preserves_state() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "test content\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let col = 12;
let row = content_first_row as u16;
harness.mouse_move(col, row).unwrap();
let state1 = harness.editor().get_mouse_hover_state();
assert!(state1.is_some(), "Should have hover state");
let (pos1, _, _) = state1.unwrap();
harness.mouse_move(col, row).unwrap();
let state2 = harness.editor().get_mouse_hover_state();
assert!(state2.is_some(), "Should still have hover state");
let (pos2, _, _) = state2.unwrap();
assert_eq!(
pos1, pos2,
"Position should be preserved when staying at same spot"
);
}
#[test]
fn test_mouse_click_below_last_line_positions_on_last_line() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "First line\nSecond line\nThird line";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, content_last_row) = harness.content_area_rows();
println!(
"Content area: rows {} to {}",
content_first_row, content_last_row
);
let click_row = content_first_row as u16 + 10; let click_col = 15;
println!("Clicking at row {}, col {}", click_row, click_col);
harness.mouse_click(click_col, click_row).unwrap();
harness.render().unwrap();
let cursor_pos = harness.cursor_position();
println!("Cursor position after click: {}", cursor_pos);
let third_line_start = 23; let content_len = content.len();
assert!(
cursor_pos >= third_line_start,
"Cursor should be on the last line (byte >= {}), but was at position {}. \
Bug: clicking below last line should NOT jump to position 0.",
third_line_start,
cursor_pos
);
assert!(
cursor_pos <= content_len,
"Cursor position {} should be within buffer (len {})",
cursor_pos,
content_len
);
println!(
"SUCCESS: Cursor is on the last line at position {}",
cursor_pos
);
}
#[test]
fn test_double_click_requires_same_position() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "hello world goodbye\nsecond line here\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
let pos_a_col = gutter_width + 2;
let pos_b_col = gutter_width + 8;
harness.mouse_click(pos_a_col, row).unwrap();
harness.render().unwrap();
assert!(
!harness.has_selection() || {
let range = harness.get_selection_range();
range.is_none_or(|r| r.start == r.end)
},
"Single click should not create a selection"
);
harness.mouse_click(pos_b_col, row).unwrap();
harness.render().unwrap();
let selected_text = harness.get_selected_text();
println!(
"Selected text after clicks at different positions: '{}'",
selected_text
);
assert!(
selected_text.is_empty() || selected_text.trim().is_empty(),
"Clicks at different positions should NOT trigger double-click word selection. \
Got selected text: '{}'. \
Bug: Double-click is being detected even when clicks are at different positions.",
selected_text
);
let double_click_delay =
std::time::Duration::from_millis(harness.config().editor.double_click_time_ms * 3);
std::thread::sleep(double_click_delay);
harness.mouse_click(pos_a_col, row).unwrap();
harness.mouse_click(pos_a_col, row).unwrap();
harness.render().unwrap();
let selected_text_same_pos = harness.get_selected_text();
println!(
"Selected text after double-click at same position: '{}'",
selected_text_same_pos
);
assert!(
!selected_text_same_pos.is_empty(),
"Double-click at same position SHOULD select a word, but got empty selection"
);
}
#[test]
fn test_double_click_drag_extends_selection_by_words() {
let mut harness = EditorTestHarness::new_no_wrap(80, 24).unwrap();
let content = "quick brown fox\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
let quick_col = gutter_width + 3; let brown_col = gutter_width + 9; let fox_col = gutter_width + 14;
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: quick_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: quick_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: quick_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let after_double = harness.get_selected_text();
assert_eq!(
after_double, "quick",
"After double-click, should have 'quick' selected, got '{}'",
after_double
);
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: brown_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let after_drag_brown = harness.get_selected_text();
assert!(
after_drag_brown.contains("quick") && after_drag_brown.contains("brown"),
"After drag to 'brown', selection should include both words, got '{}'",
after_drag_brown
);
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: fox_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let after_drag_fox = harness.get_selected_text();
assert_eq!(
after_drag_fox.trim(),
"quick brown fox",
"After drag to 'fox', selection should be 'quick brown fox', got '{}'",
after_drag_fox
);
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: fox_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
}
#[test]
fn test_blinking_bar_selection_first_char_color() {
use fresh::config::{Config, CursorStyle};
let mut config = Config::default();
config.editor.cursor_style = CursorStyle::BlinkingBar;
let mut harness = EditorTestHarness::with_config(80, 24, config).unwrap();
let content = "Hello World\nThis is line 2\nThis is line 3\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let theme = harness.editor().theme();
let selection_bg = theme.selection_bg;
let start_col = 22; let start_row = content_first_row as u16 + 1; let end_col = 8; let end_row = content_first_row as u16;
println!(
"Dragging from ({}, {}) to ({}, {})",
start_col, start_row, end_col, end_row
);
harness
.mouse_drag(start_col, start_row, end_col, end_row)
.unwrap();
harness.render().unwrap();
assert!(harness.has_selection(), "Should have selection after drag");
let selected_text = harness.get_selected_text();
println!("Selected text: '{}'", selected_text);
let buffer = harness.buffer();
let first_char_col = 8; let first_char_row = end_row;
let second_char_col = 9; let third_char_col = 10;
let first_char_idx = buffer.index_of(first_char_col, first_char_row);
let second_char_idx = buffer.index_of(second_char_col, first_char_row);
let third_char_idx = buffer.index_of(third_char_col, first_char_row);
let first_char_cell = &buffer.content[first_char_idx];
let second_char_cell = &buffer.content[second_char_idx];
let third_char_cell = &buffer.content[third_char_idx];
println!(
"First char '{}' bg: {:?}",
first_char_cell.symbol(),
first_char_cell.bg
);
println!(
"Second char '{}' bg: {:?}",
second_char_cell.symbol(),
second_char_cell.bg
);
println!(
"Third char '{}' bg: {:?}",
third_char_cell.symbol(),
third_char_cell.bg
);
println!("Expected selection_bg: {:?}", selection_bg);
assert_eq!(
first_char_cell.bg, selection_bg,
"BUG #614: First character '{}' of selection has wrong background color {:?}, expected selection_bg {:?}. \
With blinking_bar cursor style, the first character displays differently than the rest.",
first_char_cell.symbol(),
first_char_cell.bg,
selection_bg
);
assert_eq!(
second_char_cell.bg,
selection_bg,
"Second character '{}' should have selection background",
second_char_cell.symbol()
);
assert_eq!(
third_char_cell.bg,
selection_bg,
"Third character '{}' should have selection background",
third_char_cell.symbol()
);
assert_eq!(
first_char_cell.bg,
second_char_cell.bg,
"BUG #614: First character '{}' bg {:?} differs from second character '{}' bg {:?}. \
All selected characters should have identical background colors.",
first_char_cell.symbol(),
first_char_cell.bg,
second_char_cell.symbol(),
second_char_cell.bg
);
}
#[test]
fn test_hover_popup_position_with_file_explorer() {
use fresh::model::event::{
Event, PopupContentData, PopupData, PopupKindHint, PopupPositionData,
};
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content = "fn main() {\n let x = 42;\n}\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
harness.editor_mut().toggle_file_explorer();
harness.wait_for_file_explorer().unwrap();
assert!(
harness.editor().file_explorer_visible(),
"File explorer should be visible"
);
harness.render().unwrap();
let screen_before_popup = harness.screen_to_string();
println!("Screen before popup:\n{}", screen_before_popup);
let explorer_width = (80.0 * 0.3) as u16;
let popup_marker = "HOVER_POPUP_MARKER_898";
let _ = harness.apply_event(Event::ShowPopup {
popup: PopupData {
kind: PopupKindHint::Text,
title: None,
description: None,
transient: false,
content: PopupContentData::Text(vec![popup_marker.to_string()]),
position: PopupPositionData::BelowCursor,
width: 30,
max_height: 5,
bordered: true,
},
});
harness.render().unwrap();
let screen = harness.screen_to_string();
println!("Screen with hover popup:\n{}", screen);
let mut popup_start_col: Option<usize> = None;
for (row_idx, line) in screen.lines().enumerate() {
if let Some(col) = line.find(popup_marker) {
println!(
"Found popup marker at row {}, col {} (explorer ends at col {})",
row_idx, col, explorer_width
);
popup_start_col = Some(col);
break;
}
}
let popup_col = popup_start_col.expect("Popup marker should be visible on screen");
assert!(
popup_col >= explorer_width as usize,
"BUG #898: Hover popup appears at column {} which is in the file explorer area (cols 0-{}). \
The popup should be positioned under the cursor in the editor area (cols {}+). \
Screen:\n{}",
popup_col,
explorer_width - 1,
explorer_width,
screen
);
}
#[test]
fn test_double_click_drag_backward_keeps_initial_word_selected() {
let mut harness = EditorTestHarness::new_no_wrap(80, 24).unwrap();
let content = "quick brown fox\n";
let _fixture = harness.load_buffer_from_text(content).unwrap();
harness.render().unwrap();
let (content_first_row, _) = harness.content_area_rows();
let row = content_first_row as u16;
let gutter_width = harness.editor().active_state().margins.left_total_width() as u16;
let brown_col = gutter_width + 9; let quick_col = gutter_width + 3;
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: brown_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: brown_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: brown_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let after_double = harness.get_selected_text();
assert_eq!(
after_double, "brown",
"After double-click, should have 'brown' selected, got '{}'",
after_double
);
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: quick_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let after_drag_back = harness.get_selected_text();
assert!(
after_drag_back.contains("quick") && after_drag_back.contains("brown"),
"After dragging backward to 'quick', selection should include both 'quick' and 'brown', got '{}'",
after_drag_back
);
harness
.send_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: quick_col,
row,
modifiers: KeyModifiers::NONE,
})
.unwrap();
harness.render().unwrap();
let final_selection = harness.get_selected_text();
assert_eq!(
final_selection.trim(),
"quick brown",
"Final selection should be 'quick brown', got '{}'",
final_selection
);
}
#[test]
fn test_scrollbar_track_hover_highlights_single_cell() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=100)
.map(|i| format!("Line {i} with some content\n"))
.collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness.render().unwrap();
let scrollbar_col: u16 = 79;
let (content_first_row, content_last_row) = harness.content_area_rows();
let mut track_rows: Vec<u16> = Vec::new();
for row in content_first_row..=content_last_row {
if harness.is_scrollbar_track_at(scrollbar_col, row as u16) {
track_rows.push(row as u16);
}
}
assert!(
track_rows.len() >= 2,
"Need at least 2 track cells, found {}",
track_rows.len()
);
let hover_row = track_rows[0];
let other_track_row = track_rows[track_rows.len() - 1];
let style_before_hover = harness.get_cell_style(scrollbar_col, hover_row).unwrap();
let style_before_other = harness
.get_cell_style(scrollbar_col, other_track_row)
.unwrap();
harness.mouse_move(scrollbar_col, hover_row).unwrap();
let style_after_hover = harness.get_cell_style(scrollbar_col, hover_row).unwrap();
assert_ne!(
style_before_hover.bg, style_after_hover.bg,
"Hovered track cell at row {} should change color on hover",
hover_row
);
let style_after_other = harness
.get_cell_style(scrollbar_col, other_track_row)
.unwrap();
assert_eq!(
style_before_other.bg, style_after_other.bg,
"Non-hovered track cell at row {} should NOT change color (before: {:?}, after: {:?})",
other_track_row, style_before_other.bg, style_after_other.bg
);
}
#[test]
fn test_scrollbar_track_hover_then_click_clears_highlight() {
let mut harness = EditorTestHarness::new(80, 24).unwrap();
let content: String = (1..=200)
.map(|i| format!("Line {i} with some content\n"))
.collect();
let _fixture = harness.load_buffer_from_text(&content).unwrap();
harness.render().unwrap();
let scrollbar_col: u16 = 79;
let (content_first_row, content_last_row) = harness.content_area_rows();
let mut click_row = None;
for row in (content_first_row..=content_last_row).rev() {
if harness.is_scrollbar_track_at(scrollbar_col, row as u16) {
click_row = Some(row as u16);
break;
}
}
let click_row = click_row.expect("Should have a track cell to click");
let normal_style = harness.get_cell_style(scrollbar_col, click_row).unwrap();
harness.mouse_move(scrollbar_col, click_row).unwrap();
let hover_style = harness.get_cell_style(scrollbar_col, click_row).unwrap();
assert_ne!(
normal_style.bg, hover_style.bg,
"Track cell at row {} should be highlighted on hover",
click_row
);
let top_line_before = harness.top_line_number();
harness.mouse_click(scrollbar_col, click_row).unwrap();
let top_line_after = harness.top_line_number();
assert!(
top_line_after > top_line_before,
"Clicking track should scroll down (before: {}, after: {})",
top_line_before,
top_line_after
);
let post_click_style = harness.get_cell_style(scrollbar_col, click_row).unwrap();
assert_ne!(
post_click_style.bg, hover_style.bg,
"After clicking, cell at row {} should not show track hover highlight (got: {:?})",
click_row, post_click_style.bg
);
}