#![forbid(unsafe_code)]
use crate::{StatefulWidget, clear_text_area, clear_text_row, draw_text_span};
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_style::Style;
use std::{
io,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
}
impl DirEntry {
pub fn dir(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
path: path.into(),
is_dir: true,
}
}
pub fn file(name: impl Into<String>, path: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
path: path.into(),
is_dir: false,
}
}
}
#[derive(Debug, Clone)]
pub struct FilePickerState {
pub current_dir: PathBuf,
pub root: Option<PathBuf>,
pub entries: Vec<DirEntry>,
pub cursor: usize,
pub offset: usize,
pub selected: Option<PathBuf>,
history: Vec<(PathBuf, usize)>,
}
impl FilePickerState {
pub fn new(current_dir: PathBuf, entries: Vec<DirEntry>) -> Self {
Self {
current_dir,
root: None,
entries,
cursor: 0,
offset: 0,
selected: None,
history: Vec::new(),
}
}
#[must_use]
pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
self.root = Some(root.into());
self
}
pub fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
let path = path.as_ref().to_path_buf();
let entries = read_directory(&path)?;
Ok(Self::new(path, entries))
}
pub fn cursor_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_down(&mut self) {
if !self.entries.is_empty() && self.cursor < self.entries.len() - 1 {
self.cursor += 1;
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
if !self.entries.is_empty() {
self.cursor = self.entries.len() - 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.cursor = self.cursor.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
if !self.entries.is_empty() {
self.cursor = (self.cursor + page_size).min(self.entries.len() - 1);
}
}
pub fn enter(&mut self) -> std::io::Result<bool> {
let Some(entry) = self.entries.get(self.cursor) else {
return Ok(false);
};
if !entry.is_dir {
if let Some(root) = &self.root {
ensure_path_within_root(
&entry.path,
root,
"Cannot select a file outside root directory",
)?;
}
self.selected = Some(entry.path.clone());
return Ok(false);
}
let new_dir = entry.path.clone();
if let Some(root) = &self.root {
ensure_path_within_root(
&new_dir,
root,
"Cannot traverse outside root directory via symlink",
)?;
}
let new_entries = read_directory(&new_dir)?;
self.history.push((self.current_dir.clone(), self.cursor));
self.current_dir = new_dir;
self.entries = new_entries;
self.cursor = 0;
self.offset = 0;
Ok(true)
}
pub fn go_back(&mut self) -> std::io::Result<bool> {
if let Some(root) = &self.root {
let (resolved_curr, resolved_root) = canonicalize_candidate_and_root(
&self.current_dir,
root,
"current file picker directory",
)?;
if resolved_curr == resolved_root || !resolved_curr.starts_with(&resolved_root) {
return Ok(false);
}
}
if let Some((prev_dir, prev_cursor)) = self.history.last().cloned() {
if let Some(root) = &self.root {
ensure_path_within_root(
&prev_dir,
root,
"Cannot restore directory outside root directory",
)?;
}
self.history.pop();
let entries = read_directory(&prev_dir)?;
self.current_dir = prev_dir;
self.entries = entries;
self.cursor = prev_cursor.min(self.entries.len().saturating_sub(1));
self.offset = 0;
return Ok(true);
}
if let Some(parent) = self.current_dir.parent().map(|p| p.to_path_buf()) {
if let Some(root) = &self.root
&& !path_is_within_root(&parent, root)?
{
return Ok(false); }
let entries = read_directory(&parent)?;
self.current_dir = parent;
self.entries = entries;
self.cursor = 0;
self.offset = 0;
return Ok(true);
}
Ok(false)
}
fn adjust_scroll(&mut self, visible_rows: usize) {
if visible_rows == 0 {
return;
}
if self.cursor < self.offset {
self.offset = self.cursor;
}
if self.cursor >= self.offset + visible_rows {
self.offset = self.cursor + 1 - visible_rows;
}
}
}
fn canonicalize_for_confinement(path: &Path, label: &str) -> io::Result<PathBuf> {
std::fs::canonicalize(path).map_err(|error| {
io::Error::new(
error.kind(),
format!("Cannot resolve {label} {}: {error}", path.display()),
)
})
}
fn canonicalize_candidate_and_root(
candidate: &Path,
root: &Path,
label: &str,
) -> io::Result<(PathBuf, PathBuf)> {
let resolved_candidate = canonicalize_for_confinement(candidate, label)?;
let resolved_root = canonicalize_for_confinement(root, "file picker root")?;
Ok((resolved_candidate, resolved_root))
}
fn path_is_within_root(candidate: &Path, root: &Path) -> io::Result<bool> {
let (resolved_candidate, resolved_root) =
canonicalize_candidate_and_root(candidate, root, "file picker path")?;
Ok(resolved_candidate.starts_with(resolved_root))
}
fn ensure_path_within_root(candidate: &Path, root: &Path, message: &str) -> io::Result<()> {
if path_is_within_root(candidate, root)? {
return Ok(());
}
Err(io::Error::new(io::ErrorKind::PermissionDenied, message))
}
fn read_directory(path: &Path) -> std::io::Result<Vec<DirEntry>> {
let mut dirs = Vec::new();
let mut files = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
let mut file_type = entry.file_type()?;
let full_path = entry.path();
if file_type.is_symlink()
&& let Ok(metadata) = std::fs::metadata(&full_path)
{
file_type = metadata.file_type();
}
if file_type.is_dir() {
dirs.push(DirEntry::dir(name, full_path));
} else {
files.push(DirEntry::file(name, full_path));
}
}
dirs.sort_by_key(|a| a.name.to_lowercase());
files.sort_by_key(|a| a.name.to_lowercase());
dirs.extend(files);
Ok(dirs)
}
#[derive(Debug, Clone)]
pub struct FilePicker {
pub dir_style: Style,
pub file_style: Style,
pub cursor_style: Style,
pub header_style: Style,
pub show_header: bool,
pub dir_prefix: &'static str,
pub file_prefix: &'static str,
}
impl Default for FilePicker {
fn default() -> Self {
Self {
dir_style: Style::default(),
file_style: Style::default(),
cursor_style: Style::default(),
header_style: Style::default(),
show_header: true,
dir_prefix: "📁 ",
file_prefix: " ",
}
}
}
impl FilePicker {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn dir_style(mut self, style: Style) -> Self {
self.dir_style = style;
self
}
#[must_use]
pub fn file_style(mut self, style: Style) -> Self {
self.file_style = style;
self
}
#[must_use]
pub fn cursor_style(mut self, style: Style) -> Self {
self.cursor_style = style;
self
}
#[must_use]
pub fn header_style(mut self, style: Style) -> Self {
self.header_style = style;
self
}
#[must_use]
pub fn show_header(mut self, show: bool) -> Self {
self.show_header = show;
self
}
}
impl StatefulWidget for FilePicker {
type State = FilePickerState;
fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
if area.is_empty() {
return;
}
let deg = frame.buffer.degradation;
if !deg.render_content() {
return;
}
clear_text_area(frame, area, Style::default());
let header_style = if deg.apply_styling() {
self.header_style
} else {
Style::default()
};
let dir_style = if deg.apply_styling() {
self.dir_style
} else {
Style::default()
};
let file_style = if deg.apply_styling() {
self.file_style
} else {
Style::default()
};
let cursor_style = if deg.apply_styling() {
self.cursor_style
} else {
Style::default()
};
let mut y = area.y;
let max_y = area.bottom();
if self.show_header && y < max_y {
clear_text_row(frame, Rect::new(area.x, y, area.width, 1), header_style);
let header = state.current_dir.to_string_lossy();
draw_text_span(frame, area.x, y, &header, header_style, area.right());
y += 1;
}
if y >= max_y {
return;
}
let visible_rows = (max_y - y) as usize;
state.adjust_scroll(visible_rows);
if state.entries.is_empty() {
clear_text_row(frame, Rect::new(area.x, y, area.width, 1), file_style);
draw_text_span(
frame,
area.x,
y,
"(empty directory)",
file_style,
area.right(),
);
return;
}
let end_idx = (state.offset + visible_rows).min(state.entries.len());
for (i, entry) in state.entries[state.offset..end_idx].iter().enumerate() {
if y >= max_y {
break;
}
let actual_idx = state.offset + i;
let is_cursor = actual_idx == state.cursor;
let prefix = if entry.is_dir {
self.dir_prefix
} else {
self.file_prefix
};
let base_style = if entry.is_dir { dir_style } else { file_style };
let style = if is_cursor {
cursor_style.merge(&base_style)
} else {
base_style
};
clear_text_row(frame, Rect::new(area.x, y, area.width, 1), style);
let mut x = area.x;
if is_cursor {
draw_text_span(frame, x, y, "> ", cursor_style, area.right());
x = x.saturating_add(2);
} else {
x = x.saturating_add(2);
}
x = draw_text_span(frame, x, y, prefix, style, area.right());
draw_text_span(frame, x, y, &entry.name, style, area.right());
y += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::grapheme_pool::GraphemePool;
fn buf_to_lines(buf: &ftui_render::buffer::Buffer) -> Vec<String> {
let mut lines = Vec::new();
for y in 0..buf.height() {
let mut row = String::with_capacity(buf.width() as usize);
for x in 0..buf.width() {
let ch = buf
.get(x, y)
.and_then(|c| c.content.as_char())
.unwrap_or(' ');
row.push(ch);
}
lines.push(row);
}
lines
}
fn make_entries() -> Vec<DirEntry> {
vec![
DirEntry::dir("docs", "/tmp/docs"),
DirEntry::dir("src", "/tmp/src"),
DirEntry::file("README.md", "/tmp/README.md"),
DirEntry::file("main.rs", "/tmp/main.rs"),
]
}
fn make_state() -> FilePickerState {
FilePickerState::new(PathBuf::from("/tmp"), make_entries())
}
#[test]
fn dir_entry_constructors() {
let d = DirEntry::dir("src", "/src");
assert!(d.is_dir);
assert_eq!(d.name, "src");
let f = DirEntry::file("main.rs", "/main.rs");
assert!(!f.is_dir);
assert_eq!(f.name, "main.rs");
}
#[test]
fn state_cursor_movement() {
let mut state = make_state();
assert_eq!(state.cursor, 0);
state.cursor_down();
assert_eq!(state.cursor, 1);
state.cursor_down();
state.cursor_down();
assert_eq!(state.cursor, 3);
state.cursor_down();
assert_eq!(state.cursor, 3);
state.cursor_up();
assert_eq!(state.cursor, 2);
state.cursor_home();
assert_eq!(state.cursor, 0);
state.cursor_up();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 3);
}
#[test]
fn state_page_navigation() {
let entries: Vec<DirEntry> = (0..20)
.map(|i| DirEntry::file(format!("file{i}.txt"), format!("/tmp/file{i}.txt")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
state.page_down(5);
assert_eq!(state.cursor, 5);
state.page_down(5);
assert_eq!(state.cursor, 10);
state.page_up(3);
assert_eq!(state.cursor, 7);
state.page_up(100);
assert_eq!(state.cursor, 0);
state.page_down(100);
assert_eq!(state.cursor, 19);
}
#[test]
fn state_empty_entries() {
let mut state = FilePickerState::new(PathBuf::from("/tmp"), vec![]);
state.cursor_down(); state.cursor_up();
state.cursor_end();
state.cursor_home();
state.page_down(10);
state.page_up(10);
assert_eq!(state.cursor, 0);
}
#[test]
fn adjust_scroll_keeps_cursor_visible() {
let entries: Vec<DirEntry> = (0..20)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.cursor = 15;
state.adjust_scroll(5);
assert!(state.offset <= 15);
assert!(state.offset + 5 > 15);
state.cursor = 0;
state.adjust_scroll(5);
assert_eq!(state.offset, 0);
}
#[test]
fn render_basic() {
let picker = FilePicker::new().show_header(false);
let mut state = make_state();
let area = Rect::new(0, 0, 30, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].starts_with("> "));
let all_text = lines.join("\n");
assert!(all_text.contains("docs"));
assert!(all_text.contains("src"));
assert!(all_text.contains("README.md"));
assert!(all_text.contains("main.rs"));
}
#[test]
fn render_with_header() {
let picker = FilePicker::new().show_header(true);
let mut state = make_state();
let area = Rect::new(0, 0, 30, 6);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 6, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].starts_with("/tmp"));
}
#[test]
fn render_empty_directory() {
let picker = FilePicker::new().show_header(false);
let mut state = FilePickerState::new(PathBuf::from("/empty"), vec![]);
let area = Rect::new(0, 0, 30, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 3, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].contains("empty directory"));
}
#[test]
fn render_scrolling() {
let entries: Vec<DirEntry> = (0..20)
.map(|i| DirEntry::file(format!("file{i:02}.txt"), format!("/tmp/file{i:02}.txt")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/tmp"), entries);
let picker = FilePicker::new().show_header(false);
state.cursor = 15;
let area = Rect::new(0, 0, 30, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
let all_text = lines.join("\n");
assert!(all_text.contains("file15"));
}
#[test]
fn cursor_style_applied_to_selected_row() {
use ftui_render::cell::PackedRgba;
let picker = FilePicker::new()
.show_header(false)
.cursor_style(Style::new().fg(PackedRgba::rgb(255, 0, 0)));
let mut state = make_state();
state.cursor = 1;
let area = Rect::new(0, 0, 30, 4);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 4, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[1].starts_with("> "));
assert!(!lines[0].starts_with("> "));
}
#[test]
fn selected_set_on_file_entry() {
let mut state = make_state();
state.cursor = 2;
let result = state.enter();
assert!(result.is_ok());
assert_eq!(state.selected, Some(PathBuf::from("/tmp/README.md")));
}
#[test]
fn enter_on_file_rejects_canonical_path_outside_root() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo = root
.parent()
.and_then(Path::parent)
.expect("crate should be under workspace crates directory");
let outside_file = repo.join("Cargo.toml");
let mut state = FilePickerState::new(
root.clone(),
vec![DirEntry::file("Cargo.toml", outside_file)],
)
.with_root(root);
let error = state
.enter()
.expect_err("root confinement should reject outside file selections");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(state.selected.is_none());
}
#[test]
fn enter_on_directory_with_unresolvable_root_fails_closed() {
let current_dir = std::env::current_dir().expect("test should run inside the workspace");
let missing_root =
current_dir.join(format!(".missing-file-picker-root-{}", std::process::id()));
let target_dir = std::env::temp_dir();
let mut state = FilePickerState::new(
current_dir.clone(),
vec![DirEntry::dir("tmp", target_dir.clone())],
)
.with_root(missing_root);
let error = state
.enter()
.expect_err("unresolvable confinement root should fail closed");
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert_eq!(state.current_dir, current_dir);
assert_eq!(state.entries[0].path, target_dir);
}
#[test]
fn dir_entry_equality() {
let a = DirEntry::dir("src", "/src");
let b = DirEntry::dir("src", "/src");
assert_eq!(a, b);
let c = DirEntry::file("src", "/src");
assert_ne!(a, c, "dir vs file should differ");
}
#[test]
fn dir_entry_clone() {
let orig = DirEntry::file("main.rs", "/main.rs");
let cloned = orig.clone();
assert_eq!(orig, cloned);
}
#[test]
fn dir_entry_debug_format() {
let e = DirEntry::dir("test", "/test");
let dbg = format!("{e:?}");
assert!(dbg.contains("test"));
assert!(dbg.contains("is_dir: true"));
}
#[test]
fn state_new_defaults() {
let state = FilePickerState::new(PathBuf::from("/home"), vec![]);
assert_eq!(state.current_dir, PathBuf::from("/home"));
assert_eq!(state.cursor, 0);
assert_eq!(state.offset, 0);
assert!(state.selected.is_none());
assert!(state.root.is_none());
assert!(state.entries.is_empty());
}
#[test]
fn state_with_root_sets_root() {
let state = FilePickerState::new(PathBuf::from("/home/user"), vec![]).with_root("/home");
assert_eq!(state.root, Some(PathBuf::from("/home")));
}
#[test]
fn cursor_movement_single_entry() {
let entries = vec![DirEntry::file("only.txt", "/only.txt")];
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
assert_eq!(state.cursor, 0);
state.cursor_down();
assert_eq!(state.cursor, 0, "can't go past single entry");
state.cursor_up();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 0);
state.cursor_home();
assert_eq!(state.cursor, 0);
}
#[test]
fn page_down_clamps_to_last() {
let entries: Vec<DirEntry> = (0..5)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.page_down(100);
assert_eq!(state.cursor, 4);
}
#[test]
fn page_up_clamps_to_zero() {
let entries: Vec<DirEntry> = (0..5)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.cursor = 3;
state.page_up(100);
assert_eq!(state.cursor, 0);
}
#[test]
fn page_operations_on_empty_entries() {
let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
state.page_down(10);
assert_eq!(state.cursor, 0);
state.page_up(10);
assert_eq!(state.cursor, 0);
}
#[test]
fn enter_on_empty_entries_returns_false() {
let mut state = FilePickerState::new(PathBuf::from("/"), vec![]);
let result = state.enter();
assert!(result.is_ok());
assert!(!result.unwrap());
assert!(state.selected.is_none());
}
#[test]
fn enter_on_file_sets_selected_without_navigation() {
let entries = vec![
DirEntry::dir("sub", "/sub"),
DirEntry::file("readme.txt", "/readme.txt"),
];
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.cursor = 1;
let result = state.enter().unwrap();
assert!(!result, "enter on file returns false (no navigation)");
assert_eq!(state.selected, Some(PathBuf::from("/readme.txt")));
assert_eq!(state.current_dir, PathBuf::from("/"));
}
#[test]
fn go_back_blocked_at_root() {
let root = std::env::temp_dir();
let mut state = FilePickerState::new(root.clone(), vec![]).with_root(root);
let changed = state.go_back().unwrap();
assert!(!changed, "go_back should be blocked when already at root");
}
#[test]
fn go_back_without_history_uses_parent_directory() {
let current = std::env::temp_dir();
let parent = current
.parent()
.expect("temp_dir should have a parent")
.to_path_buf();
let mut state = FilePickerState::new(current.clone(), vec![]);
let changed = state.go_back().unwrap();
assert!(
changed,
"go_back should navigate to parent when history is empty"
);
assert_eq!(state.current_dir, parent);
assert_eq!(state.cursor, 0, "parent navigation resets cursor to home");
}
#[test]
fn go_back_restores_history_cursor_with_clamp() {
let child = std::env::temp_dir();
let parent = child
.parent()
.expect("temp_dir should have a parent")
.to_path_buf();
let mut state = FilePickerState::new(
parent.clone(),
vec![
DirEntry::file("placeholder.txt", parent.join("placeholder.txt")),
DirEntry::dir("child", child.clone()),
],
);
state.cursor = 1;
let entered = state.enter().unwrap();
assert!(entered, "enter should navigate into selected directory");
let went_back = state.go_back().unwrap();
assert!(
went_back,
"go_back should restore previous directory from history"
);
assert_eq!(state.current_dir, parent);
let expected_cursor = 1.min(state.entries.len().saturating_sub(1));
assert_eq!(state.cursor, expected_cursor);
}
#[test]
fn adjust_scroll_zero_visible_rows_is_noop() {
let entries: Vec<DirEntry> = (0..10)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.cursor = 5;
state.offset = 0;
state.adjust_scroll(0);
assert_eq!(
state.offset, 0,
"zero visible rows should not change offset"
);
}
#[test]
fn adjust_scroll_cursor_above_viewport() {
let entries: Vec<DirEntry> = (0..20)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.offset = 10;
state.cursor = 5;
state.adjust_scroll(5);
assert_eq!(state.offset, 5, "offset should snap to cursor");
}
#[test]
fn adjust_scroll_cursor_below_viewport() {
let entries: Vec<DirEntry> = (0..20)
.map(|i| DirEntry::file(format!("f{i}"), format!("/f{i}")))
.collect();
let mut state = FilePickerState::new(PathBuf::from("/"), entries);
state.offset = 0;
state.cursor = 10;
state.adjust_scroll(5);
assert_eq!(state.offset, 6);
}
#[test]
fn file_picker_default_values() {
let picker = FilePicker::default();
assert!(picker.show_header);
assert_eq!(picker.dir_prefix, "📁 ");
assert_eq!(picker.file_prefix, " ");
}
#[test]
fn file_picker_builder_chain() {
let picker = FilePicker::new()
.dir_style(Style::default())
.file_style(Style::default())
.cursor_style(Style::default())
.header_style(Style::default())
.show_header(false);
assert!(!picker.show_header);
}
#[test]
fn file_picker_debug_format() {
let picker = FilePicker::new();
let dbg = format!("{picker:?}");
assert!(dbg.contains("FilePicker"));
}
#[test]
fn render_zero_area_is_noop() {
let picker = FilePicker::new();
let mut state = make_state();
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].trim().is_empty());
}
#[test]
fn render_height_one_shows_only_header() {
let picker = FilePicker::new().show_header(true);
let mut state = make_state();
let area = Rect::new(0, 0, 30, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].starts_with("/tmp"));
assert!(lines[1].trim().is_empty());
}
#[test]
fn render_no_header_uses_full_area_for_entries() {
let picker = FilePicker::new().show_header(false);
let mut state = make_state();
let area = Rect::new(0, 0, 30, 4);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 4, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].starts_with("> "));
}
#[test]
fn render_cursor_on_last_entry() {
let picker = FilePicker::new().show_header(false);
let mut state = make_state();
state.cursor = 3;
let area = Rect::new(0, 0, 30, 5);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 5, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
let cursor_line = lines.iter().find(|l| l.starts_with("> ")).unwrap();
assert!(cursor_line.contains("main.rs"));
}
#[test]
fn render_area_offset() {
let picker = FilePicker::new().show_header(false);
let mut state = make_state();
let area = Rect::new(5, 2, 20, 3);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(30, 10, &mut pool);
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert!(lines[0].trim().is_empty());
assert!(lines[1].trim().is_empty());
assert!(lines[2].len() >= 7);
}
#[test]
fn render_shorter_header_and_fewer_entries_clear_stale_content() {
let picker = FilePicker::new().show_header(true);
let mut state = FilePickerState::new(PathBuf::from("/tmp/very/long/path"), make_entries());
let area = Rect::new(0, 0, 24, 4);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(24, 4, &mut pool);
picker.render(area, &mut frame, &mut state);
state.current_dir = PathBuf::from("/x");
state.entries = vec![DirEntry::file("a", "/x/a")];
state.cursor = 0;
state.offset = 0;
picker.render(area, &mut frame, &mut state);
let lines = buf_to_lines(&frame.buffer);
assert_eq!(lines[0], format!("{:<24}", "/x"));
assert!(lines[1].starts_with("> a"));
assert_eq!(lines[2], " ".repeat(24));
assert_eq!(lines[3], " ".repeat(24));
}
}