use super::*;
#[test]
fn a_click_on_the_key_bar_is_the_key() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let label = |terminal: &ratatui::Terminal<ratatui::backend::TestBackend>, cell: ratatui::layout::Rect| -> String { (cell.x..cell.right()).map(|x| terminal.backend().buffer()[(x, cell.y)].symbol().to_string()).collect() };
let hit = |app_state: &AppState, key: u8| app_state.drawn.fkey_hits.iter().find(|(_, k)| *k == key).map(|(cell, _)| *cell);
let click = |app_state: &mut AppState, cell: ratatui::layout::Rect| {
crate::input::handle_mouse(
app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: cell.x + 1,
row: cell.y,
modifiers: KeyModifiers::NONE,
},
)
};
assert_eq!(app_state.drawn.fkey_hits.len(), 12);
assert_eq!(label(&terminal, hit(&app_state, 8).unwrap()), " F8 Trash ");
app_state.options.delete_to_trash = false;
crate::ui::render_ui(&mut terminal, &mut app_state);
assert_eq!(label(&terminal, hit(&app_state, 8).unwrap()), " F8 Delete ");
let (f7, f10) = (hit(&app_state, 7).unwrap(), hit(&app_state, 10).unwrap());
assert!(click(&mut app_state, f7));
assert!(matches!(app_state.dialog, Some(Dialog::Create { is_dir: true, .. })));
assert!(click(&mut app_state, f10));
press(&mut app_state, KeyCode::Esc);
assert!(!click(&mut app_state, f10));
let dir = std::env::temp_dir().join(format!("fm84-key-bar-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "text").unwrap();
app_state.open_dir(Side::Left, dir.clone(), Some("a.txt".as_ref()));
app_state.active_side = Side::Left;
press(&mut app_state, KeyCode::F(3));
assert!(app_state.is_viewing());
let keys = |terminal: &mut ratatui::Terminal<ratatui::backend::TestBackend>, app_state: &mut AppState| {
crate::ui::render_ui(terminal, app_state);
app_state.drawn.fkey_hits.iter().map(|(_, key)| *key).collect::<Vec<_>>()
};
assert_eq!(keys(&mut terminal, &mut app_state), [3, 10]);
app_state.viewer_mut().unwrap().mode = crate::viewer::ViewMode::Image;
assert_eq!(keys(&mut terminal, &mut app_state), [10]);
app_state.viewer_mut().unwrap().mode = crate::viewer::ViewMode::Text;
press(&mut app_state, KeyCode::Esc);
std::fs::remove_dir_all(&dir).unwrap();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 30)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let shown = app_state.drawn.fkey_hits.len();
assert!((1..12).contains(&shown));
let (last, _) = app_state.drawn.fkey_hits[shown - 1];
assert!(label(&terminal, last).ends_with(' ') && last.right() < 60);
}
#[test]
fn a_click_on_a_path_edits_it() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app_state = AppState::new();
app_state.active_side = Side::Left;
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let bar = app_state.drawn.path_bar_area;
let click_at = |app_state: &mut AppState, column, row| {
crate::input::handle_mouse(
app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column,
row,
modifiers: KeyModifiers::NONE,
},
);
};
let click = |app_state: &mut AppState, column| click_at(app_state, column, bar.y);
let going_to = |app_state: &AppState| match &app_state.dialog {
Some(Dialog::Prompt(PromptKind::GoTo, input)) => Some(input.text.clone()),
_ => None,
};
click(&mut app_state, bar.x + bar.width - 10);
assert_eq!(app_state.active_side, Side::Right);
assert!(going_to(&app_state).unwrap().starts_with(&*app_state.right.dir.to_string_lossy()));
press(&mut app_state, KeyCode::Char('x'));
let typed = going_to(&app_state);
click(&mut app_state, bar.x + bar.width - 10);
assert_eq!(going_to(&app_state), typed);
click(&mut app_state, bar.x + 5);
assert_eq!(app_state.active_side, Side::Left);
assert!(going_to(&app_state).unwrap().starts_with(&*app_state.left.dir.to_string_lossy()));
let right = app_state.drawn.table_areas[Side::Right];
click_at(&mut app_state, right.x + 3, right.y + 1);
assert!(going_to(&app_state).is_none());
assert_eq!(app_state.active_side, Side::Right);
click(&mut app_state, bar.x + bar.width - 10);
click_at(&mut app_state, bar.x + 1, 0);
assert!(going_to(&app_state).is_none());
}
#[test]
fn wide_characters_take_their_columns_in_the_editor() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let path = std::env::temp_dir().join(format!("fm84-wide-editor-{}.txt", std::process::id()));
std::fs::write(&path, "æ—¥".repeat(60)).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_editor(path.clone()).unwrap();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
press(&mut app_state, KeyCode::End);
crate::ui::render_ui(&mut terminal, &mut app_state);
let area = app_state.drawn.editor_content_area;
let state = app_state.editor().unwrap();
assert_eq!(state.cursor_col, 60);
let cursor = crate::utils::display_column(&state.lines[0], state.cursor_col);
assert!(
cursor >= state.horizontal_offset && cursor < state.horizontal_offset + area.width as usize,
"cursor at column {cursor}, showing from {} across {}",
state.horizontal_offset,
area.width
);
press(&mut app_state, KeyCode::Home);
crate::ui::render_ui(&mut terminal, &mut app_state);
assert_eq!(app_state.editor().unwrap().horizontal_offset, 0);
let area = app_state.drawn.editor_content_area;
crate::input::handle_mouse(
&mut app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x + 4,
row: area.y,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(app_state.editor().unwrap().cursor_col, 2);
std::fs::remove_file(path).unwrap();
}
#[test]
fn scroll_bars_page_when_clicked_and_follow_a_drag() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let dir = std::env::temp_dir().join(format!("fm84-bar-mouse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.txt");
std::fs::write(&path, (1..=200).map(|n| format!("{n} {}\n", "x".repeat(300))).collect::<String>()).unwrap();
let mouse = |app_state: &mut AppState, kind: MouseEventKind, (column, row): (u16, u16)| {
crate::input::handle_mouse(
app_state,
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
},
);
};
let (down, drag, up) = (MouseEventKind::Down(MouseButton::Left), MouseEventKind::Drag(MouseButton::Left), MouseEventKind::Up(MouseButton::Left));
for edit in [false, true] {
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
if edit {
app_state.open_editor(path.clone()).unwrap();
} else {
app_state.open_viewer(path.clone()).unwrap();
}
let mut draw = |app_state: &mut AppState| crate::ui::render_ui(&mut terminal, app_state);
let offsets = |app_state: &AppState| match &app_state.screen {
Screen::Editor(state) => (state.scroll_offset, state.horizontal_offset),
Screen::Viewer(state) => (state.scroll_offset, state.horizontal_offset),
_ => unreachable!(),
};
let bar = |app_state: &AppState, vertical: bool| *app_state.drawn.scroll_bars.iter().find(|bar| bar.vertical == vertical).expect("a bar");
let thumb_cell = |bar: crate::scroll_bar::ScrollBar| {
let (start, _) = crate::scroll_bar::thumb(if bar.vertical { bar.track.height } else { bar.track.width } as usize, bar.extent).unwrap();
if bar.vertical { (bar.track.x, bar.track.y + start as u16) } else { (bar.track.x + start as u16, bar.track.y) }
};
draw(&mut app_state);
let vertical = bar(&app_state, true);
let view = vertical.extent.view;
mouse(&mut app_state, down, (vertical.track.x, vertical.track.bottom() - 1));
mouse(&mut app_state, up, (vertical.track.x, vertical.track.bottom() - 1));
draw(&mut app_state);
assert_eq!(offsets(&app_state).0, view, "edit: {edit}");
if edit {
assert_eq!(app_state.editor().unwrap().cursor_line, 0, "the click went to the bar, not the text");
} else {
assert_eq!(app_state.viewer().unwrap().selection, None, "the click went to the bar, not the text");
}
let vertical = bar(&app_state, true);
let grabbed = thumb_cell(vertical);
mouse(&mut app_state, down, grabbed);
mouse(&mut app_state, drag, (grabbed.0 + 5, 200));
draw(&mut app_state);
let furthest = vertical.extent.content - view;
assert_eq!(offsets(&app_state).0, furthest, "edit: {edit}");
mouse(&mut app_state, drag, (grabbed.0, grabbed.1));
assert_eq!(offsets(&app_state).0, vertical.extent.offset, "followed back up");
mouse(&mut app_state, up, (grabbed.0, grabbed.1));
mouse(&mut app_state, drag, (grabbed.0, 200));
assert_eq!(offsets(&app_state).0, vertical.extent.offset, "let go, it stays");
draw(&mut app_state);
let horizontal = bar(&app_state, false);
let grabbed = thumb_cell(horizontal);
mouse(&mut app_state, down, grabbed);
mouse(&mut app_state, drag, (horizontal.track.right() + 10, grabbed.1));
mouse(&mut app_state, up, (horizontal.track.right() + 10, grabbed.1));
draw(&mut app_state);
assert_eq!(offsets(&app_state).1, horizontal.extent.content - horizontal.extent.view, "edit: {edit}");
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_click_on_the_branch_lists_branches_to_switch_to() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let dir = std::env::temp_dir().join(format!("fm84-branch-click-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let git = |arguments: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(&dir)
.args(["-c", "user.name=fm84", "-c", "user.email=fm84@localhost", "-c", "commit.gpgsign=false"])
.args(arguments)
.output()
.is_ok_and(|output| output.status.success())
};
if !git(&["init", "--quiet", "--initial-branch=main"]) {
std::fs::remove_dir_all(&dir).unwrap();
return;
}
std::fs::write(dir.join("file.txt"), "main\n").unwrap();
assert!(git(&["add", "file.txt"]) && git(&["commit", "--quiet", "-m", "first"]));
assert!(git(&["switch", "--quiet", "-c", "feature"]));
std::fs::write(dir.join("file.txt"), "feature\n").unwrap();
assert!(git(&["commit", "--quiet", "-am", "second"]));
assert!(git(&["switch", "--quiet", "main"]));
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_dir(Side::Left, dir.clone(), None);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(140, 30)).unwrap();
let mut draw = |app_state: &mut AppState| crate::ui::render_ui(&mut terminal, app_state);
let click = |app_state: &mut AppState, cell: ratatui::layout::Rect| {
crate::input::mouse_to(
app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: cell.x + 1,
row: cell.y,
modifiers: KeyModifiers::NONE,
},
);
};
let listed = |app_state: &AppState| match &app_state.dialog {
Some(Dialog::Branches(list)) => Some((list.names.clone(), list.current, list.index)),
_ => None,
};
draw(&mut app_state);
assert_eq!(app_state.left.branch.as_deref(), Some("main"));
let branch = app_state.drawn.branch_hits[Side::Left].expect("the branch is drawn");
click(&mut app_state, branch);
assert_eq!(listed(&app_state), Some((vec!["feature".to_string(), "main".to_string()], Some(1), 1)));
draw(&mut app_state);
let feature = app_state.drawn.branch_rows.iter().find(|(_, index)| *index == 0).unwrap().0;
click(&mut app_state, feature);
assert_eq!(listed(&app_state).map(|(_, _, index)| index), Some(0));
draw(&mut app_state);
click(&mut app_state, feature);
assert!(app_state.dialog.is_none());
assert_eq!(app_state.error, None);
assert_eq!(app_state.left.branch.as_deref(), Some("feature"));
assert_eq!(std::fs::read_to_string(dir.join("file.txt")).unwrap(), "feature\n");
std::fs::write(dir.join("file.txt"), "not committed\n").unwrap();
draw(&mut app_state);
let branch = app_state.drawn.branch_hits[Side::Left].unwrap();
click(&mut app_state, branch);
press(&mut app_state, KeyCode::Down);
assert_eq!(listed(&app_state).map(|(_, _, index)| index), Some(1));
press(&mut app_state, KeyCode::Enter);
let refused = app_state.error.clone().expect("git said why");
assert!(refused.contains("file.txt"), "{refused}");
assert_eq!(app_state.left.branch.as_deref(), Some("feature"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_viewer_counts_wide_characters_as_the_columns_they_take() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let path = std::env::temp_dir().join(format!("fm84-viewer-wide-{}.txt", std::process::id()));
std::fs::write(&path, format!("{}x{}\n", "日".repeat(100), "本".repeat(20))).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_viewer(path.clone()).unwrap();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let area = app_state.drawn.viewer_content_area;
let click = |app_state: &mut AppState, column: u16| {
crate::input::handle_mouse(
app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x + column,
row: area.y,
modifiers: KeyModifiers::NONE,
},
);
app_state.viewer().unwrap().selection.map(|(anchor, _)| anchor)
};
assert_eq!(click(&mut app_state, 10), Some((0, 5)));
assert_eq!(click(&mut app_state, 11), Some((0, 5)));
app_state.recall.find_term = "x".to_string();
app_state.find(true, true);
crate::ui::render_ui(&mut terminal, &mut app_state);
let buffer = terminal.backend().buffer();
let row: String = (area.x..area.right()).map(|x| buffer[(x, area.y)].symbol().to_string()).collect();
assert!(row.contains('x'), "the match is on screen: {row}");
let at = (area.x..area.right()).position(|x| buffer[(x, area.y)].symbol() == "x").unwrap();
assert_eq!(click(&mut app_state, at as u16), Some((0, 100)));
app_state.viewer_mut().unwrap().wrap = true;
crate::ui::render_ui(&mut terminal, &mut app_state);
app_state.fit_viewer_wrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let state = app_state.viewer().unwrap();
let (_, second_from, _) = state.row(1);
crate::input::handle_mouse(
&mut app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x + 4,
row: area.y + 1,
modifiers: KeyModifiers::NONE,
},
);
assert_eq!(app_state.viewer().unwrap().selection.map(|(anchor, _)| anchor), Some((0, second_from + 2)));
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_double_click_enters_a_directory() {
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let dir = std::env::temp_dir().join(format!("fm84-double-click-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
std::fs::create_dir_all(dir.join("other")).unwrap();
let mut app_state = AppState::new();
app_state.open_dir(Side::Left, dir.clone(), None);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
crate::ui::render_ui(&mut terminal, &mut app_state);
let row_of = |app_state: &AppState, name: &str| {
let index = app_state.left.children.iter().position(|item| item.name_os == name).unwrap();
app_state.drawn.table_areas[Side::Left].y + 1 + (index - app_state.drawn.table_starts[Side::Left]) as u16
};
let click = |app_state: &mut AppState, row: u16| {
crate::input::handle_mouse(
app_state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: app_state.drawn.table_areas[Side::Left].x + 2,
row,
modifiers: KeyModifiers::NONE,
},
)
};
let (inner, other) = (row_of(&app_state, "inner"), row_of(&app_state, "other"));
click(&mut app_state, other);
click(&mut app_state, inner);
assert_eq!(app_state.left.dir, dir, "two rows are not a double click");
click(&mut app_state, inner);
assert_eq!(app_state.left.dir, dir.join("inner"));
let _ = std::fs::remove_dir_all(&dir);
}