use crate::{
shell::{Key, Mods},
ui::Mouse,
};
use pixel8_runtime::{
fb::{Framebuffer, HEIGHT, WIDTH},
font,
palette::col,
ui as rui,
};
pub enum PickerAction {
Switch(String),
Create(String),
}
#[derive(PartialEq)]
enum Mode {
List,
NewFile,
}
const LINE_H: i32 = font::GLYPH_H + 1;
const BOX_W: i32 = 90;
const MAX_VIS_ROWS: usize = ((120 - 9 - 4) / LINE_H) as usize;
pub struct FilePicker {
open: bool,
sel: usize,
scroll: usize,
mode: Mode,
input: String,
}
impl FilePicker {
pub fn new() -> Self {
Self {
open: false,
sel: 0,
scroll: 0,
mode: Mode::List,
input: String::new(),
}
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self, files: &[&str], current: &str, previous: Option<&str>) {
self.open = true;
self.mode = Mode::List;
self.input.clear();
self.sel = Self::initial_sel(files, current, previous);
self.scroll = 0;
self.clamp_scroll(files.len() + 1);
}
fn clamp_scroll(&mut self, n: usize) {
let vis = n.min(MAX_VIS_ROWS);
if self.sel < self.scroll {
self.scroll = self.sel;
} else if self.sel >= self.scroll + vis {
self.scroll = self.sel + 1 - vis;
}
self.scroll = self.scroll.min(n.saturating_sub(vis));
}
fn initial_sel(files: &[&str], current: &str, previous: Option<&str>) -> usize {
if let Some(prev) = previous {
if prev != current {
if let Some(i) = files.iter().position(|f| *f == prev) {
return i;
}
}
}
files.iter().position(|f| *f != current).unwrap_or(0)
}
pub fn close(&mut self) {
self.open = false;
}
pub fn key(&mut self, key: Key, mods: Mods, files: &[&str]) -> Option<PickerAction> {
match self.mode {
Mode::List => {
let n = files.len() + 1;
match key {
Key::Up => {
self.sel = (self.sel + n - 1) % n;
self.clamp_scroll(n);
None
}
Key::Down => {
self.sel = (self.sel + 1) % n;
self.clamp_scroll(n);
None
}
Key::Escape => {
self.close();
None
}
Key::Enter => self.confirm_selection(files),
_ => None,
}
}
Mode::NewFile => match key {
Key::Escape => {
self.mode = Mode::List;
None
}
Key::Backspace => {
self.input.pop();
None
}
Key::Enter => {
let name = self.input.trim().to_string();
if name.is_empty() {
return None;
}
self.close();
Some(PickerAction::Create(name))
}
Key::Char(c)
if !mods.ctrl && (c.is_ascii_alphanumeric() || c == '_' || c == '.') =>
{
self.input.push(c);
None
}
_ => None,
},
}
}
pub fn tick(&mut self, mouse: &Mouse, files: &[&str]) -> Option<PickerAction> {
if !mouse.left_pressed {
return None;
}
let n = files.len() + 1;
let (x0, y0, x1, y1) = Self::geom(n);
if !mouse.over(x0, y0, x1, y1) {
self.close();
return None;
}
if self.mode != Mode::List {
return None;
}
let vis = (n.min(MAX_VIS_ROWS)) as i32;
let row = (mouse.y - (y0 + 2)) / LINE_H;
if (0..vis).contains(&row) {
self.sel = self.scroll + row as usize;
return self.confirm_selection(files);
}
None
}
fn confirm_selection(&mut self, files: &[&str]) -> Option<PickerAction> {
if self.sel == files.len() {
self.mode = Mode::NewFile;
self.input.clear();
None
} else {
let name = files[self.sel].to_string();
self.close();
Some(PickerAction::Switch(name))
}
}
pub fn draw(&self, fb: &mut Framebuffer, files: &[&str], current: &str) {
let n = files.len() + 1;
let (x0, y0, x1, y1) = Self::geom(n);
rui::panel(fb, x0, y0, x1, y1, col::DARK_BLUE, col::LIGHT_GREY);
if self.mode == Mode::NewFile {
fb.print("new file:", x0 + 3, y0 + 3, col::LIGHT_GREY);
fb.print(
&format!("{}_", self.input),
x0 + 3,
y0 + 3 + LINE_H,
col::WHITE,
);
return;
}
let vis = n.min(MAX_VIS_ROWS);
for row in 0..vis {
let i = self.scroll + row;
let y = y0 + 2 + row as i32 * LINE_H;
let selected = i == self.sel;
if selected {
fb.rectfill(x0 + 1, y - 1, x1 - 1, y + LINE_H - 2, col::DARK_PURPLE);
}
let (text, color) = if i == files.len() {
("+ new file", if selected { col::WHITE } else { col::GREEN })
} else {
let name = files[i];
let color = if selected {
col::WHITE
} else if name == current {
col::PEACH
} else {
col::LIGHT_GREY
};
(name, color)
};
fb.print(text, x0 + 3, y, color);
}
if self.scroll > 0 {
scroll_marker(fb, x1 - 5, y0 + 2, true, col::LIGHT_GREY);
}
if self.scroll + vis < n {
scroll_marker(fb, x1 - 5, y1 - 4, false, col::LIGHT_GREY);
}
}
fn geom(n: usize) -> (i32, i32, i32, i32) {
let vis = n.min(MAX_VIS_ROWS);
let h = vis as i32 * LINE_H + 4;
let x0 = (WIDTH - BOX_W) / 2;
let y0 = ((HEIGHT - h) / 2).max(9);
(x0, y0, x0 + BOX_W - 1, y0 + h - 1)
}
}
fn scroll_marker(fb: &mut Framebuffer, cx: i32, y: i32, up: bool, color: u8) {
for r in 0..3 {
let half = if up { r } else { 2 - r };
for dx in -half..=half {
fb.pset(cx + dx, y + r, color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn large_list_scrolls_to_keep_selection_visible() {
let names: Vec<String> = (0..20).map(|i| format!("file{i}.rs")).collect();
let files: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
let mut p = FilePicker::new();
p.open(&files, "", None);
let n = files.len() + 1;
for _ in 0..n {
assert!(p.scroll <= p.sel, "scroll={} sel={}", p.scroll, p.sel);
assert!(
p.sel < p.scroll + MAX_VIS_ROWS,
"scroll={} sel={} MAX_VIS_ROWS={}",
p.scroll,
p.sel,
MAX_VIS_ROWS
);
p.key(Key::Down, Mods::default(), &files);
}
assert_eq!(p.sel, 0);
p.key(Key::Up, Mods::default(), &files);
assert_eq!(
p.sel,
files.len(),
"sel should be on the last (+ new file) row"
);
assert!(p.scroll <= p.sel, "scroll={} sel={}", p.scroll, p.sel);
assert!(
p.sel < p.scroll + MAX_VIS_ROWS,
"scroll={} sel={} MAX_VIS_ROWS={}",
p.scroll,
p.sel,
MAX_VIS_ROWS
);
}
#[test]
fn box_stays_on_screen_for_large_lists() {
let (_, y0, _, y1) = FilePicker::geom(25);
assert!(y0 >= 9, "y0={y0} must be >= 9");
assert!(y1 <= 119, "y1={y1} must be <= 119");
let n: usize = 3;
let expected_h = n as i32 * LINE_H + 4;
let (_, y0_small, _, y1_small) = FilePicker::geom(n);
let actual_h = y1_small - y0_small + 1;
assert_eq!(actual_h, expected_h, "small list height mismatch");
}
#[test]
fn arrows_move_and_enter_switches() {
let files = ["lib.rs", "enemy.rs"];
let mut p = FilePicker::new();
p.open(&files, "", None);
assert!(p.key(Key::Down, Mods::default(), &files).is_none());
let action = p.key(Key::Enter, Mods::default(), &files);
assert!(matches!(action, Some(PickerAction::Switch(ref s)) if s == "enemy.rs"));
assert!(!p.is_open());
}
#[test]
fn new_file_flow_emits_create() {
let files = ["lib.rs"];
let mut p = FilePicker::new();
p.open(&files, "", None);
p.key(Key::Down, Mods::default(), &files);
assert!(p.key(Key::Enter, Mods::default(), &files).is_none());
for c in "enemy".chars() {
p.key(Key::Char(c), Mods::default(), &files);
}
let action = p.key(Key::Enter, Mods::default(), &files);
assert!(matches!(action, Some(PickerAction::Create(ref s)) if s == "enemy"));
assert!(!p.is_open());
}
#[test]
fn escape_backs_out_then_closes() {
let files = ["lib.rs"];
let mut p = FilePicker::new();
p.open(&files, "", None);
p.key(Key::Down, Mods::default(), &files);
p.key(Key::Enter, Mods::default(), &files); p.key(Key::Escape, Mods::default(), &files); assert!(p.is_open());
p.key(Key::Escape, Mods::default(), &files); assert!(!p.is_open());
}
#[test]
fn click_outside_closes_in_new_file_mode() {
let files = ["lib.rs"];
let mut p = FilePicker::new();
p.open(&files, "", None);
p.key(Key::Down, Mods::default(), &files); p.key(Key::Enter, Mods::default(), &files); let outside = Mouse {
left_pressed: true,
..Default::default()
};
assert!(p.tick(&outside, &files).is_none());
assert!(!p.is_open());
}
#[test]
fn open_preselects_previous_else_first_other() {
let files = ["lib.rs", "enemy.rs", "player.rs"];
let mut p = FilePicker::new();
p.open(&files, "lib.rs", Some("player.rs"));
let action = p.key(Key::Enter, Mods::default(), &files);
assert!(matches!(action, Some(PickerAction::Switch(ref s)) if s == "player.rs"));
p.open(&files, "lib.rs", None);
let action = p.key(Key::Enter, Mods::default(), &files);
assert!(matches!(action, Some(PickerAction::Switch(ref s)) if s == "enemy.rs"));
p.open(&files, "enemy.rs", Some("gone.rs"));
let action = p.key(Key::Enter, Mods::default(), &files);
assert!(matches!(action, Some(PickerAction::Switch(ref s)) if s == "lib.rs"));
}
}