diff --git a/data/defaults.json b/data/defaults.json index 718f8d1..5ffce2f 100644 --- a/data/defaults.json +++ b/data/defaults.json @@ -1,8 +1,8 @@ { "deployments_base": "/sls/mx/applications/AareDAQ", "default_venv_name": ".venv", - "default_gui_file": "src/aare/gui/gui.py", - "default_server_file": "src/aare/daq/server.py", + "default_gui_file": "aaredaq/src/aare/gui/gui.py", + "default_server_file": "aaredaq/src/aare/daq/server.py", "map": { "x10sa": { "dir": "x10sa" diff --git a/src/launcher.rs b/src/launcher.rs index 7d775d4..5d23fd9 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -10,7 +10,7 @@ use crate::config::LauncherEntry; fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { let status = Command::new(python) .arg(entrypoint) - .envs(env.iter().map(|(key, value)| (key, value))) + .envs(env.to_owned()) .status() .with_context(|| { format!( @@ -31,9 +31,10 @@ fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> Ok(()) } -/// Run `python ` on the system, waiting for it to finish. -pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> { - run(python, entrypoint, &[]) +/// Run `python ` on the system with `env` added to its environment, +/// waiting for it to finish. +pub fn launch_with_env(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> { + run(python, entrypoint, env) } /// Launch the server entry point with extra `env` variables added to its diff --git a/src/main.rs b/src/main.rs index 6483ac3..f26467d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,8 +83,13 @@ fn main() -> Result<()> { match cli.command { Command::Pick => { - if let Some((python, entrypoint)) = tui::run(&config)? { - launcher::launch(&python, &entrypoint)?; + if let Some(launch) = tui::run(&config, DEFAULT_ENV_FILE)? { + let env = launch + .env_file + .as_deref() + .map(env_file::load_env_file) + .unwrap_or_default(); + launcher::launch_with_env(&launch.python, &launch.entrypoint, &env)?; } } Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?, diff --git a/src/tui/custom.rs b/src/tui/custom.rs deleted file mode 100644 index c50e8f2..0000000 --- a/src/tui/custom.rs +++ /dev/null @@ -1,208 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use ratatui::Frame; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; - -use crate::detect::detect_venvs; - -/// Which of the two lists currently has focus. -#[derive(Clone, Copy, PartialEq)] -enum Focus { - Source, - Venv, -} - -/// The "Custom" tab: pick a deployment directory from `deployments_base`, then -/// one of the virtualenvs found inside it. -pub struct CustomTab { - /// Base directory the source deployments live under. - deployments_base: PathBuf, - /// Deployment directory names under `deployments_base`. - sources: Vec, - /// Virtualenv directory names inside the selected deployment. - venvs: Vec, - source_state: ListState, - venv_state: ListState, - focus: Focus, -} - -impl CustomTab { - pub fn new(deployments_base: &Path) -> Self { - let sources = list_dirs(deployments_base); - - let mut tab = CustomTab { - deployments_base: deployments_base.to_path_buf(), - source_state: initial_state(&sources), - venv_state: ListState::default(), - sources, - venvs: Vec::new(), - focus: Focus::Source, - }; - tab.refresh_venvs(); - tab - } - - /// Move focus between the source and venv lists. - pub fn toggle_focus(&mut self) { - self.focus = match self.focus { - Focus::Source => Focus::Venv, - Focus::Venv => Focus::Source, - }; - } - - pub fn next(&mut self) { - self.move_selection(1); - } - - pub fn previous(&mut self) { - self.move_selection(-1); - } - - /// Move the selection in the focused list. Moving between deployments - /// re-scans the venvs of the newly selected one. - fn move_selection(&mut self, delta: isize) { - match self.focus { - Focus::Source => { - select_relative(&self.sources, &mut self.source_state, delta); - self.refresh_venvs(); - } - Focus::Venv => select_relative(&self.venvs, &mut self.venv_state, delta), - } - } - - /// Recompute the venv list for the currently selected deployment. - fn refresh_venvs(&mut self) { - self.venvs = match self.selected_source_dir() { - Some(dir) => detect_venvs(&dir) - .unwrap_or_default() - .iter() - .filter_map(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) - .collect(), - None => Vec::new(), - }; - self.venv_state = initial_state(&self.venvs); - } - - /// Absolute path of the currently selected deployment directory. - fn selected_source_dir(&self) -> Option { - let name = self.sources.get(self.source_state.selected()?)?; - Some(self.deployments_base.join(name)) - } - - pub fn draw(&mut self, frame: &mut Frame, area: Rect) { - let [source_area, venv_area] = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .areas(area); - - let source_list = dir_list("Deployment", &self.sources, self.focus == Focus::Source); - frame.render_stateful_widget(source_list, source_area, &mut self.source_state); - - let venv_list = dir_list("Venv", &self.venvs, self.focus == Focus::Venv); - frame.render_stateful_widget(venv_list, venv_area, &mut self.venv_state); - } -} - -/// List the immediate subdirectories of `base`, sorted by name. Returns an -/// empty list if the directory can't be read. -fn list_dirs(base: &Path) -> Vec { - let mut dirs: Vec = fs::read_dir(base) - .into_iter() - .flatten() - .flatten() - .filter(|entry| entry.file_type().map(|t| t.is_dir()).unwrap_or(false)) - .filter_map(|entry| entry.file_name().into_string().ok()) - .collect(); - dirs.sort(); - dirs -} - -fn initial_state(items: &[String]) -> ListState { - let mut state = ListState::default(); - if !items.is_empty() { - state.select(Some(0)); - } - state -} - -fn select_relative(items: &[String], state: &mut ListState, delta: isize) { - if items.is_empty() { - return; - } - let len = items.len() as isize; - let current = state.selected().unwrap_or(0) as isize; - let next = (current + delta).rem_euclid(len) as usize; - state.select(Some(next)); -} - -fn dir_list<'a>(title: &'a str, items: &'a [String], focused: bool) -> List<'a> { - let border_style = if focused { - Style::default().add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - let list_items: Vec = items.iter().map(|d| ListItem::new(d.as_str())).collect(); - - List::new(list_items) - .block( - Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(title), - ) - .highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)) - .highlight_symbol("> ") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn items(n: usize) -> Vec { - (0..n).map(|i| i.to_string()).collect() - } - - #[test] - fn initial_state_selects_first_when_nonempty() { - assert_eq!(initial_state(&items(3)).selected(), Some(0)); - } - - #[test] - fn initial_state_selects_nothing_when_empty() { - assert_eq!(initial_state(&items(0)).selected(), None); - } - - #[test] - fn select_relative_moves_within_bounds() { - let items = items(3); - let mut state = initial_state(&items); - select_relative(&items, &mut state, 1); - assert_eq!(state.selected(), Some(1)); - } - - #[test] - fn select_relative_wraps_forward_past_end() { - let items = items(3); - let mut state = ListState::default(); - state.select(Some(2)); - select_relative(&items, &mut state, 1); - assert_eq!(state.selected(), Some(0)); - } - - #[test] - fn select_relative_wraps_backward_past_start() { - let items = items(3); - let mut state = initial_state(&items); - select_relative(&items, &mut state, -1); - assert_eq!(state.selected(), Some(2)); - } - - #[test] - fn select_relative_on_empty_is_noop() { - let mut state = ListState::default(); - select_relative(&items(0), &mut state, 1); - assert_eq!(state.selected(), None); - } -} diff --git a/src/tui/entries.rs b/src/tui/entries.rs index 37c203a..5729e38 100644 --- a/src/tui/entries.rs +++ b/src/tui/entries.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::path::PathBuf; use ratatui::Frame; @@ -16,55 +15,50 @@ enum LaunchChoice { Server, } -/// The "Entries" tab: a list of config keys with a detail panel. -pub struct EntriesTab<'a> { - /// The available launcher entries. - config: &'a HashMap, - /// Config keys, sorted for stable display. - keys: Vec<&'a String>, +/// The entries list: configured entries plus discovered deployments, with a +/// detail panel. +pub struct EntriesTab { + /// Launcher entries, `(label, entry)`, sorted by label. + entries: Vec<(String, LauncherEntry)>, /// Selection state of the entry list. list_state: ListState, /// When `Some`, the launch-choice dialog is open with this selection. dialog: Option, } -impl<'a> EntriesTab<'a> { - pub fn new(config: &'a HashMap) -> Self { - let mut keys: Vec<&String> = config.keys().collect(); - keys.sort(); - +impl EntriesTab { + pub fn new(entries: Vec<(String, LauncherEntry)>) -> Self { let mut list_state = ListState::default(); - if !keys.is_empty() { + if !entries.is_empty() { list_state.select(Some(0)); } EntriesTab { - config, - keys, + entries, list_state, dialog: None, } } pub fn next(&mut self) { - if self.keys.is_empty() { + if self.entries.is_empty() { return; } let next = self .list_state .selected() - .map_or(0, |i| (i + 1) % self.keys.len()); + .map_or(0, |i| (i + 1) % self.entries.len()); self.list_state.select(Some(next)); } pub fn previous(&mut self) { - if self.keys.is_empty() { + if self.entries.is_empty() { return; } let previous = self .list_state .selected() - .map_or(0, |i| (i + self.keys.len() - 1) % self.keys.len()); + .map_or(0, |i| (i + self.entries.len() - 1) % self.entries.len()); self.list_state.select(Some(previous)); } @@ -96,21 +90,24 @@ impl<'a> EntriesTab<'a> { } } - /// Confirm the dialog, returning the `(python, entrypoint)` to launch. - pub fn confirm(&self) -> Option<(PathBuf, PathBuf)> { + /// Confirm the dialog, returning `(python, entrypoint, is_server)`. + pub fn confirm(&self) -> Option<(PathBuf, PathBuf, bool)> { let choice = self.dialog?; let entry = self.selected_entry()?; - let entrypoint = match choice { - LaunchChoice::Gui => entry.gui_file.clone(), - LaunchChoice::Server => entry.server_file.clone(), + let is_server = choice == LaunchChoice::Server; + let entrypoint = if is_server { + entry.server_file.clone() + } else { + entry.gui_file.clone() }; - Some((entry.python.clone(), entrypoint)) + Some((entry.python.clone(), entrypoint, is_server)) } /// The currently selected entry, if any. - fn selected_entry(&self) -> Option<&'a LauncherEntry> { - let key = self.keys.get(self.list_state.selected()?)?; - self.config.get(*key) + fn selected_entry(&self) -> Option<&LauncherEntry> { + self.entries + .get(self.list_state.selected()?) + .map(|(_, entry)| entry) } pub fn draw(&mut self, frame: &mut Frame, area: Rect) { @@ -118,9 +115,9 @@ impl<'a> EntriesTab<'a> { Layout::horizontal([Constraint::Percentage(30), Constraint::Min(0)]).areas(area); let items: Vec = self - .keys + .entries .iter() - .map(|k| ListItem::new(k.as_str())) + .map(|(label, _)| ListItem::new(label.as_str())) .collect(); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title("Entries")) diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9c31d0b..6960587 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,4 +1,3 @@ -mod custom; mod entries; use std::path::PathBuf; @@ -7,51 +6,67 @@ 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::widgets::{Block, Borders, Paragraph}; use ratatui::{DefaultTerminal, Frame}; -use crate::config::Config; -use custom::CustomTab; +use crate::config::{Config, LauncherEntry}; 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, +/// What the user chose to launch in the TUI. +pub struct Launch { + pub python: PathBuf, + pub entrypoint: PathBuf, + /// Env file to apply, set only for server launches. + pub env_file: Option, } -impl<'a> App<'a> { - fn new(config: &'a Config) -> Self { +/// Which widget currently has keyboard focus. +#[derive(Clone, Copy, PartialEq)] +enum Focus { + List, + EnvFile, +} + +struct App { + entries: EntriesTab, + /// Editable path of the env file applied to server launches. + env_file: String, + focus: Focus, +} + +impl App { + fn new(config: &Config, env_file: &str) -> Self { + // Configured entries first, then the deployments discovered on disk. + let mut entries: Vec<(String, LauncherEntry)> = config + .entries + .iter() + .map(|(label, entry)| (label.clone(), entry.clone())) + .collect(); + entries.extend(config.discovered_entries()); + App { - selected_tab: 0, - entries: EntriesTab::new(&config.entries), - custom: CustomTab::new(&config.deployments_base), + entries: EntriesTab::new(entries), + env_file: env_file.to_string(), + focus: Focus::List, } } - - 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> { +/// Run the launcher TUI, returning the launch the user chose (or `None` if they +/// quit without choosing). +pub fn run(config: &Config, env_file: &str) -> Result> { let mut terminal = ratatui::init(); - let result = run_app(&mut terminal, config); + let result = run_app(&mut terminal, config, env_file); ratatui::restore(); result } -fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result> { - let mut app = App::new(config); +fn run_app( + terminal: &mut DefaultTerminal, + config: &Config, + env_file: &str, +) -> Result> { + let mut app = App::new(config, env_file); loop { terminal.draw(|frame| draw(frame, &mut app))?; @@ -65,8 +80,13 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result { - if let Some(command) = app.entries.confirm() { - return Ok(Some(command)); + if let Some((python, entrypoint, is_server)) = app.entries.confirm() { + let env_file = is_server.then(|| PathBuf::from(&app.env_file)); + return Ok(Some(Launch { + python, + entrypoint, + env_file, + })); } } KeyCode::Esc => app.entries.cancel_dialog(), @@ -78,25 +98,26 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result 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(), - _ => {} - }, + match app.focus { + // The entries list has focus: navigate and launch. + Focus::List => match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Tab | KeyCode::BackTab => app.focus = Focus::EnvFile, + KeyCode::Down => app.entries.next(), + KeyCode::Up => app.entries.previous(), + KeyCode::Enter => app.entries.open_dialog(), + _ => {} + }, + // The env-file box has focus: edit the path. + Focus::EnvFile => match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Tab | KeyCode::BackTab => { + app.focus = Focus::List + } + KeyCode::Char(c) => app.env_file.push(c), + KeyCode::Backspace => { + app.env_file.pop(); + } + _ => {} }, } } @@ -104,26 +125,33 @@ fn run_app(terminal: &mut DefaultTerminal, config: &Config) -> Result app.entries.draw(frame, content_area), - _ => app.custom.draw(frame, content_area), + let focused = app.focus == Focus::EnvFile; + let border_style = if focused { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let env = Paragraph::new(app.env_file.as_str()).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title("Env file (server)"), + ); + frame.render_widget(env, env_area); + if focused { + // Place the cursor at the end of the editable text. + let x = env_area.x + 1 + app.env_file.chars().count() as u16; + let max_x = env_area.x + env_area.width.saturating_sub(2); + frame.set_cursor_position((x.min(max_x), env_area.y + 1)); } let footer = Paragraph::new(help_text(app)).style(Style::default().add_modifier(Modifier::DIM)); @@ -134,9 +162,9 @@ fn draw(frame: &mut Frame, app: &mut App) { 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 if app.focus == Focus::EnvFile { + "type to edit env file path Enter/Tab/Esc: back to list" } else { - "Tab: switch tab ←/→: switch list ↑/↓: select q: quit" + "↑/↓: select Enter: launch Tab: edit env file q: quit" } }