feat: basic functionality #1
Generated
+1811
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,10 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.103"
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
gethostname = "1.1.0"
|
||||
ratatui = "0.30.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
serde_path_to_error = "0.1.20"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"apps_base": "/sls/mx/applications/",
|
||||
"venvs_base": "/sls/mx/applications/venvs",
|
||||
"default_gui_file": "src/aare/gui/gui.py",
|
||||
"default_server_file": "src/aare/daq/server.py",
|
||||
"map": {
|
||||
"x10sa": {
|
||||
"venv": "aaredaq-pxii",
|
||||
"src": "aaredaq-pxii"
|
||||
},
|
||||
"x06da": {
|
||||
"venv": "aaredaq-pxiii",
|
||||
"src": "aaredaq-pxiii"
|
||||
},
|
||||
"x06sa": {
|
||||
"venv": "aaredaq-pxi",
|
||||
"src": "aaredaq-pxi"
|
||||
}
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// 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)]
|
||||
struct RawLauncherEntry {
|
||||
/// Virtualenv directory, relative to `venvs_base`.
|
||||
venv: PathBuf,
|
||||
/// Source directory, relative to `apps_base`.
|
||||
src: PathBuf,
|
||||
/// GUI entry point, relative to the entry's `src` directory.
|
||||
gui_file: Option<PathBuf>,
|
||||
/// Server entry point, relative to the entry's `src` directory.
|
||||
server_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// The configuration as parsed directly from `defaults.json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawConfig {
|
||||
apps_base: PathBuf,
|
||||
venvs_base: PathBuf,
|
||||
default_gui_file: PathBuf,
|
||||
default_server_file: PathBuf,
|
||||
map: HashMap<String, RawLauncherEntry>,
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher entry with all paths expanded to absolute paths.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LauncherEntry {
|
||||
/// Python interpreter inside the virtualenv.
|
||||
pub python: PathBuf,
|
||||
/// Absolute path to the GUI entry point.
|
||||
pub gui_file: PathBuf,
|
||||
/// Absolute path to the server entry point.
|
||||
pub server_file: PathBuf,
|
||||
}
|
||||
|
||||
/// The resolved launcher configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Base directory containing the source/app directories.
|
||||
pub apps_base: PathBuf,
|
||||
/// Base directory containing the virtualenvs.
|
||||
pub venvs_base: PathBuf,
|
||||
/// Resolved launcher entries, keyed by beamline.
|
||||
pub entries: HashMap<String, LauncherEntry>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
base.join(rel.strip_prefix("/").unwrap_or(rel))
|
||||
}
|
||||
|
||||
impl RawLauncherEntry {
|
||||
/// Resolve this raw entry into absolute paths using the top-level bases and
|
||||
/// defaults.
|
||||
fn resolve(
|
||||
self,
|
||||
apps_base: &Path,
|
||||
venvs_base: &Path,
|
||||
default_gui_file: &Path,
|
||||
default_server_file: &Path,
|
||||
) -> LauncherEntry {
|
||||
let src_dir = apps_base.join(&self.src);
|
||||
let gui_file = self.gui_file.as_deref().unwrap_or(default_gui_file);
|
||||
let server_file = self.server_file.as_deref().unwrap_or(default_server_file);
|
||||
|
||||
LauncherEntry {
|
||||
python: venvs_base.join(&self.venv).join("bin/python"),
|
||||
gui_file: join_relative(&src_dir, gui_file),
|
||||
server_file: join_relative(&src_dir, server_file),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a `defaults.json` document into a resolved [`Config`].
|
||||
pub fn load_config(json: &str) -> Result<Config> {
|
||||
let deserializer = &mut serde_json::Deserializer::from_str(json);
|
||||
let raw: RawConfig = serde_path_to_error::deserialize(deserializer)
|
||||
.context("failed to parse config JSON")?;
|
||||
|
||||
let entries = raw
|
||||
.map
|
||||
.into_iter()
|
||||
.map(|(key, entry)| {
|
||||
let resolved = entry.resolve(
|
||||
&raw.apps_base,
|
||||
&raw.venvs_base,
|
||||
&raw.default_gui_file,
|
||||
&raw.default_server_file,
|
||||
);
|
||||
(key, resolved)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Config {
|
||||
apps_base: raw.apps_base,
|
||||
venvs_base: raw.venvs_base,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"{
|
||||
"apps_base": "/apps",
|
||||
"venvs_base": "/venvs",
|
||||
"default_gui_file": "/src/gui.py",
|
||||
"default_server_file": "/src/server.py",
|
||||
"map": {
|
||||
"x10sa": {
|
||||
"venv": "pxii",
|
||||
"src": "aaredaq-pxii",
|
||||
"gui_file": "/custom/gui.py",
|
||||
"server_file": "/custom/server.py"
|
||||
},
|
||||
"x06da": {
|
||||
"venv": "pxiii",
|
||||
"src": "aaredaq-pxiii"
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn expands_python_and_entry_points() {
|
||||
let config = load_config(SAMPLE).unwrap();
|
||||
|
||||
let entry = &config.entries["x10sa"];
|
||||
assert_eq!(entry.python, PathBuf::from("/venvs/pxii/bin/python"));
|
||||
assert_eq!(entry.gui_file, PathBuf::from("/apps/aaredaq-pxii/custom/gui.py"));
|
||||
assert_eq!(
|
||||
entry.server_file,
|
||||
PathBuf::from("/apps/aaredaq-pxii/custom/server.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_defaults_when_entry_points_omitted() {
|
||||
let config = load_config(SAMPLE).unwrap();
|
||||
|
||||
let entry = &config.entries["x06da"];
|
||||
assert_eq!(entry.python, PathBuf::from("/venvs/pxiii/bin/python"));
|
||||
assert_eq!(entry.gui_file, PathBuf::from("/apps/aaredaq-pxiii/src/gui.py"));
|
||||
assert_eq!(
|
||||
entry.server_file,
|
||||
PathBuf::from("/apps/aaredaq-pxiii/src/server.py")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_entries() {
|
||||
let config = load_config(SAMPLE).unwrap();
|
||||
assert_eq!(config.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_is_an_error() {
|
||||
assert!(load_config("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_reports_field_path() {
|
||||
// `venv` has the wrong type in the single entry.
|
||||
let bad = r#"{
|
||||
"apps_base": "/apps",
|
||||
"venvs_base": "/venvs",
|
||||
"default_gui_file": "/src/gui.py",
|
||||
"default_server_file": "/src/server.py",
|
||||
"map": { "x10sa": { "venv": 123, "src": "aaredaq-pxii" } }
|
||||
}"#;
|
||||
|
||||
let err = load_config(bad).unwrap_err();
|
||||
let message = format!("{err:#}");
|
||||
assert!(
|
||||
message.contains("map.x10sa.venv"),
|
||||
"expected field path in error, got: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use gethostname::gethostname;
|
||||
|
||||
/// Detect which console/beamline we're running on based on the short hostname.
|
||||
pub fn detect_beamline() -> Result<String, String> {
|
||||
let hostname = gethostname().to_string_lossy().into_owned();
|
||||
if hostname.starts_with("x10sa") {
|
||||
Ok("x10sa".to_string())
|
||||
} else if hostname.starts_with("x06da") {
|
||||
Ok("x06da".to_string())
|
||||
} else if hostname.starts_with("x0sda") {
|
||||
Ok("x06sa".to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Error: unrecognized hostname '{hostname}' — cannot determine beamline"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
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<()> {
|
||||
let status = Command::new(python)
|
||||
.arg(entrypoint)
|
||||
.status()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to launch {} {}",
|
||||
python.display(),
|
||||
entrypoint.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"{} {} exited with {status}",
|
||||
python.display(),
|
||||
entrypoint.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn launch_server(entry: &LauncherEntry) -> Result<()> {
|
||||
launch(&entry.python, &entry.server_file)
|
||||
}
|
||||
|
||||
pub fn launch_gui(entry: &LauncherEntry) -> Result<()> {
|
||||
launch(&entry.python, &entry.gui_file)
|
||||
}
|
||||
+90
-2
@@ -1,3 +1,91 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
mod config;
|
||||
mod detect;
|
||||
mod launcher;
|
||||
mod tui;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Error, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use config::{Config, LauncherEntry, load_config};
|
||||
use detect::detect_beamline;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "aare_launcher")]
|
||||
struct Cli {
|
||||
/// Path to the launcher config file.
|
||||
#[arg(long, default_value = "data/defaults.json")]
|
||||
config: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Enter the TUI to choose what to launch
|
||||
#[command(alias = "p")]
|
||||
Pick,
|
||||
/// Launch the AareGUI with default settings
|
||||
#[command(alias = "g")]
|
||||
Gui {
|
||||
/// Config entry (beamline) 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.
|
||||
#[arg(long)]
|
||||
entry: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let json = std::fs::read_to_string(&cli.config)
|
||||
.with_context(|| format!("failed to read config file {}", cli.config.display()))?;
|
||||
let config = load_config(&json)?;
|
||||
|
||||
check_base_exists("apps_base", &config.apps_base, &cli.config)?;
|
||||
check_base_exists("venvs_base", &config.venvs_base, &cli.config)?;
|
||||
|
||||
match cli.command {
|
||||
Command::Pick => {
|
||||
if let Some((python, entrypoint)) = tui::run(&config)? {
|
||||
launcher::launch(&python, &entrypoint)?;
|
||||
}
|
||||
}
|
||||
Command::Gui { entry } => launcher::launch_gui(lookup_entry(&config, entry)?)?,
|
||||
Command::Server { entry } => launcher::launch_server(lookup_entry(&config, entry)?)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fail with a helpful message if a configured base directory is missing.
|
||||
fn check_base_exists(field: &str, base: &Path, config_file: &Path) -> Result<()> {
|
||||
if !base.is_dir() {
|
||||
bail!(
|
||||
"{field} '{}' does not exist — edit '{field}' in {} to point at a valid directory",
|
||||
base.display(),
|
||||
config_file.display(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up the launcher entry to use: the explicitly requested `entry`, or the
|
||||
/// detected beamline when none was given.
|
||||
fn lookup_entry(config: &Config, entry: Option<String>) -> Result<&LauncherEntry> {
|
||||
let beamline = match entry {
|
||||
Some(entry) => entry,
|
||||
None => detect_beamline().map_err(Error::msg)?,
|
||||
};
|
||||
config
|
||||
.entries
|
||||
.get(&beamline)
|
||||
.with_context(|| format!("no config entry for beamline '{beamline}'"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
|
||||
|
||||
/// Which of the two lists currently has focus.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Focus {
|
||||
Source,
|
||||
Venv,
|
||||
}
|
||||
|
||||
/// The "Custom" tab: pick a source directory and a venv directory from the
|
||||
/// contents of `apps_base` and `venvs_base`.
|
||||
pub struct CustomTab {
|
||||
sources: Vec<String>,
|
||||
venvs: Vec<String>,
|
||||
source_state: ListState,
|
||||
venv_state: ListState,
|
||||
focus: Focus,
|
||||
}
|
||||
|
||||
impl CustomTab {
|
||||
pub fn new(apps_base: &Path, venvs_base: &Path) -> Self {
|
||||
let sources = list_dirs(apps_base);
|
||||
let venvs = list_dirs(venvs_base);
|
||||
|
||||
CustomTab {
|
||||
source_state: initial_state(&sources),
|
||||
venv_state: initial_state(&venvs),
|
||||
sources,
|
||||
venvs,
|
||||
focus: Focus::Source,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let (items, state) = self.focused_mut();
|
||||
select_relative(items, state, 1);
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
let (items, state) = self.focused_mut();
|
||||
select_relative(items, state, -1);
|
||||
}
|
||||
|
||||
fn focused_mut(&mut self) -> (&[String], &mut ListState) {
|
||||
match self.focus {
|
||||
Focus::Source => (&self.sources, &mut self.source_state),
|
||||
Focus::Venv => (&self.venvs, &mut self.venv_state),
|
||||
}
|
||||
}
|
||||
|
||||
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("Source", &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("> ")
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 crate::config::LauncherEntry;
|
||||
|
||||
/// Which entry point to launch for the selected entry.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum LaunchChoice {
|
||||
Gui,
|
||||
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>,
|
||||
/// 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();
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if !keys.is_empty() {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
|
||||
EntriesTab {
|
||||
config,
|
||||
keys,
|
||||
list_state,
|
||||
dialog: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) {
|
||||
if self.keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let next = self
|
||||
.list_state
|
||||
.selected()
|
||||
.map_or(0, |i| (i + 1) % self.keys.len());
|
||||
self.list_state.select(Some(next));
|
||||
}
|
||||
|
||||
pub fn previous(&mut self) {
|
||||
if self.keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let previous = self
|
||||
.list_state
|
||||
.selected()
|
||||
.map_or(0, |i| (i + self.keys.len() - 1) % self.keys.len());
|
||||
self.list_state.select(Some(previous));
|
||||
}
|
||||
|
||||
/// Whether the launch-choice dialog is currently open.
|
||||
pub fn dialog_open(&self) -> bool {
|
||||
self.dialog.is_some()
|
||||
}
|
||||
|
||||
/// Open the launch-choice dialog for the current selection, defaulting to
|
||||
/// the GUI. No-op when nothing is selected.
|
||||
pub fn open_dialog(&mut self) {
|
||||
if self.selected_entry().is_some() {
|
||||
self.dialog = Some(LaunchChoice::Gui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the dialog without launching.
|
||||
pub fn cancel_dialog(&mut self) {
|
||||
self.dialog = None;
|
||||
}
|
||||
|
||||
/// Toggle the dialog selection between GUI and server.
|
||||
pub fn toggle_dialog(&mut self) {
|
||||
if let Some(choice) = &mut self.dialog {
|
||||
*choice = match choice {
|
||||
LaunchChoice::Gui => LaunchChoice::Server,
|
||||
LaunchChoice::Server => LaunchChoice::Gui,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm the dialog, returning the `(python, entrypoint)` to launch.
|
||||
pub fn confirm(&self) -> Option<(PathBuf, PathBuf)> {
|
||||
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(),
|
||||
};
|
||||
Some((entry.python.clone(), entrypoint))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
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 items: Vec<ListItem> = self.keys.iter().map(|k| ListItem::new(k.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);
|
||||
|
||||
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(format!("gui_file: {}", entry.gui_file.display())),
|
||||
Line::from(format!("server_file: {}", entry.server_file.display())),
|
||||
]),
|
||||
None => Text::from("no entries"),
|
||||
};
|
||||
frame.render_widget(Paragraph::new(text).block(detail), detail_area);
|
||||
|
||||
if let Some(choice) = self.dialog {
|
||||
self.draw_dialog(frame, area, choice);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_dialog(&self, frame: &mut Frame, area: Rect, choice: LaunchChoice) {
|
||||
let popup = centered_rect(40, 30, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
|
||||
let selected = Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED);
|
||||
let gui_style = if choice == LaunchChoice::Gui {
|
||||
selected
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let server_style = if choice == LaunchChoice::Server {
|
||||
selected
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let text = Text::from(vec![
|
||||
Line::styled(" Launch GUI ", gui_style),
|
||||
Line::styled(" Launch Server ", server_style),
|
||||
Line::from(""),
|
||||
Line::from("↑/↓ choose · Enter confirm · Esc cancel"),
|
||||
]);
|
||||
let block = Block::default().borders(Borders::ALL).title("Launch");
|
||||
frame.render_widget(Paragraph::new(text).block(block), popup);
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle centered within `area`, sized as a percentage of it.
|
||||
fn centered_rect(width_pct: u16, height_pct: u16, area: Rect) -> Rect {
|
||||
let [area] = Layout::vertical([Constraint::Percentage(height_pct)])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
let [area] = Layout::horizontal([Constraint::Percentage(width_pct)])
|
||||
.flex(Flex::Center)
|
||||
.areas(area);
|
||||
area
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user