diff --git a/crates/wayfarer/src/tui.rs b/crates/wayfarer/src/tui.rs index 6a57f28..f9b7baf 100644 --- a/crates/wayfarer/src/tui.rs +++ b/crates/wayfarer/src/tui.rs @@ -1,25 +1,21 @@ #![cfg(feature = "tui")] +mod events; +mod view; + + use std::io::{self, Stdout}; use std::sync::mpsc::{self, TryRecvError}; -use std::time::Duration; use anyhow::Result; use clap::Parser as ArgParser; -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, -}; +use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use crossterm::{event, execute}; -use jrny_save::{Savefile, LEVEL_NAMES}; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::Style; -use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; use tracing::{debug, error, info}; -use tui_input::backend::crossterm::EventHandler; use tui_input::Input; use crate::state::PersistentState; @@ -28,11 +24,14 @@ use crate::watcher::FileWatcher; use crate::Args as AppArgs; +type Terminal = ratatui::Terminal>; + + #[derive(Debug, Clone, ArgParser)] pub struct Args; -struct State { +pub struct State { persistent: PersistentState, mode: Mode, file_select: Input, @@ -43,7 +42,7 @@ struct State { #[derive(Debug, Clone)] #[non_exhaustive] -enum Message { +pub enum Message { Exit, SetMode(Mode), @@ -59,7 +58,7 @@ enum Message { #[derive(Debug, Default, Clone, PartialEq, Eq)] -enum Mode { +pub enum Mode { #[default] Normal, @@ -69,10 +68,6 @@ enum Mode { } -type Terminal = ratatui::Terminal>; -type Frame<'a> = ratatui::Frame<'a, CrosstermBackend>; - - pub(crate) fn execute(_app_args: &AppArgs, _args: &Args) -> Result<()> { let persistent = PersistentState::load()?; @@ -101,10 +96,10 @@ fn run(terminal: &mut Terminal, mut state: State) -> Result<()> { loop { terminal.draw(|frame| { - render(&mut state, frame); + view::render(&mut state, frame); })?; - handle_events(&mut msg_tx, &mut state)?; + events::handle(&mut msg_tx, &mut state)?; match msg_rx.try_recv() { Ok(Message::Exit) => { @@ -190,67 +185,6 @@ fn handle_message( } -fn handle_events(event_queue: &mut mpsc::Sender, state: &mut State) -> Result<()> { - if !event::poll(Duration::from_millis(250))? { - return Ok(()); - } - - match event::read()? { - Event::Key(key) => handle_keyboard_input(key, event_queue, state)?, - _ => return Ok(()), - } - - Ok(()) -} - - -#[tracing::instrument(skip(msg_tx, state))] -fn handle_keyboard_input( - key: KeyEvent, - msg_tx: &mut mpsc::Sender, - state: &mut State, -) -> Result<()> { - match (&state.mode, key.code) { - (_, KeyCode::Char('q')) if key.modifiers.contains(KeyModifiers::CONTROL) => { - msg_tx.send(Message::Exit)?; - } - - (_, KeyCode::Esc) => { - msg_tx.send(Message::SetMode(Mode::Normal))?; - } - - (Mode::ShowError(_), _) => { - msg_tx.send(Message::SetMode(Mode::Normal))?; - } - - (Mode::SelectFile, KeyCode::Enter) => { - msg_tx.send(Message::LoadFile)?; - } - - (Mode::SelectFile, _) => { - state.file_select.handle_event(&Event::Key(key)); - } - - (Mode::Normal, KeyCode::Char('q')) => { - msg_tx.send(Message::Exit)?; - } - - (Mode::Normal, KeyCode::Char('o')) => { - msg_tx.send(Message::SetMode(Mode::SelectFile))?; - } - - #[cfg(feature = "watch")] - (Mode::Normal, KeyCode::Char('w')) => { - msg_tx.send(Message::ToggleFileWatch)?; - } - - _ => (), - }; - - Ok(()) -} - - fn setup() -> Result { let mut stdout = io::stdout(); @@ -277,268 +211,3 @@ fn reset(mut terminal: Terminal) -> Result<()> { Ok(()) } - - -fn render(state: &mut State, mut frame: &mut Frame) { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(40), Constraint::Length(2)]) - .split(frame.size()); - - match &state.persistent.savefile { - Some(savefile) => render_info(&savefile, &mut frame, rows[0]), - None => render_no_active_file(&mut frame, rows[0]), - } - - render_status_bar(&state, &mut frame, rows[1]); -} - - -fn render_no_active_file(frame: &mut Frame, area: Rect) { - let info_block = Block::default() - .padding(Padding::horizontal(2)) - .borders(Borders::ALL); - - let info = Paragraph::new("No active file.\nPress 'o' to open a file, or 'q' to quit.") - .block(info_block); - - frame.render_widget(info, area); -} - - -fn render_status_bar(state: &State, mut frame: &mut Frame, area: Rect) { - let status_block = Block::default().padding(Padding::horizontal(2)); - - match &state.mode { - Mode::ShowError(error_msg) => { - let error_msg = Paragraph::new(error_msg.clone()) - .style(Style::default().fg(ratatui::style::Color::LightRed)) - .block(status_block); - frame.render_widget(error_msg, area); - } - - #[cfg(feature = "watch")] - Mode::Normal if state.file_watcher.is_some() && state.persistent.savefile.is_some() => { - let savefile = state.persistent.savefile.as_ref().unwrap(); - let text = format!("Watching file: {}", savefile.path.display()); - let status = Paragraph::new(text).block(status_block); - frame.render_widget(status, area); - } - - Mode::Normal if state.persistent.savefile.is_none() => (), - - Mode::Normal => { - let savefile = state.persistent.savefile.as_ref().unwrap(); - let text = format!("Showing file: {}", savefile.path.display()); - let status = Paragraph::new(text).block(status_block); - frame.render_widget(status, area); - } - - Mode::SelectFile => render_file_select(&state, &mut frame, status_block, area), - } -} - -fn render_file_select(state: &State, frame: &mut Frame, block: Block, area: Rect) { - const PROMPT: &str = "Open file:"; - const PADDING: usize = 2; - - let scroll = state.file_select.visual_scroll(area.width as usize); - let input = Paragraph::new(format!("{} {}", PROMPT, state.file_select.value())) - .scroll((0, scroll as u16)) - .block(block); - frame.render_widget(input, area); - frame.set_cursor( - area.x + (state.file_select.visual_cursor() + PROMPT.len() + 1 + PADDING) as u16, - area.y, - ); -} - - -fn render_info(savefile: &Savefile, mut frame: &mut Frame, area: Rect) { - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(area); - - let left_column = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Ratio(6, 12), - Constraint::Ratio(3, 12), - Constraint::Ratio(3, 12), - ]) - .split(columns[0]); - - let right_column = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(10, 10)]) - .split(columns[1]); - - - render_stats(&savefile, &mut frame, left_column[0]); - render_glyphs(&savefile, &mut frame, left_column[1]); - render_murals(&savefile, &mut frame, left_column[2]); - - render_companions(&savefile, &mut frame, right_column[0]); -} - - -fn render_stats<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { - let stats_section_block = Block::default() - .padding(Padding::new(2, 2, 1, 1)) - .borders(Borders::ALL); - - let layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) - .split(stats_section_block.inner(area)); - - let stats_block = Block::default().title("Stats"); - - let table = Table::new([ - Row::new([ - "Journeys Completed".to_string(), - savefile.journey_count.to_string(), - ]), - Row::new([ - "Total Companions Met".to_string(), - savefile.total_companions_met.to_string(), - ]), - Row::new([ - "Total Symbols Collected".to_string(), - savefile.total_collected_symbols.to_string(), - ]), - Row::new(["Current Level", savefile.current_level_name()]), - Row::new([ - "Companions Met".to_string(), - savefile.companions_met.to_string(), - ]), - Row::new([ - "Scarf Length".to_string(), - savefile.scarf_length.to_string(), - ]), - Row::new(["Symbol Number".to_string(), savefile.symbol.id.to_string()]), - Row::new(["Robe Color".to_string(), savefile.robe_color().to_string()]), - Row::new(["Robe Tier".to_string(), savefile.robe_tier().to_string()]), - Row::new(["Last Played".to_string(), savefile.last_played.to_string()]), - ]) - .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .block(stats_block); - - let cur_symbol_block = Block::default(); - - let cur_symbol = Paragraph::new(savefile.symbol.to_string()).block(cur_symbol_block); - - frame.render_widget(stats_section_block, area); - frame.render_widget(cur_symbol, layout[0]); - frame.render_widget(table, layout[1]); -} - - -fn render_companions<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { - let companions_block = Block::default() - .title("Companions") - .padding(Padding::new(2, 2, 1, 1)) - .borders(Borders::ALL); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) - .split(companions_block.inner(area)); - - let current_companions_block = Block::default() - .title("Current") - .borders(Borders::TOP) - .title_alignment(Alignment::Center); - - let current_companions = Table::new( - savefile - .current_companions() - .map(|companion| Row::new([companion.name.clone(), companion.steam_url()])), - ) - .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .block(current_companions_block); - - let past_companions_block = Block::default() - .title("Past") - .borders(Borders::TOP) - .title_alignment(Alignment::Center); - - let past_companions = Table::new( - savefile - .past_companions() - .map(|companion| Row::new([companion.name.clone(), companion.steam_url()])), - ) - .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .block(past_companions_block); - - frame.render_widget(companions_block, area); - frame.render_widget(current_companions, layout[0]); - frame.render_widget(past_companions, layout[1]); -} - - -fn render_glyphs<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { - const FOUND_SIGN: &str = "◆"; - const NOT_FOUND_SIGN: &str = "◇"; - - let block = Block::default() - .title("Glyphs") - .borders(Borders::ALL) - .padding(Padding::new(2, 2, 1, 1)); - - let table = Table::new(savefile.glyphs.all().map(|(level_number, status)| { - let status = status - .iter() - .map(|&val| Cell::from(if val { FOUND_SIGN } else { NOT_FOUND_SIGN })); - Row::new( - [Cell::from(LEVEL_NAMES[level_number])] - .into_iter() - .chain(status), - ) - })) - .widths(&[ - Constraint::Length(20), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - ]) - .column_spacing(1) - .block(block); - - frame.render_widget(table, area); -} - - -fn render_murals<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { - const FOUND_SIGN: &str = "▾"; - const NOT_FOUND_SIGN: &str = "▿"; - - let block = Block::default() - .title("Murals") - .borders(Borders::ALL) - .padding(Padding::new(2, 2, 1, 1)); - - let table = Table::new(savefile.murals.all().map(|(level_number, status)| { - let status = status - .iter() - .map(|&val| Cell::from(if val { FOUND_SIGN } else { NOT_FOUND_SIGN })); - Row::new( - [Cell::from(LEVEL_NAMES[level_number])] - .into_iter() - .chain(status), - ) - })) - .widths(&[ - Constraint::Length(20), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - Constraint::Length(3), - ]) - .column_spacing(1) - .block(block); - - frame.render_widget(table, area); -} diff --git a/crates/wayfarer/src/tui/events.rs b/crates/wayfarer/src/tui/events.rs new file mode 100644 index 0000000..22d9b60 --- /dev/null +++ b/crates/wayfarer/src/tui/events.rs @@ -0,0 +1,69 @@ +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; +use tui_input::backend::crossterm::EventHandler; + +use super::{Message, Mode, State}; + + +pub fn handle(event_queue: &mut mpsc::Sender, state: &mut State) -> Result<()> { + if !event::poll(Duration::from_millis(250))? { + return Ok(()); + } + + match event::read()? { + Event::Key(key) => handle_keyboard_input(key, event_queue, state)?, + _ => return Ok(()), + } + + Ok(()) +} + + +#[tracing::instrument(skip(msg_tx, state))] +fn handle_keyboard_input( + key: KeyEvent, + msg_tx: &mut mpsc::Sender, + state: &mut State, +) -> Result<()> { + match (&state.mode, key.code) { + (_, KeyCode::Char('q')) if key.modifiers.contains(KeyModifiers::CONTROL) => { + msg_tx.send(Message::Exit)?; + } + + (_, KeyCode::Esc) => { + msg_tx.send(Message::SetMode(Mode::Normal))?; + } + + (Mode::ShowError(_), _) => { + msg_tx.send(Message::SetMode(Mode::Normal))?; + } + + (Mode::SelectFile, KeyCode::Enter) => { + msg_tx.send(Message::LoadFile)?; + } + + (Mode::SelectFile, _) => { + state.file_select.handle_event(&Event::Key(key)); + } + + (Mode::Normal, KeyCode::Char('q')) => { + msg_tx.send(Message::Exit)?; + } + + (Mode::Normal, KeyCode::Char('o')) => { + msg_tx.send(Message::SetMode(Mode::SelectFile))?; + } + + #[cfg(feature = "watch")] + (Mode::Normal, KeyCode::Char('w')) => { + msg_tx.send(Message::ToggleFileWatch)?; + } + + _ => (), + }; + + Ok(()) +} diff --git a/crates/wayfarer/src/tui/view.rs b/crates/wayfarer/src/tui/view.rs new file mode 100644 index 0000000..1fca915 --- /dev/null +++ b/crates/wayfarer/src/tui/view.rs @@ -0,0 +1,25 @@ +mod info; +mod status_bar; + + +use std::io::Stdout; + +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; + +use super::State; + + +type Frame<'a> = ratatui::Frame<'a, CrosstermBackend>; + + +pub fn render(state: &mut State, frame: &mut Frame) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(40), Constraint::Length(2)]) + .split(frame.size()); + + info::render(state, frame, rows[0]); + + status_bar::render(state, frame, rows[1]); +} diff --git a/crates/wayfarer/src/tui/view/info.rs b/crates/wayfarer/src/tui/view/info.rs new file mode 100644 index 0000000..31883dc --- /dev/null +++ b/crates/wayfarer/src/tui/view/info.rs @@ -0,0 +1,216 @@ +use jrny_save::{Savefile, LEVEL_NAMES}; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::widgets::{Block, Borders, Cell, Padding, Paragraph, Row, Table}; + +use crate::tui::view::Frame; +use crate::tui::State; + + +pub fn render(state: &mut State, frame: &mut Frame, area: Rect) { + match &state.persistent.savefile { + Some(savefile) => render_info(savefile, frame, area), + None => render_no_active_file(frame, area), + } +} + + +fn render_no_active_file(frame: &mut Frame, area: Rect) { + let info_block = Block::default() + .padding(Padding::horizontal(2)) + .borders(Borders::ALL); + + let info = Paragraph::new("No active file.\nPress 'o' to open a file, or 'q' to quit.") + .block(info_block); + + frame.render_widget(info, area); +} + + +fn render_info(savefile: &Savefile, mut frame: &mut Frame, area: Rect) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + let left_column = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Ratio(6, 12), + Constraint::Ratio(3, 12), + Constraint::Ratio(3, 12), + ]) + .split(columns[0]); + + let right_column = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Ratio(10, 10)]) + .split(columns[1]); + + + render_stats(&savefile, &mut frame, left_column[0]); + render_glyphs(&savefile, &mut frame, left_column[1]); + render_murals(&savefile, &mut frame, left_column[2]); + + render_companions(&savefile, &mut frame, right_column[0]); +} + + +fn render_stats<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { + let stats_section_block = Block::default() + .padding(Padding::new(2, 2, 1, 1)) + .borders(Borders::ALL); + + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) + .split(stats_section_block.inner(area)); + + let stats_block = Block::default().title("Stats"); + + let table = Table::new([ + Row::new([ + "Journeys Completed".to_string(), + savefile.journey_count.to_string(), + ]), + Row::new([ + "Total Companions Met".to_string(), + savefile.total_companions_met.to_string(), + ]), + Row::new([ + "Total Symbols Collected".to_string(), + savefile.total_collected_symbols.to_string(), + ]), + Row::new(["Current Level", savefile.current_level_name()]), + Row::new([ + "Companions Met".to_string(), + savefile.companions_met.to_string(), + ]), + Row::new([ + "Scarf Length".to_string(), + savefile.scarf_length.to_string(), + ]), + Row::new(["Symbol Number".to_string(), savefile.symbol.id.to_string()]), + Row::new(["Robe Color".to_string(), savefile.robe_color().to_string()]), + Row::new(["Robe Tier".to_string(), savefile.robe_tier().to_string()]), + Row::new(["Last Played".to_string(), savefile.last_played.to_string()]), + ]) + .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .block(stats_block); + + let cur_symbol_block = Block::default(); + + let cur_symbol = Paragraph::new(savefile.symbol.to_string()).block(cur_symbol_block); + + frame.render_widget(stats_section_block, area); + frame.render_widget(cur_symbol, layout[0]); + frame.render_widget(table, layout[1]); +} + + +fn render_companions<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { + let companions_block = Block::default() + .title("Companions") + .padding(Padding::new(2, 2, 1, 1)) + .borders(Borders::ALL); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) + .split(companions_block.inner(area)); + + let current_companions_block = Block::default() + .title("Current") + .borders(Borders::TOP) + .title_alignment(Alignment::Center); + + let current_companions = Table::new( + savefile + .current_companions() + .map(|companion| Row::new([companion.name.clone(), companion.steam_url()])), + ) + .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .block(current_companions_block); + + let past_companions_block = Block::default() + .title("Past") + .borders(Borders::TOP) + .title_alignment(Alignment::Center); + + let past_companions = Table::new( + savefile + .past_companions() + .map(|companion| Row::new([companion.name.clone(), companion.steam_url()])), + ) + .widths(&[Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .block(past_companions_block); + + frame.render_widget(companions_block, area); + frame.render_widget(current_companions, layout[0]); + frame.render_widget(past_companions, layout[1]); +} + + +fn render_glyphs<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { + const FOUND_SIGN: &str = "◆"; + const NOT_FOUND_SIGN: &str = "◇"; + + let block = Block::default() + .title("Glyphs") + .borders(Borders::ALL) + .padding(Padding::new(2, 2, 1, 1)); + + let table = Table::new(savefile.glyphs.all().map(|(level_number, status)| { + let status = status + .iter() + .map(|&val| Cell::from(if val { FOUND_SIGN } else { NOT_FOUND_SIGN })); + Row::new( + [Cell::from(LEVEL_NAMES[level_number])] + .into_iter() + .chain(status), + ) + })) + .widths(&[ + Constraint::Length(20), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .column_spacing(1) + .block(block); + + frame.render_widget(table, area); +} + + +fn render_murals<'a>(savefile: &Savefile, frame: &mut Frame, area: Rect) { + const FOUND_SIGN: &str = "▾"; + const NOT_FOUND_SIGN: &str = "▿"; + + let block = Block::default() + .title("Murals") + .borders(Borders::ALL) + .padding(Padding::new(2, 2, 1, 1)); + + let table = Table::new(savefile.murals.all().map(|(level_number, status)| { + let status = status + .iter() + .map(|&val| Cell::from(if val { FOUND_SIGN } else { NOT_FOUND_SIGN })); + Row::new( + [Cell::from(LEVEL_NAMES[level_number])] + .into_iter() + .chain(status), + ) + })) + .widths(&[ + Constraint::Length(20), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .column_spacing(1) + .block(block); + + frame.render_widget(table, area); +} diff --git a/crates/wayfarer/src/tui/view/status_bar.rs b/crates/wayfarer/src/tui/view/status_bar.rs new file mode 100644 index 0000000..d727571 --- /dev/null +++ b/crates/wayfarer/src/tui/view/status_bar.rs @@ -0,0 +1,55 @@ +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{Block, Padding, Paragraph}; + +use crate::tui::view::Frame; +use crate::tui::{Mode, State}; + + +pub fn render(state: &State, mut frame: &mut Frame, area: Rect) { + let status_block = Block::default().padding(Padding::horizontal(2)); + + match &state.mode { + Mode::ShowError(error_msg) => { + let error_msg = Paragraph::new(error_msg.clone()) + .style(Style::default().fg(ratatui::style::Color::LightRed)) + .block(status_block); + frame.render_widget(error_msg, area); + } + + #[cfg(feature = "watch")] + Mode::Normal if state.file_watcher.is_some() && state.persistent.savefile.is_some() => { + let savefile = state.persistent.savefile.as_ref().unwrap(); + let text = format!("Watching file: {}", savefile.path.display()); + let status = Paragraph::new(text).block(status_block); + frame.render_widget(status, area); + } + + Mode::Normal if state.persistent.savefile.is_none() => (), + + Mode::Normal => { + let savefile = state.persistent.savefile.as_ref().unwrap(); + let text = format!("Showing file: {}", savefile.path.display()); + let status = Paragraph::new(text).block(status_block); + frame.render_widget(status, area); + } + + Mode::SelectFile => render_file_select(&state, &mut frame, status_block, area), + } +} + + +fn render_file_select(state: &State, frame: &mut Frame, block: Block, area: Rect) { + const PROMPT: &str = "Open file:"; + const PADDING: usize = 2; + + let scroll = state.file_select.visual_scroll(area.width as usize); + let input = Paragraph::new(format!("{} {}", PROMPT, state.file_select.value())) + .scroll((0, scroll as u16)) + .block(block); + frame.render_widget(input, area); + frame.set_cursor( + area.x + (state.file_select.visual_cursor() + PROMPT.len() + 1 + PADDING) as u16, + area.y, + ); +}