Feat/load env file #15

Merged
perl_d merged 3 commits from feat/load_env_file into main 2026-08-12 11:10:33 +02:00
11 changed files with 326 additions and 325 deletions
+7 -3
View File
@@ -47,10 +47,14 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: https://github.com/dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: https://github.com/Swatinem/rust-cache@v2
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build RPM
run: |
cargo build --release
strip -s target/release/aarel
cargo build --release --target x86_64-unknown-linux-musl
strip -s target/x86_64-unknown-linux-musl/release/aarel
cargo install cargo-generate-rpm
cargo generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-musl
+7 -3
View File
@@ -54,16 +54,20 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: https://github.com/dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: https://github.com/Swatinem/rust-cache@v2
- name: Install musl toolchain
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build RPM
run: |
# Pick up the version bump semantic-release just committed.
git pull
cargo build --release
strip -s target/release/aarel
cargo build --release --target x86_64-unknown-linux-musl
strip -s target/x86_64-unknown-linux-musl/release/aarel
cargo install cargo-generate-rpm
cargo generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-musl
- name: Push to Gitea RPM registry
run: |
+1 -1
View File
@@ -16,7 +16,7 @@ path = "src/main.rs"
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/aarel", dest = "/usr/local/bin/aarel", mode = "755" },
{ 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 },
]
+2 -2
View File
@@ -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"
+47
View File
@@ -1,9 +1,12 @@
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::detect::detect_venvs;
/// A launcher entry as it appears in `defaults.json`, where `gui_file` and
/// `server_file` may be omitted (falling back to the top-level defaults).
#[derive(Debug, Deserialize)]
@@ -44,10 +47,52 @@ pub struct LauncherEntry {
pub struct Config {
/// Base directory containing the source/app directories.
pub deployments_base: PathBuf,
/// Default GUI entry point, relative to a deployment directory.
pub default_gui_file: PathBuf,
/// Default server entry point, relative to a deployment directory.
pub default_server_file: PathBuf,
/// Resolved launcher entries, keyed by beamline.
pub entries: HashMap<String, LauncherEntry>,
}
impl Config {
/// Discover launchable deployments on disk under `deployments_base`, one
/// entry per (deployment, virtualenv) pair, labelled `"<deployment>/<venv>"`.
///
/// These are the "custom" deployments that are present on the machine but
/// not named in the config. Entry points use the configured defaults.
pub fn discovered_entries(&self) -> Vec<(String, LauncherEntry)> {
let mut found = Vec::new();
let Ok(dirs) = fs::read_dir(&self.deployments_base) else {
return found;
};
for deployment in dirs.flatten() {
if !deployment.file_type().map(|t| t.is_dir()).unwrap_or(false) {
continue;
}
let dir = deployment.path();
let name = deployment.file_name().to_string_lossy().into_owned();
for venv in detect_venvs(&dir).unwrap_or_default() {
let venv_name = venv
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
found.push((
format!("{name}/{venv_name}"),
LauncherEntry {
python: venv.join("bin/python"),
gui_file: join_relative(&dir, &self.default_gui_file),
server_file: join_relative(&dir, &self.default_server_file),
},
));
}
}
found
}
}
/// Join `rel` onto `base`, treating `rel` as relative even if it starts with a
/// leading separator (as the entry points in `defaults.json` could).
fn join_relative(base: &Path, rel: &Path) -> PathBuf {
@@ -99,6 +144,8 @@ pub fn load_config(json: &str) -> Result<Config> {
Ok(Config {
deployments_base: raw.deployments_base,
default_gui_file: raw.default_gui_file,
default_server_file: raw.default_server_file,
entries,
})
}
+103
View File
@@ -0,0 +1,103 @@
use std::path::Path;
/// Load `key=value` pairs from an env file to add to a process's environment.
///
/// If the file does not exist (or cannot be read) this logs a warning and
/// returns an empty set rather than failing — the env file is optional.
pub fn load_env_file(path: &Path) -> Vec<(String, String)> {
match std::fs::read_to_string(path) {
Ok(contents) => parse_env(&contents),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
eprintln!(
"warning: env file {} not found, continuing without it",
path.display()
);
Vec::new()
}
Err(err) => {
eprintln!(
"warning: could not read env file {} ({err}), continuing without it",
path.display()
);
Vec::new()
}
}
}
/// Parse `key=value` lines into pairs. Blank lines, `#` comments, and lines
/// without a `=` are ignored. Keys and values are trimmed of surrounding
/// whitespace.
fn parse_env(contents: &str) -> Vec<(String, String)> {
contents
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (key, value) = line.split_once('=')?;
Some((key.trim().to_string(), value.trim().to_string()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_basic_pairs() {
let env = parse_env("A=1\nB=two");
assert_eq!(
env,
vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "two".to_string())
]
);
}
#[test]
fn ignores_blank_lines_and_comments() {
let env = parse_env("\n# a comment\nA=1\n\n # indented comment\nB=2\n");
assert_eq!(
env,
vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string())
]
);
}
#[test]
fn trims_surrounding_whitespace() {
let env = parse_env(" KEY = value ");
assert_eq!(env, vec![("KEY".to_string(), "value".to_string())]);
}
#[test]
fn keeps_equals_signs_in_value() {
let env = parse_env("URL=postgres://h/db?a=1&b=2");
assert_eq!(
env,
vec![("URL".to_string(), "postgres://h/db?a=1&b=2".to_string())]
);
}
#[test]
fn skips_lines_without_equals() {
let env = parse_env("A=1\ngarbage\nB=2");
assert_eq!(
env,
vec![
("A".to_string(), "1".to_string()),
("B".to_string(), "2".to_string())
]
);
}
#[test]
fn empty_input_yields_nothing() {
assert!(parse_env("").is_empty());
}
}
+15 -5
View File
@@ -5,10 +5,12 @@ use anyhow::{Context, Result, bail};
use crate::config::LauncherEntry;
/// Run `python <entrypoint>` on the system, waiting for it to finish.
pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
/// Run `python <entrypoint>` on the system with `env` added to its environment,
/// waiting for it to finish.
fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> {
let status = Command::new(python)
.arg(entrypoint)
.envs(env.to_owned())
.status()
.with_context(|| {
format!(
@@ -29,10 +31,18 @@ pub fn launch(python: &Path, entrypoint: &Path) -> Result<()> {
Ok(())
}
pub fn launch_server(entry: &LauncherEntry) -> Result<()> {
launch(&entry.python, &entry.server_file)
/// Run `python <entrypoint>` 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
/// environment.
pub fn launch_server(entry: &LauncherEntry, env: &[(String, String)]) -> Result<()> {
run(&entry.python, &entry.server_file, env)
}
pub fn launch_gui(entry: &LauncherEntry) -> Result<()> {
launch(&entry.python, &entry.gui_file)
run(&entry.python, &entry.gui_file, &[])
}
+21 -5
View File
@@ -1,5 +1,6 @@
mod config;
mod detect;
mod env_file;
mod launcher;
mod tui;
@@ -14,6 +15,8 @@ use detect::detect_beamline;
const INSTALLED_CONFIG: &str = "/etc/aarel/defaults.json";
/// Config file shipped in the source tree, used as a fallback.
const LOCAL_CONFIG: &str = "data/defaults.json";
/// Default env file added to the server's environment when launching.
const DEFAULT_ENV_FILE: &str = "/etc/aaredaq/aaredaq.env";
#[derive(Parser)]
#[command(name = "aarel")]
@@ -49,16 +52,21 @@ enum Command {
/// Launch the AareGUI with default settings
#[command(alias = "g")]
Gui {
/// Config entry (beamline) to launch. Defaults to the detected beamline.
/// Config entry (deployment) to launch. Defaults to the detected beamline.
#[arg(long)]
entry: Option<String>,
},
/// Launch the AareDAQ server with default settings
#[command(alias = "s")]
Server {
/// Config entry (beamline) to launch. Defaults to the detected beamline.
/// Config entry (deployment) to launch. Defaults to the detected beamline.
#[arg(long)]
entry: Option<String>,
/// Env file (`key=value` per line) added to the server's environment.
/// Ignored with a warning if it does not exist.
#[arg(long, default_value = DEFAULT_ENV_FILE)]
env_file: PathBuf,
},
}
@@ -75,12 +83,20 @@ 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)?)?,
Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?,
Command::Server { entry, env_file } => {
let env = env_file::load_env_file(&env_file);
launcher::launch_server(lookup_entry(&config, entry)?, &env)?
}
}
Ok(())
-208
View File
@@ -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<String>,
/// Virtualenv directory names inside the selected deployment.
venvs: Vec<String>,
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<PathBuf> {
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<String> {
let mut dirs: Vec<String> = 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<ListItem> = 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<String> {
(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);
}
}
+27 -30
View File
@@ -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<String, LauncherEntry>,
/// 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<LaunchChoice>,
}
impl<'a> EntriesTab<'a> {
pub fn new(config: &'a HashMap<String, LauncherEntry>) -> 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<ListItem> = 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"))
+96 -68
View File
@@ -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<PathBuf>,
}
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<Option<(PathBuf, PathBuf)>> {
/// 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<Option<Launch>> {
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<Option<(PathBuf, PathBuf)>> {
let mut app = App::new(config);
fn run_app(
terminal: &mut DefaultTerminal,
config: &Config,
env_file: &str,
) -> Result<Option<Launch>> {
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<Option<(Pa
if app.entries.dialog_open() {
match key.code {
KeyCode::Enter => {
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<Option<(Pa
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(),
_ => {}
},
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<Option<(Pa
}
fn draw(frame: &mut Frame, app: &mut App) {
let [tabs_area, content_area, footer_area] = Layout::vertical([
Constraint::Length(3),
let [content_area, env_area, footer_area] = Layout::vertical([
Constraint::Min(0),
Constraint::Length(3),
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);
app.entries.draw(frame, content_area);
match app.selected_tab {
0 => 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"
}
}