Files
aare_launcher/src/tui/mod.rs
T
perl_d 2c98dd1c80 feat: basic functionality
- load config from json
- cli commands to launch existing profiles
- tui to pick custom directories and venv
2026-07-07 14:53:28 +02:00

147 lines
4.6 KiB
Rust

mod custom;
mod entries;
use std::path::PathBuf;
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Block, Borders, Paragraph, Tabs};
use ratatui::{DefaultTerminal, Frame};
use crate::config::Config;
use custom::CustomTab;
use entries::EntriesTab;
const TAB_TITLES: [&str; 2] = ["Entries", "Custom"];
struct App<'a> {
/// Index of the currently selected tab.
selected_tab: usize,
entries: EntriesTab<'a>,
custom: CustomTab,
}
impl<'a> App<'a> {
fn new(config: &'a Config) -> Self {
App {
selected_tab: 0,
entries: EntriesTab::new(&config.entries),
custom: CustomTab::new(&config.apps_base, &config.venvs_base),
}
}
fn next_tab(&mut self) {
self.selected_tab = (self.selected_tab + 1) % TAB_TITLES.len();
}
fn previous_tab(&mut self) {
self.selected_tab = (self.selected_tab + TAB_TITLES.len() - 1) % TAB_TITLES.len();
}
}
/// Run the launcher TUI, returning the command the user chose (or `None` if
/// they quit without choosing).
pub fn run(config: &Config) -> Result<Option<(PathBuf, PathBuf)>> {
let mut terminal = ratatui::init();
let result = run_app(&mut terminal, config);
ratatui::restore();
result
}
fn run_app(
terminal: &mut DefaultTerminal,
config: &Config,
) -> Result<Option<(PathBuf, PathBuf)>> {
let mut app = App::new(config);
loop {
terminal.draw(|frame| draw(frame, &mut app))?;
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
// While the launch dialog is open it captures all input.
if app.entries.dialog_open() {
match key.code {
KeyCode::Enter => {
if let Some(command) = app.entries.confirm() {
return Ok(Some(command));
}
}
KeyCode::Esc => app.entries.cancel_dialog(),
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
app.entries.toggle_dialog()
}
_ => {}
}
continue;
}
// Global keys: quit and tab switching.
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
KeyCode::Tab => app.next_tab(),
KeyCode::BackTab => app.previous_tab(),
// Everything else is routed to the active tab.
_ => match app.selected_tab {
0 => match key.code {
KeyCode::Down => app.entries.next(),
KeyCode::Up => app.entries.previous(),
KeyCode::Enter => app.entries.open_dialog(),
_ => {}
},
_ => match key.code {
KeyCode::Left | KeyCode::Right => app.custom.toggle_focus(),
KeyCode::Down => app.custom.next(),
KeyCode::Up => app.custom.previous(),
_ => {}
},
},
}
}
}
}
fn draw(frame: &mut Frame, app: &mut App) {
let [tabs_area, content_area, footer_area] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(frame.area());
let tabs = Tabs::new(TAB_TITLES)
.block(
Block::default()
.borders(Borders::ALL)
.title("aare launcher"),
)
.select(app.selected_tab)
.highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED));
frame.render_widget(tabs, tabs_area);
match app.selected_tab {
0 => app.entries.draw(frame, content_area),
_ => app.custom.draw(frame, content_area),
}
let footer = Paragraph::new(help_text(app))
.style(Style::default().add_modifier(Modifier::DIM));
frame.render_widget(footer, footer_area);
}
/// Context-sensitive interaction hints shown in the footer.
fn help_text(app: &App) -> &'static str {
if app.entries.dialog_open() {
"↑/↓: choose Enter: launch Esc: cancel"
} else if app.selected_tab == 0 {
"Tab: switch tab ↑/↓: select Enter: launch q: quit"
} else {
"Tab: switch tab ←/→: switch list ↑/↓: select q: quit"
}
}