diff --git a/Cargo.toml b/Cargo.toml index 959d616..56452cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ path = "src/main.rs" [package.metadata.generate-rpm] assets = [ { source = "target/x86_64-unknown-linux-musl/release/aarel", dest = "/usr/local/bin/aarel", mode = "755" }, - { source = "data/defaults.json", dest = "/etc/aarel/defaults.json", mode = "644", config = true }, + { source = "data/defaults.json", dest = "/etc/aarel/defaults.json", mode = "644" }, ] [dependencies] diff --git a/src/config.rs b/src/config.rs index c6dddd5..79cc843 100644 --- a/src/config.rs +++ b/src/config.rs @@ -42,6 +42,19 @@ pub struct LauncherEntry { pub server_file: PathBuf, } +impl LauncherEntry { + /// Name of the virtualenv directory, derived from `python` which always + /// lives at `/bin/python`. + pub fn venv_name(&self) -> String { + self.python + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } +} + /// The resolved launcher configuration. #[derive(Debug, Clone)] pub struct Config { @@ -285,6 +298,13 @@ mod tests { ); } + #[test] + fn venv_name_is_directory_holding_python() { + let config = load_config(SAMPLE).unwrap(); + assert_eq!(config.entries["x10sa"].venv_name(), "pxii"); + assert_eq!(config.entries["x06da"].venv_name(), ".venv"); + } + #[test] fn custom_venv_overrides_default() { let config = load_config(SAMPLE).unwrap(); diff --git a/src/tui/entries.rs b/src/tui/entries.rs index b28f294..df5fc44 100644 --- a/src/tui/entries.rs +++ b/src/tui/entries.rs @@ -4,7 +4,7 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Text}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use crate::config::LauncherEntry; @@ -15,54 +15,95 @@ enum LaunchChoice { Gui, } -/// The entries list: configured entries plus discovered deployments, with a -/// detail panel. +/// Which of the two list panes has keyboard focus. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Pane { + Deployments, + Venvs, +} + +/// A launchable virtualenv within a deployment. +struct Venv { + name: String, + entry: LauncherEntry, +} + +/// A deployment directory and the virtualenvs found inside it. +struct Deployment { + name: String, + /// Sorted by name, never empty. + venvs: Vec, +} + +/// The entries view: a deployments pane, a venvs pane for the selected +/// deployment, and 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, + /// Deployments with production first, then alphabetical. + deployments: Vec, + /// Selection state of the deployments pane. + deployment_state: ListState, + /// Selection state of the venvs pane, into the selected deployment. + venv_state: ListState, + pane: Pane, /// When `Some`, the launch-choice dialog is open with this selection. dialog: Option, } impl EntriesTab { - pub fn new(mut entries: Vec<(String, LauncherEntry)>) -> Self { - // Production deployments first, then the rest, alphabetical within each. - entries.sort_by(|a, b| sort_key(&a.0).cmp(&sort_key(&b.0))); + pub fn new(entries: Vec<(String, LauncherEntry)>) -> Self { + let deployments = group_by_deployment(entries); - let mut list_state = ListState::default(); - if !entries.is_empty() { - list_state.select(Some(0)); + let mut deployment_state = ListState::default(); + let mut venv_state = ListState::default(); + if !deployments.is_empty() { + deployment_state.select(Some(0)); + venv_state.select(Some(0)); } EntriesTab { - entries, - list_state, + deployments, + deployment_state, + venv_state, + pane: Pane::Deployments, dialog: None, } } + /// Move the selection in the focused pane down, wrapping around. pub fn next(&mut self) { - if self.entries.is_empty() { - return; - } - let next = self - .list_state - .selected() - .map_or(0, |i| (i + 1) % self.entries.len()); - self.list_state.select(Some(next)); + self.move_selection(1); } + /// Move the selection in the focused pane up, wrapping around. pub fn previous(&mut self) { - if self.entries.is_empty() { - return; + self.move_selection(-1); + } + + /// Move the focused pane's selection by `delta`, wrapping around. Changing + /// deployment resets the venv selection to its first entry. + fn move_selection(&mut self, delta: isize) { + match self.pane { + Pane::Deployments => { + step(&mut self.deployment_state, self.deployments.len(), delta); + self.venv_state.select(Some(0)); + } + Pane::Venvs => { + let len = self.venv_count(); + step(&mut self.venv_state, len, delta); + } + } + } + + /// Give focus to the deployments pane. + pub fn focus_left(&mut self) { + self.pane = Pane::Deployments; + } + + /// Give focus to the venvs pane. No-op when there is nothing to select. + pub fn focus_right(&mut self) { + if self.selected_deployment().is_some() { + self.pane = Pane::Venvs; } - let previous = self - .list_state - .selected() - .map_or(0, |i| (i + self.entries.len() - 1) % self.entries.len()); - self.list_state.select(Some(previous)); } /// Whether the launch-choice dialog is currently open. @@ -106,38 +147,72 @@ impl EntriesTab { Some((entry.python.clone(), entrypoint, is_server)) } - /// The currently selected entry, if any. + /// The currently selected deployment, if any. + fn selected_deployment(&self) -> Option<&Deployment> { + self.deployments.get(self.deployment_state.selected()?) + } + + /// Number of venvs in the selected deployment. + fn venv_count(&self) -> usize { + self.selected_deployment().map_or(0, |d| d.venvs.len()) + } + + /// The currently selected entry (venv of the selected deployment), if any. fn selected_entry(&self) -> Option<&LauncherEntry> { - self.entries - .get(self.list_state.selected()?) - .map(|(_, entry)| entry) + self.selected_deployment()? + .venvs + .get(self.venv_state.selected()?) + .map(|venv| &venv.entry) } pub fn draw(&mut self, frame: &mut Frame, area: Rect) { - let [list_area, detail_area] = - Layout::horizontal([Constraint::Percentage(30), Constraint::Min(0)]).areas(area); + let [deployments_area, venvs_area, detail_area] = Layout::horizontal([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Min(0), + ]) + .areas(area); - let items: Vec = self - .entries + let deployments: Vec = self + .deployments .iter() - .map(|(label, _)| ListItem::new(label.as_str())) + .map(|d| ListItem::new(d.name.as_str())) .collect(); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("Entries")) - .highlight_style(Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED)) - .highlight_symbol("> "); - frame.render_stateful_widget(list, list_area, &mut self.list_state); + // Index through the fields rather than `selected_deployment()` so the + // list states can still be borrowed mutably for rendering below. + let venvs: Vec = self + .deployment_state + .selected() + .and_then(|i| self.deployments.get(i)) + .map(|d| { + d.venvs + .iter() + .map(|v| ListItem::new(v.name.as_str())) + .collect() + }) + .unwrap_or_default(); + + let deployments = pane_list(deployments, "Deployments", self.pane == Pane::Deployments); + frame.render_stateful_widget(deployments, deployments_area, &mut self.deployment_state); + let venvs = pane_list(venvs, "Venvs", self.pane == Pane::Venvs); + frame.render_stateful_widget(venvs, venvs_area, &mut self.venv_state); let detail = Block::default().borders(Borders::ALL).title("Details"); let text = match self.selected_entry() { Some(entry) => Text::from(vec![ Line::from(format!("python: {}", entry.python.display())), + Line::from("\n"), Line::from(format!("gui_file: {}", entry.gui_file.display())), + Line::from("\n"), Line::from(format!("server_file: {}", entry.server_file.display())), ]), None => Text::from("no entries"), }; - frame.render_widget(Paragraph::new(text).block(detail), detail_area); + // Paths easily outgrow the pane, so wrap rather than clip them. + let detail = Paragraph::new(text) + .block(detail) + .wrap(Wrap { trim: false }); + frame.render_widget(detail, detail_area); if let Some(choice) = self.dialog { self.draw_dialog(frame, area, choice); @@ -171,6 +246,72 @@ impl EntriesTab { } } +/// A bordered list pane; the focused pane gets a bold border and a reversed +/// highlight, an unfocused one keeps a bold highlight so the selection stays +/// visible. +fn pane_list<'a>(items: Vec>, title: &'a str, focused: bool) -> List<'a> { + let (border, highlight) = if focused { + ( + Style::default().add_modifier(Modifier::BOLD), + Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED), + ) + } else { + ( + Style::default(), + Style::default().add_modifier(Modifier::BOLD), + ) + }; + List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border) + .title(title), + ) + .highlight_style(highlight) + .highlight_symbol("> ") +} + +/// Move a list selection by `delta` within `len` items, wrapping around. +fn step(state: &mut ListState, len: usize, delta: isize) { + if len == 0 { + return; + } + let current = state.selected().unwrap_or(0) as isize; + let next = (current + delta).rem_euclid(len as isize) as usize; + state.select(Some(next)); +} + +/// Group flat `(label, entry)` pairs by deployment. +/// +/// Labels are either `""` or `"/"` (see +/// `Config::discovered_entries`), so the deployment is the part before the +/// first `/`. Deployments are ordered production first, then alphabetical; +/// venvs alphabetical within each. +fn group_by_deployment(entries: Vec<(String, LauncherEntry)>) -> Vec { + let mut deployments: Vec = Vec::new(); + for (label, entry) in entries { + let name = label.split_once('/').map_or(label.as_str(), |(d, _)| d); + let venv = Venv { + name: entry.venv_name(), + entry, + }; + match deployments.iter_mut().find(|d| d.name == name) { + Some(deployment) => deployment.venvs.push(venv), + None => deployments.push(Deployment { + name: name.to_string(), + venvs: vec![venv], + }), + } + } + + deployments.sort_by(|a, b| sort_key(&a.name).cmp(&sort_key(&b.name))); + for deployment in &mut deployments { + deployment.venvs.sort_by(|a, b| a.name.cmp(&b.name)); + } + deployments +} + /// Production deployment labels, pinned to the top of the list. const PRODUCTION: [&str; 3] = ["x06da", "x06sa", "x10sa"]; @@ -192,7 +333,15 @@ fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect { #[cfg(test)] mod tests { - use super::sort_key; + use super::*; + + fn entry(venv: &str) -> LauncherEntry { + LauncherEntry { + python: PathBuf::from(format!("/apps/d/{venv}/bin/python")), + gui_file: PathBuf::from("/apps/d/gui.py"), + server_file: PathBuf::from("/apps/d/server.py"), + } + } #[test] fn production_sorts_before_others() { @@ -205,4 +354,67 @@ mod tests { assert!(sort_key("x06da") < sort_key("x06sa")); assert!(sort_key("x06sa") < sort_key("x10sa")); } + + #[test] + fn groups_disambiguated_labels_under_one_deployment() { + let deployments = group_by_deployment(vec![ + ("test/.venv".to_string(), entry(".venv")), + ("x10sa".to_string(), entry(".venv")), + ("test/other".to_string(), entry("other")), + ]); + + let names: Vec<&str> = deployments.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["x10sa", "test"]); + + let venvs: Vec<&str> = deployments[1] + .venvs + .iter() + .map(|v| v.name.as_str()) + .collect(); + assert_eq!(venvs, [".venv", "other"]); + } + + #[test] + fn navigation_wraps_and_resets_venv_selection() { + let mut tab = EntriesTab::new(vec![ + ("a/x".to_string(), entry("x")), + ("a/y".to_string(), entry("y")), + ("b".to_string(), entry(".venv")), + ]); + + tab.focus_right(); + tab.next(); + assert_eq!(tab.venv_state.selected(), Some(1)); + tab.next(); + assert_eq!(tab.venv_state.selected(), Some(0), "wraps within venvs"); + + tab.next(); + tab.focus_left(); + tab.next(); + assert_eq!(tab.deployment_state.selected(), Some(1)); + assert_eq!( + tab.venv_state.selected(), + Some(0), + "reset on deployment change" + ); + + tab.previous(); + tab.previous(); + assert_eq!( + tab.deployment_state.selected(), + Some(1), + "wraps within deployments" + ); + } + + #[test] + fn empty_tab_has_no_selection_and_ignores_input() { + let mut tab = EntriesTab::new(Vec::new()); + tab.next(); + tab.focus_right(); + tab.open_dialog(); + assert!(tab.selected_entry().is_none()); + assert_eq!(tab.pane, Pane::Deployments); + assert!(!tab.dialog_open()); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 78e52b7..8437400 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -105,6 +105,8 @@ fn run_app( KeyCode::Tab | KeyCode::BackTab => app.focus = Focus::EnvFile, KeyCode::Down => app.entries.next(), KeyCode::Up => app.entries.previous(), + KeyCode::Left => app.entries.focus_left(), + KeyCode::Right => app.entries.focus_right(), KeyCode::Enter => app.entries.open_dialog(), _ => {} }, @@ -165,6 +167,6 @@ fn help_text(app: &App) -> &'static str { } else if app.focus == Focus::EnvFile { "type to edit env file path Enter/Tab/Esc: back to list" } else { - "↑/↓: select Enter: launch Tab: edit env file q: quit" + "↑/↓: select ←/→: pane Enter: launch Tab: edit env file q: quit" } }