[WIP] Daemon & CLI

This commit is contained in:
Alex-Emmet 2026-07-23 23:13:02 +02:00
commit 8b158108bb
100 changed files with 6519 additions and 1596 deletions

View file

@ -0,0 +1,163 @@
use super::ThemeName;
use serde::{Deserialize, Serialize};
use std::{
fs, io,
path::{Path, PathBuf},
str::FromStr,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UiConfig {
#[serde(default)]
pub theme: ThemeName,
/// Whether opening the interactive UI should launch a locally installed daemon.
#[serde(default)]
pub daemon_start_policy: DaemonStartPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DaemonStartPolicy {
#[default]
Ask,
WithUi,
}
impl Serialize for DaemonStartPolicy {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Ask => serializer.serialize_str("ask"),
Self::WithUi => serializer.serialize_str("with_ui"),
}
}
}
impl<'de> Deserialize<'de> for DaemonStartPolicy {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Compat {
Policy(String),
Legacy(bool),
}
match Compat::deserialize(deserializer)? {
Compat::Policy(v) if v == "with_ui" || v == "WithUi" => Ok(Self::WithUi),
Compat::Policy(_) => Ok(Self::Ask),
Compat::Legacy(true) => Ok(Self::WithUi),
Compat::Legacy(false) => Ok(Self::Ask),
}
}
}
impl UiConfig {
pub fn path() -> PathBuf {
iota_paths::config_dir().join("ui.yaml")
}
pub fn load() -> Result<Self, io::Error> {
Self::load_from(&Self::path())
}
fn load_from(path: &Path) -> Result<Self, io::Error> {
if !path.exists() {
return Ok(Self::default());
}
serde_yaml::from_str(&fs::read_to_string(path)?).map_err(io::Error::other)
}
pub fn save(&self) -> Result<(), io::Error> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(self).map_err(io::Error::other)?;
fs::write(path, yaml)
}
pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
Self::resolve_theme_from(
override_theme,
std::env::var("IOTA_THEME").ok().as_deref(),
&Self::path(),
)
}
fn resolve_theme_from(
override_theme: Option<ThemeName>,
environment_theme: Option<&str>,
config_path: &Path,
) -> ThemeName {
if let Some(theme) = override_theme {
return theme;
}
if let Some(value) = environment_theme {
match ThemeName::from_str(value) {
Ok(theme) => return theme,
Err(error) => {
eprintln!("Invalid IOTA_THEME value: {error}; checking UI configuration.");
}
}
}
match Self::load_from(config_path) {
Ok(config) => config.theme,
Err(error) => {
eprintln!(
"Could not read UI configuration {}: {error}; using ansi.",
config_path.display()
);
ThemeName::Ansi
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("iota-ui-config-{}-{name}.yaml", std::process::id()))
}
#[test]
fn command_line_override_has_highest_precedence() {
let path = config_path("override");
fs::write(&path, "theme: surface\n").unwrap();
let resolved =
UiConfig::resolve_theme_from(Some(ThemeName::Binary), Some("monospace"), &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Binary);
}
#[test]
fn environment_precedes_stored_configuration() {
let path = config_path("environment");
fs::write(&path, "theme: surface\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, Some("monospace"), &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Monospace);
}
#[test]
fn stored_configuration_precedes_default() {
let path = config_path("stored");
fs::write(&path, "theme: surface\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, None, &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Surface);
}
#[test]
fn invalid_stored_configuration_falls_back_to_ansi() {
let path = config_path("invalid");
fs::write(&path, "theme: ultraviolet\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, None, &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Ansi);
}
#[test]
fn missing_configuration_falls_back_to_ansi() {
let path = config_path("missing");
let _ = fs::remove_file(&path);
assert_eq!(
UiConfig::resolve_theme_from(None, None, &path),
ThemeName::Ansi
);
}
}

12
iota-cli/src/theme/mod.rs Normal file
View file

@ -0,0 +1,12 @@
mod config;
mod model;
mod name;
mod presets;
pub use config::{DaemonStartPolicy, UiConfig};
pub use model::*;
pub use name::ThemeName;
pub fn resolve(name: ThemeName) -> ResolvedTheme {
presets::resolve(name)
}

149
iota-cli/src/theme/model.rs Normal file
View file

@ -0,0 +1,149 @@
use super::ThemeName;
use ratatui::style::Style;
#[derive(Clone, Debug)]
pub struct TextStyles {
pub normal: Style,
pub muted: Style,
pub heading: Style,
pub link: Style,
pub code: Style,
}
#[derive(Clone, Debug)]
pub struct StatusStyles {
pub info: Style,
pub success: Style,
pub warning: Style,
pub error: Style,
}
#[derive(Clone, Debug)]
pub struct BorderStyles {
pub normal: Style,
pub focused: Style,
pub disabled: Style,
pub title: Style,
}
#[derive(Clone, Debug)]
pub struct ChoiceItemStyle {
pub marker: Style,
pub label: Style,
pub description: Style,
pub prefix: &'static str,
pub suffix: &'static str,
}
#[derive(Clone, Debug)]
pub struct ChoiceStyles {
pub normal: ChoiceItemStyle,
pub focused: ChoiceItemStyle,
pub selected: ChoiceItemStyle,
pub focused_selected: ChoiceItemStyle,
pub disabled: ChoiceItemStyle,
pub focused_disabled: ChoiceItemStyle,
pub selected_disabled: ChoiceItemStyle,
}
#[derive(Clone, Debug)]
pub struct ButtonStyles {
pub primary: Style,
pub primary_focused: Style,
pub neutral: Style,
pub neutral_focused: Style,
pub cancel: Style,
pub cancel_focused: Style,
pub destructive: Style,
pub disabled: Style,
}
#[derive(Clone, Debug)]
pub struct MarkerSet {
pub checkbox_unselected: &'static str,
pub checkbox_selected: &'static str,
pub radio_unselected: &'static str,
pub radio_selected: &'static str,
}
#[derive(Clone, Debug)]
pub enum CursorPresentation {
StyledCell(Style),
Character { glyph: &'static str, style: Style },
}
#[derive(Clone, Debug)]
pub struct ConsoleStyles {
pub text: Style,
pub prefix: Style,
pub hint: Style,
pub error: Style,
pub confirmation: Style,
pub cursor: CursorPresentation,
pub border: Style,
pub focused_border: Style,
pub title: Style,
}
#[derive(Clone, Debug)]
pub struct GraphStyles {
pub ram: ratatui::style::Color,
pub cpu: ratatui::style::Color,
pub ping: ratatui::style::Color,
pub text: Style,
pub border: Style,
pub focused_border: Style,
}
#[derive(Clone, Debug)]
pub struct LogStyles {
pub call: Style,
pub client: Style,
pub iota: Style,
pub omikron: Style,
pub omega: Style,
pub command: Style,
pub other: Style,
pub text: Style,
pub error: Style,
pub timestamp: Style,
pub border: Style,
pub focused_border: Style,
}
#[derive(Clone, Debug)]
pub struct MarkdownStyles {
pub normal: Style,
pub muted: Style,
pub heading: Style,
pub link: Style,
pub code: Style,
pub table_header: Style,
pub table_text: Style,
pub divider: Style,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct TextSemantics {
pub bold: bool,
pub underline: bool,
}
#[derive(Clone, Debug)]
pub struct ResolvedTheme {
pub name: ThemeName,
pub text: TextStyles,
pub status: StatusStyles,
pub choices: ChoiceStyles,
pub buttons: ButtonStyles,
pub borders: BorderStyles,
pub console: ConsoleStyles,
pub graphs: GraphStyles,
pub logs: LogStyles,
pub markdown: MarkdownStyles,
pub markers: MarkerSet,
}
impl ResolvedTheme {
pub fn apply_text_semantics(&self, base: Style, semantics: TextSemantics) -> Style {
use ratatui::style::Modifier;
if matches!(self.name, ThemeName::Monospace) {
return base;
}
let mut style = base;
if semantics.bold {
style = style.add_modifier(Modifier::BOLD);
}
if semantics.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
}

View file

@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ThemeName {
Monospace,
Binary,
#[default]
Ansi,
Surface,
}
impl ThemeName {
pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface];
pub fn supported_names() -> &'static str {
"monospace, binary, ansi, surface"
}
}
impl fmt::Display for ThemeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Monospace => "monospace",
Self::Binary => "binary",
Self::Ansi => "ansi",
Self::Surface => "surface",
})
}
}
impl FromStr for ThemeName {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"monospace" => Ok(Self::Monospace),
"binary" => Ok(Self::Binary),
"ansi" => Ok(Self::Ansi),
"surface" => Ok(Self::Surface),
_ => Err(format!(
"unknown theme `{value}`; supported themes: {}",
Self::supported_names()
)),
}
}
}

View file

@ -0,0 +1,301 @@
use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles,
ThemeName,
};
use ratatui::style::{Color, Modifier, Style};
fn marker() -> MarkerSet {
MarkerSet {
checkbox_unselected: "[ ]",
checkbox_selected: "[x]",
radio_unselected: "( )",
radio_selected: "(x)",
}
}
fn choice(
marker: Style,
label: Style,
prefix: &'static str,
suffix: &'static str,
) -> ChoiceItemStyle {
ChoiceItemStyle {
marker,
label,
description: label,
prefix,
suffix,
}
}
fn base(
name: ThemeName,
normal: Style,
muted: Style,
focused: Style,
selected: Style,
disabled: Style,
status: StatusStyles,
buttons: ButtonStyles,
) -> ResolvedTheme {
let error = status.error;
let (prefix, suffix) = if matches!(name, ThemeName::Monospace | ThemeName::Binary) {
("> ", " <")
} else {
("", "")
};
ResolvedTheme {
name,
text: TextStyles {
normal,
muted,
heading: normal,
link: focused,
code: normal,
},
status,
choices: ChoiceStyles {
normal: choice(normal, normal, "", ""),
focused: choice(focused, focused, prefix, suffix),
selected: choice(selected, selected, "", ""),
focused_selected: choice(
selected.patch(focused),
selected.patch(focused),
prefix,
suffix,
),
disabled: choice(disabled, disabled, "", ""),
focused_disabled: choice(disabled, error, prefix, suffix),
selected_disabled: choice(disabled, disabled, "", ""),
},
buttons,
borders: BorderStyles {
normal,
focused,
disabled,
title: normal,
},
console: ConsoleStyles {
text: normal,
prefix: muted,
hint: muted,
error,
confirmation: focused,
cursor: CursorPresentation::StyledCell(focused),
border: normal,
focused_border: focused,
title: normal,
},
graphs: GraphStyles {
ram: Color::Reset,
cpu: Color::Reset,
ping: Color::Reset,
text: normal,
border: normal,
focused_border: focused,
},
logs: LogStyles {
call: normal,
client: normal,
iota: normal,
omikron: normal,
omega: normal,
command: normal,
other: normal,
text: normal,
error,
timestamp: muted,
border: normal,
focused_border: focused,
},
markdown: MarkdownStyles {
normal,
muted,
heading: focused,
link: focused,
code: focused,
table_header: focused,
table_text: normal,
divider: muted,
},
markers: marker(),
}
}
pub fn resolve(name: ThemeName) -> ResolvedTheme {
let plain = Style::default();
match name {
ThemeName::Monospace => {
let mut theme = base(
name,
plain,
plain,
plain,
plain,
plain,
StatusStyles {
info: plain,
success: plain,
warning: plain,
error: plain,
},
ButtonStyles {
primary: plain,
primary_focused: plain,
neutral: plain,
neutral_focused: plain,
cancel: plain,
cancel_focused: plain,
destructive: plain,
disabled: plain,
},
);
theme.console.cursor = CursorPresentation::Character {
glyph: "",
style: plain,
};
theme.graphs = GraphStyles {
ram: Color::Reset,
cpu: Color::Reset,
ping: Color::Reset,
text: plain,
border: plain,
focused_border: plain,
};
theme
}
ThemeName::Binary => {
let reversed = plain.add_modifier(Modifier::REVERSED);
base(
name,
plain,
plain,
plain,
reversed,
plain,
StatusStyles {
info: plain,
success: plain,
warning: plain,
error: plain,
},
ButtonStyles {
primary: plain,
primary_focused: reversed,
neutral: plain,
neutral_focused: reversed,
cancel: plain,
cancel_focused: reversed,
destructive: plain,
disabled: plain,
},
)
}
ThemeName::Ansi => {
let yellow = plain.fg(Color::Yellow).add_modifier(Modifier::BOLD);
let mut theme = base(
name,
plain,
plain.fg(Color::DarkGray),
yellow,
plain,
plain.fg(Color::DarkGray),
StatusStyles {
info: plain,
success: plain.fg(Color::Green),
warning: plain.fg(Color::Yellow),
error: plain.fg(Color::Red),
},
ButtonStyles {
primary: plain.fg(Color::Green),
primary_focused: plain
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
neutral: plain,
neutral_focused: yellow,
cancel: plain.fg(Color::Red),
cancel_focused: plain
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
destructive: plain.fg(Color::Red),
disabled: plain.fg(Color::DarkGray),
},
);
theme.console = ConsoleStyles {
text: plain.fg(Color::White),
prefix: plain.fg(Color::DarkGray),
hint: plain.fg(Color::DarkGray),
error: plain.fg(Color::Red),
confirmation: plain.fg(Color::Yellow),
cursor: CursorPresentation::StyledCell(plain.fg(Color::White).bg(Color::DarkGray)),
border: plain,
focused_border: plain.fg(Color::Yellow),
title: plain.fg(Color::White),
};
theme.graphs = GraphStyles {
ram: Color::Blue,
cpu: Color::Red,
ping: Color::Green,
text: plain,
border: plain,
focused_border: plain.fg(Color::Yellow),
};
theme.logs = LogStyles {
call: plain.fg(Color::Magenta),
client: plain.fg(Color::Green),
iota: plain.fg(Color::Yellow),
omikron: plain.fg(Color::Blue),
omega: plain.fg(Color::Cyan),
command: plain.fg(Color::LightGreen),
other: plain.fg(Color::LightCyan),
text: plain.fg(Color::White),
error: plain.fg(Color::Red),
timestamp: plain.fg(Color::DarkGray),
border: plain,
focused_border: plain.fg(Color::Yellow),
};
theme.markdown = MarkdownStyles {
normal: plain,
muted: plain.fg(Color::DarkGray),
heading: plain.fg(Color::Cyan),
link: plain.fg(Color::Cyan),
code: plain.fg(Color::Yellow),
table_header: plain.fg(Color::Cyan),
table_text: plain.fg(Color::Green),
divider: plain.fg(Color::DarkGray),
};
theme
}
ThemeName::Surface => {
let focus = plain.fg(Color::Black).bg(Color::Yellow);
let selected = plain.fg(Color::Black).bg(Color::Cyan);
let mut theme = base(
name,
plain,
plain.fg(Color::DarkGray),
focus,
selected,
plain.fg(Color::DarkGray),
StatusStyles {
info: plain,
success: plain.fg(Color::Green),
warning: plain.fg(Color::Yellow),
error: plain.fg(Color::Red),
},
ButtonStyles {
primary: plain.fg(Color::Black).bg(Color::Green),
primary_focused: focus,
neutral: plain,
neutral_focused: focus,
cancel: plain.fg(Color::Black).bg(Color::Red),
cancel_focused: focus,
destructive: plain.fg(Color::Black).bg(Color::Red),
disabled: plain.fg(Color::DarkGray),
},
);
theme.console.cursor =
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
theme
}
}
}