[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

@ -19,6 +19,8 @@ iota-storage = { path = "../iota-storage", optional = true }
iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util", optional = true }
iota-ipc = { path = "../iota-ipc" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
omikron-connector = { path = "../omikron-connector", optional = true }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true }
@ -59,6 +61,8 @@ rusqlite = "0.39.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
rustls-pemfile = "2.2.0"
serde_json = "1.0.149"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
sha2 = "0.10.9"
strum = "0.27.2"
strum_macros = "0.27.2"
@ -71,3 +75,7 @@ walkdir = "2.5.0"
warp = "*"
x448 = { version = "*" }
zip = "6.0.0"
unicode-width = "0.2"
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlAction {
FocusNext,
FocusPrevious,
Select,
Activate,
}

View file

@ -0,0 +1,72 @@
use crate::theme::ResolvedTheme;
use ratatui::{
Frame,
layout::{Alignment, Rect},
text::Span,
widgets::{Block, Borders, Paragraph},
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonIntent {
Primary,
Neutral,
Cancel,
Destructive,
}
pub struct ActionButton<'a> {
pub label: &'a str,
pub intent: ButtonIntent,
pub focused: bool,
pub enabled: bool,
}
pub fn render_button(
frame: &mut Frame,
area: Rect,
button: ActionButton<'_>,
theme: &ResolvedTheme,
) {
let style = if !button.enabled {
theme.buttons.disabled
} else {
match (button.intent, button.focused) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
}
};
frame.render_widget(
Paragraph::new(Span::styled(button.label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL)),
area,
);
}
pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option<Vec<u16>> {
let required = minimums
.iter()
.try_fold(0u16, |total, width| total.checked_add(*width))?;
if required > available {
return None;
}
if minimums.is_empty() {
return Some(Vec::new());
}
let extra = available - required;
let count = minimums.len() as u16;
Some(
minimums
.iter()
.enumerate()
.map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count))
.collect(),
)
}
pub fn button_minimum_width(label: &str) -> u16 {
UnicodeWidthStr::width(label)
.saturating_add(2)
.min(u16::MAX as usize) as u16
}

View file

@ -0,0 +1,135 @@
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
use std::{collections::HashSet, hash::Hash};
pub struct CheckboxItem<T> {
pub value: T,
pub label: String,
pub description: Option<String>,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
items: Vec<CheckboxItem<T>>,
selected: HashSet<T>,
focused_index: usize,
focus_policy: DisabledFocusPolicy,
wrap_navigation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckboxGroupError {
Empty,
DuplicateValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckboxChange<T> {
Selected(T),
Deselected(T),
IgnoredDisabled(T),
NoItem,
}
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
pub fn new(
items: Vec<CheckboxItem<T>>,
selected: impl IntoIterator<Item = T>,
) -> Result<Self, CheckboxGroupError> {
let mut values = HashSet::new();
if items.iter().any(|item| !values.insert(item.value.clone())) {
return Err(CheckboxGroupError::DuplicateValue);
}
let selected = selected
.into_iter()
.filter(|value| values.contains(value))
.collect();
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
Ok(Self {
items,
selected,
focused_index,
focus_policy: DisabledFocusPolicy::Skip,
wrap_navigation: true,
})
}
pub fn items(&self) -> &[CheckboxItem<T>] {
&self.items
}
pub fn selected(&self) -> &HashSet<T> {
&self.selected
}
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
self.items.get(self.focused_index)
}
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
self.focus_policy = policy;
}
pub fn set_wrap_navigation(&mut self, wrap: bool) {
self.wrap_navigation = wrap;
}
pub fn focus_next(&mut self) {
self.move_focus(true);
}
pub fn focus_previous(&mut self) {
self.move_focus(false);
}
fn move_focus(&mut self, forward: bool) {
if self.items.is_empty() {
return;
}
for step in 1..=self.items.len() {
let current = self.focused_index as isize;
let delta = if forward {
step as isize
} else {
-(step as isize)
};
let raw = current + delta;
let next = if self.wrap_navigation {
raw.rem_euclid(self.items.len() as isize) as usize
} else if raw < 0 || raw >= self.items.len() as isize {
return;
} else {
raw as usize
};
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
self.focused_index = next;
return;
}
}
}
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
let Some(item) = self.items.get(self.focused_index) else {
return CheckboxChange::NoItem;
};
let value = item.value.clone();
if !item.enabled {
return CheckboxChange::IgnoredDisabled(value);
}
if self.selected.remove(&value) {
CheckboxChange::Deselected(value)
} else {
self.selected.insert(value.clone());
CheckboxChange::Selected(value)
}
}
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
item.enabled = enabled;
}
}
pub fn set_selected(&mut self, value: T, selected: bool) {
if selected {
self.selected.insert(value);
} else {
self.selected.remove(&value);
}
}
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
let item = self.items.iter().position(|item| &item.value == value);
ChoiceVisualState {
selected: self.selected.contains(value),
focused: item == Some(self.focused_index),
enabled: item
.and_then(|index| self.items.get(index))
.is_some_and(|item| item.enabled),
}
}
}

View file

@ -0,0 +1,42 @@
use crate::theme::ResolvedTheme;
use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChoiceKind {
Checkbox,
Radio,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChoiceVisualState {
pub selected: bool,
pub focused: bool,
pub enabled: bool,
}
pub fn render_choice_line<'a>(
label: &'a str,
kind: ChoiceKind,
state: ChoiceVisualState,
theme: &'a ResolvedTheme,
) -> Line<'a> {
let item = match (state.selected, state.focused, state.enabled) {
(_, true, false) => &theme.choices.focused_disabled,
(true, false, false) => &theme.choices.selected_disabled,
(false, false, false) => &theme.choices.disabled,
(true, true, true) => &theme.choices.focused_selected,
(true, false, true) => &theme.choices.selected,
(false, true, true) => &theme.choices.focused,
(false, false, true) => &theme.choices.normal,
};
let marker = match (kind, state.selected) {
(ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected,
(ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected,
(ChoiceKind::Radio, false) => theme.markers.radio_unselected,
(ChoiceKind::Radio, true) => theme.markers.radio_selected,
};
Line::from(vec![
Span::styled(item.prefix, item.label),
Span::styled(marker, item.marker),
Span::raw(" "),
Span::styled(label, item.label),
Span::styled(item.suffix, item.label),
])
}

View file

@ -0,0 +1,6 @@
pub mod action;
pub mod button;
pub mod checkbox_group;
pub mod choice;
pub mod navigation;
pub mod radio_group;

View file

@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DisabledFocusPolicy {
Include,
#[default]
Skip,
}

View file

@ -0,0 +1,194 @@
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
pub struct RadioItem<T> {
pub value: T,
pub label: String,
pub description: Option<String>,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
pub struct RadioGroup<T: Clone + Eq> {
items: Vec<RadioItem<T>>,
selected: T,
default: T,
focused_index: usize,
focus_policy: DisabledFocusPolicy,
wrap_navigation: bool,
disabled_selection_policy: DisabledSelectionPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadioGroupError {
Empty,
DefaultMissing,
DefaultDisabled,
NoEnabledItems,
SelectedItemDisabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadioChange<T> {
Changed { previous: T, selected: T },
Unchanged(T),
IgnoredDisabled(T),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisabledSelectionPolicy {
UseConfiguredDefault,
UseFirstEnabled,
ReturnError,
}
impl<T: Clone + Eq> RadioGroup<T> {
pub fn new(
items: Vec<RadioItem<T>>,
observed: Option<T>,
default: T,
) -> Result<Self, RadioGroupError> {
if items.is_empty() {
return Err(RadioGroupError::Empty);
}
let default_item = items
.iter()
.find(|item| item.value == default)
.ok_or(RadioGroupError::DefaultMissing)?;
if !default_item.enabled {
return Err(RadioGroupError::DefaultDisabled);
}
let focused_index = items
.iter()
.position(|item| item.enabled)
.ok_or(RadioGroupError::NoEnabledItems)?;
let selected = observed
.filter(|value| {
items
.iter()
.any(|item| item.enabled && item.value == *value)
})
.unwrap_or_else(|| default.clone());
Ok(Self {
items,
selected,
default,
focused_index,
focus_policy: DisabledFocusPolicy::Skip,
wrap_navigation: true,
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
})
}
pub fn items(&self) -> &[RadioItem<T>] {
&self.items
}
pub fn selected(&self) -> &T {
&self.selected
}
pub fn focused_item(&self) -> &RadioItem<T> {
&self.items[self.focused_index]
}
pub fn focus_next(&mut self) {
self.move_focus(true);
}
pub fn focus_previous(&mut self) {
self.move_focus(false);
}
fn move_focus(&mut self, forward: bool) {
for step in 1..=self.items.len() {
let raw = self.focused_index as isize
+ if forward {
step as isize
} else {
-(step as isize)
};
let next = if self.wrap_navigation {
raw.rem_euclid(self.items.len() as isize) as usize
} else if raw < 0 || raw >= self.items.len() as isize {
return;
} else {
raw as usize
};
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
self.focused_index = next;
return;
}
}
}
pub fn select_focused(&mut self) -> RadioChange<T> {
let item = self.focused_item();
let enabled = item.enabled;
let value = item.value.clone();
if !enabled {
return RadioChange::IgnoredDisabled(value);
}
if value == self.selected {
RadioChange::Unchanged(self.selected.clone())
} else {
let previous = std::mem::replace(&mut self.selected, value);
RadioChange::Changed {
previous,
selected: self.selected.clone(),
}
}
}
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
let item = self.items.iter().position(|item| &item.value == value);
ChoiceVisualState {
selected: &self.selected == value,
focused: item == Some(self.focused_index),
enabled: item
.and_then(|index| self.items.get(index))
.is_some_and(|item| item.enabled),
}
}
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
self.disabled_selection_policy = policy;
}
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
self.focus_policy = policy;
}
pub fn set_wrap_navigation(&mut self, wrap: bool) {
self.wrap_navigation = wrap;
}
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
return Ok(());
};
if self.items[index].enabled == enabled {
return Ok(());
}
if !enabled
&& self
.items
.iter()
.enumerate()
.all(|(other, item)| other == index || !item.enabled)
{
return Err(RadioGroupError::NoEnabledItems);
}
if !enabled && self.selected == *value {
let replacement = match self.disabled_selection_policy {
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
.items
.iter()
.find(|item| item.enabled && item.value == self.default)
.map(|item| item.value.clone()),
DisabledSelectionPolicy::UseConfiguredDefault => None,
DisabledSelectionPolicy::UseFirstEnabled => self
.items
.iter()
.enumerate()
.find(|(other, item)| *other != index && item.enabled)
.map(|(_, item)| item.value.clone()),
DisabledSelectionPolicy::ReturnError => {
return Err(RadioGroupError::SelectedItemDisabled);
}
};
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
}
self.items[index].enabled = enabled;
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
{
self.focus_next();
}
Ok(())
}
pub fn default(&self) -> &T {
&self.default
}
}

View file

@ -2,7 +2,6 @@ use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
@ -18,6 +17,7 @@ use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
util::borders::draw_block_joins,
};
@ -34,6 +34,7 @@ pub struct ConsoleCard {
cursor: Arc<Mutex<bool>>,
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
}
impl ConsoleCard {
@ -49,6 +50,7 @@ impl ConsoleCard {
cursor: Arc::new(Mutex::new(true)),
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
}
}
@ -85,26 +87,25 @@ impl ConsoleCard {
}
}
fn cursor_spans(&self) -> Vec<Span<'static>> {
fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
let cursor_visible = self.cursor_visible();
let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray);
let mut spans = Vec::new();
if self.content.is_empty() {
if self.focused {
if cursor_visible {
spans.push(Span::styled(" ", cursor_style));
Self::push_cursor(&mut spans, theme);
} else {
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
spans.push(Span::styled(" ", theme.console.text));
}
spans.push(Span::styled(
"send command (/help for info)",
Style::default().fg(Color::DarkGray),
theme.console.hint,
));
} else {
spans.push(Span::styled(
" send command (/help for info)",
Style::default().fg(Color::DarkGray),
theme.console.hint,
));
}
return spans;
@ -119,52 +120,64 @@ impl ConsoleCard {
if prefix_len > 0 && before.len() >= prefix_len {
let prefix = &before[..prefix_len];
let rest = &before[prefix_len..];
spans.push(Span::styled(
prefix.to_string(),
Self::style_for_part(true, false, false),
));
spans.push(Span::styled(prefix.to_string(), theme.console.prefix));
if !rest.is_empty() {
spans.push(Span::styled(
rest.to_string(),
Style::default().fg(Color::White),
));
spans.push(Span::styled(rest.to_string(), theme.console.text));
}
} else if !before.is_empty() {
spans.push(Span::styled(
before.clone(),
Style::default().fg(Color::White),
));
spans.push(Span::styled(before.clone(), theme.console.text));
}
if cursor_visible {
spans.push(Span::styled(" ", cursor_style));
Self::push_cursor(&mut spans, theme);
}
if !after.is_empty() {
spans.push(Span::styled(after, Style::default().fg(Color::White)));
spans.push(Span::styled(after, theme.console.text));
}
spans
}
fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style {
if is_error {
return Style::default().fg(Color::Red);
fn push_cursor(spans: &mut Vec<Span<'static>>, theme: &crate::theme::ResolvedTheme) {
match &theme.console.cursor {
crate::theme::CursorPresentation::StyledCell(style) => {
spans.push(Span::styled(" ", *style))
}
crate::theme::CursorPresentation::Character { glyph, style } => {
spans.push(Span::styled(*glyph, *style))
}
}
if is_hint {
return Style::default().fg(Color::DarkGray);
}
if is_prefix {
return Style::default().fg(Color::DarkGray);
}
Style::default().fg(Color::White)
}
fn render_cursor_spans(&self) -> Vec<Span<'static>> {
self.cursor_spans()
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
theme.console.confirmation,
)];
}
self.cursor_spans(theme)
}
fn is_destructive(command: &str) -> bool {
matches!(
command.trim_start_matches('/').trim(),
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
) || command
.trim_start_matches('/')
.trim_start()
.starts_with("user remove ")
}
fn dispatch_command(&self, command: String) {
let ipc = self.ipc.clone();
let restore = self.pending_restore.clone();
tokio::spawn(async move {
if ipc.send_command(0, command.clone()).await.is_err() {
*restore.lock().unwrap() = Some(command);
}
});
}
fn move_cursor_left(&mut self) {
@ -212,28 +225,34 @@ impl Element for ConsoleCard {
self
}
fn render(&self, f: &mut Frame, r: Rect) {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
let block = Block::default()
.borders(self.borders)
.title(self.title.clone())
.title_style(Style::default().fg(Color::White))
.title_style(context.theme.console.title)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.console.focused_border
} else {
Style::default()
context.theme.console.border
})
.style(if self.focused {
Style::default().fg(Color::White)
} else {
Style::default()
});
.style(context.theme.console.text);
let spans = self.render_cursor_spans();
let spans = self.render_cursor_spans(context.theme);
let par = Paragraph::new(Line::from(spans))
.block(block)
.scroll((0, 0));
f.render_widget(par, r);
draw_block_joins(f, r, self.borders, self.joins);
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
@ -287,6 +306,13 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
}
if let Some(command) = self.pending_confirmation.take() {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
self.dispatch_command(command);
}
return InteractionResult::Handled;
}
match key.code {
KeyCode::Enter => {
if self.content.is_empty() {
@ -294,16 +320,13 @@ impl InteractableElement for ConsoleCard {
}
let command = self.content.clone();
let ipc = self.ipc.clone();
let restore = self.pending_restore.clone();
tokio::spawn(async move {
if ipc.send_command(0, command.clone()).await.is_err() {
*restore.lock().unwrap() = Some(command);
}
});
self.content.clear();
self.cursor_position = 0;
if Self::is_destructive(&command) {
self.pending_confirmation = Some(command);
} else {
self.dispatch_command(command);
}
InteractionResult::Handled
}
KeyCode::Backspace => {

View file

@ -3,14 +3,16 @@ use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect, widgets::Borders};
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
use crate::{
interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen,
};
#[allow(unused)]
pub trait Element: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, r: Rect);
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>);
}
#[allow(unused)]

View file

@ -5,7 +5,6 @@ use iota_state::ClientState;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{
Block, Borders,
canvas::{Canvas, Line},
@ -15,6 +14,7 @@ use ratatui::{
use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
render_context::RenderContext,
ui::UI,
util::borders::draw_block_joins,
};
@ -26,43 +26,30 @@ pub enum GRAPHS {
}
impl GRAPHS {
pub fn get_color(&self) -> Color {
pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color {
match self {
GRAPHS::Ram => Color::Blue,
GRAPHS::Cpu => Color::Red,
GRAPHS::Ping => Color::Green,
GRAPHS::Ram => theme.graphs.ram,
GRAPHS::Cpu => theme.graphs.cpu,
GRAPHS::Ping => theme.graphs.ping,
}
}
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
let state = match state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
match self {
GRAPHS::Ram => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ram
.clone(),
GRAPHS::Cpu => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.cpu
.clone(),
GRAPHS::Ping => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ping
.clone(),
GRAPHS::Ram => state.with_width(28).ram.clone(),
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
GRAPHS::Ping => state.with_width(28).ping.clone(),
}
}
pub fn get_unit(&self) -> String {
match self {
GRAPHS::Ram => "MB".to_string(),
// Memory is collected as a percentage of total RAM, not MiB.
GRAPHS::Ram => "%".to_string(),
GRAPHS::Cpu => "%".to_string(),
GRAPHS::Ping => "ms".to_string(),
}
@ -111,19 +98,24 @@ impl Element for GraphCard {
self
}
fn render(&self, f: &mut Frame, r: Rect) {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if self.open {
let graph = self.graph_type.get_graph(&self.state);
let unit = self.graph_type.get_unit();
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
let max_x = if max_x <= min_x { min_x + 1.0 } else { max_x };
let min_y = graph
.iter()
.map(|(_, y)| *y)
.filter(|y| *y > 0.0)
.min_by(|a, b| a.total_cmp(b))
.unwrap_or(0.0);
let max_y = graph.iter().map(|(_, y)| *y).fold(-1.0, f64::max);
let max_y = graph.iter().map(|(_, y)| *y).fold(0.0, f64::max);
let y_upper = match self.graph_type {
GRAPHS::Cpu | GRAPHS::Ram => 100.0,
GRAPHS::Ping => (max_y * 1.2).max(10.0),
};
let block = Block::default()
.title(format!(
@ -136,15 +128,15 @@ impl Element for GraphCard {
))
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.graphs.focused_border
} else {
Style::default()
context.theme.graphs.border
});
let canvas = Canvas::default()
.block(block)
.x_bounds([min_x, max_x])
.y_bounds([0.0, 100.0])
.y_bounds([0.0, y_upper])
.paint(|ctx| {
for (x, y) in &graph {
ctx.draw(&Line {
@ -152,7 +144,7 @@ impl Element for GraphCard {
y1: 0.0,
x2: *x,
y2: *y,
color: self.graph_type.get_color(),
color: self.graph_type.get_color(context.theme),
});
}
});
@ -162,13 +154,23 @@ impl Element for GraphCard {
.title("")
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.graphs.focused_border
} else {
Style::default()
context.theme.graphs.border
});
f.render_widget(block, r);
}
draw_block_joins(f, r, self.borders, self.joins);
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}

View file

@ -1,16 +1,54 @@
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::interaction_result::InteractionResult;
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy)]
enum LogSource {
Call,
Client,
Iota,
Omikron,
Omega,
Command,
Other,
}
impl LogSource {
fn from_sender(sender: &str) -> Self {
match sender {
"Call" => Self::Call,
"Client" => Self::Client,
"Iota" => Self::Iota,
"Omikron" => Self::Omikron,
"Omega" => Self::Omega,
"Command" => Self::Command,
_ => Self::Other,
}
}
fn style(self, theme: &crate::theme::ResolvedTheme) -> Style {
match self {
Self::Call => theme.logs.call,
Self::Client => theme.logs.client,
Self::Iota => theme.logs.iota,
Self::Omikron => theme.logs.omikron,
Self::Omega => theme.logs.omega,
Self::Command => theme.logs.command,
Self::Other => theme.logs.other,
}
}
}
pub struct LogCard {
state: ClientState,
@ -38,11 +76,10 @@ impl LogCard {
}
fn get_logs(&self) -> Vec<UiLogEntry> {
let state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let state = match self.state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
state
.get_logs()
.iter()
@ -64,7 +101,7 @@ impl LogCard {
let mut last_boundary = 0usize;
for (idx, ch) in s.char_indices() {
let char_width = if ch.is_ascii() { 1 } else { 2 };
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + char_width > max_width {
if last_boundary == 0 {
return idx + ch.len_utf8();
@ -78,7 +115,7 @@ impl LogCard {
s.len()
}
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> {
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> {
let mut result = Vec::new();
let timestamp = entry.format_timestamp();
@ -141,29 +178,17 @@ impl LogCard {
line.push_str(&timestamp);
}
result.push((line, Self::sender_color(&entry.sender), entry.is_error));
result.push((line, LogSource::from_sender(&entry.sender), entry.is_error));
}
result
}
fn sender_color(sender: &str) -> Color {
match sender {
"Call" => Color::Magenta,
"Client" => Color::Green,
"Iota" => Color::Yellow,
"Omikron" => Color::Blue,
"Omega" => Color::Cyan,
"Command" => Color::LightGreen,
_ => Color::LightCyan,
}
}
fn build_all_lines(
&self,
entries: Vec<UiLogEntry>,
width: usize,
) -> Vec<(String, Color, bool)> {
) -> Vec<(String, LogSource, bool)> {
let mut lines = Vec::new();
for entry in entries {
@ -269,23 +294,33 @@ impl Element for LogCard {
self
}
fn render(&self, f: &mut Frame, area: Rect) {
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let entries = self.get_logs();
let block = Block::default()
.title(self.build_title())
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.logs.focused_border
} else {
Style::default()
context.theme.logs.border
});
let inner_area = block.inner(area);
f.render_widget(block, area);
if inner_area.width == 0 || inner_area.height == 0 {
draw_block_joins(f, area, self.borders, self.joins);
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
return;
}
@ -298,7 +333,7 @@ impl Element for LogCard {
let rendered_lines: Vec<Line> = visible_lines
.iter()
.map(|(line, prefix_color, is_error)| {
.map(|(line, source, is_error)| {
let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line);
@ -306,24 +341,25 @@ impl Element for LogCard {
if !prefix.is_empty() {
spans.push(Span::styled(
prefix.to_string(),
Style::default().fg(*prefix_color),
source.style(context.theme),
));
}
let (content, timestamp) = Self::split_timestamp_suffix(rest);
let text_color = if *is_error { Color::Red } else { Color::White };
let text_style = if *is_error {
context.theme.logs.error
} else {
context.theme.logs.text
};
if !content.is_empty() {
spans.push(Span::styled(
content.to_string(),
Style::default().fg(text_color),
));
spans.push(Span::styled(content.to_string(), text_style));
}
if !timestamp.is_empty() {
spans.push(Span::styled(
timestamp.to_string(),
Style::default().fg(Color::DarkGray),
context.theme.logs.timestamp,
));
}
@ -341,7 +377,17 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(f, area, self.borders, self.joins);
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}

View file

@ -2,52 +2,59 @@ use crate::ui::UI;
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub fn setup_input_handler(ui: Arc<UI>) {
pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
tokio::spawn(async move {
let cancellation = ui.cancellation_token();
let (tx, mut rx) = mpsc::unbounded_channel();
let worker_cancellation = cancellation.clone();
let worker = tokio::task::spawn_blocking(move || -> Result<(), String> {
while !worker_cancellation.is_cancelled() {
if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
tx.send(read().map_err(|e| e.to_string())?)
.map_err(|_| "input session closed".to_string())?;
}
}
Ok(())
});
loop {
if ui.is_shutdown() {
break;
}
let event_result = tokio::task::spawn_blocking(|| {
if let Ok(true) = poll(Duration::from_millis(100)) {
read().ok().and_then(|ev| match ev {
Event::Key(key) if key.kind == KeyEventKind::Press => Some(key),
_ => None,
})
} else {
None
}
})
.await;
match event_result {
Ok(Some(key_event)) => {
handle_input(key_event, ui.clone()).await;
}
Ok(_) => {}
Err(e) => {
eprintln!("Input task error: {}", e);
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::select! {
event = rx.recv() => match event {
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
Some(Event::Resize(_, _)) => ui.invalidate(),
Some(Event::Paste(text)) => ui.handle_paste(text).await,
Some(_) => {},
None => break,
},
_ = cancellation.cancelled() => break,
}
}
});
let result = match worker.await {
Ok(result) => result,
Err(error) if error.is_cancelled() => Ok(()),
Err(error) => Err(format!("input worker failed: {error}")),
};
if result.is_err() {
ui.request_shutdown();
}
result
})
}
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
match (key.code, key.modifiers) {
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
ui.request_shutdown();
}
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
let _ = ui.send_restart().await;
ui.request_shutdown();
}
_ => {
ui.handle_input(key).await;
}
if matches!(
key.code,
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
) && key.modifiers.contains(KeyModifiers::CONTROL)
{
ui.request_shutdown();
} else {
ui.handle_input(key).await;
}
}

View file

@ -1,21 +1,25 @@
use iota_ipc::{
ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
use std::io::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{Mutex, oneshot, watch};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(10);
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2);
/// Connection state exposed to the UI.
#[derive(Clone, Debug)]
@ -24,96 +28,114 @@ pub enum IpcConnectionState {
Connected,
Reconnecting { attempt: u32 },
Incompatible { message: String },
Failed { message: String },
Disconnected,
}
/// Daemon information shown by the UI. This is separate from socket connectivity:
/// a connected daemon may still be starting or degraded.
#[derive(Clone, Debug, Default)]
pub struct DaemonStatus {
pub version: String,
pub instance_id: String,
pub startup_phase: Option<iota_ipc::StartupPhase>,
pub degraded_reason: Option<String>,
pub lifecycle: Option<iota_ipc::LifecyclePhase>,
pub health: iota_ipc::HealthStatus,
pub deployment_mode: Option<iota_ipc::DeploymentMode>,
pub supervisor: Option<iota_ipc::SupervisorKind>,
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
}
/// Pending request awaiting a response.
struct PendingRequest {
response_tx: oneshot::Sender<ResponseResult>,
}
struct ActiveWriter {
generation: u64,
writer: OwnedWriteHalf,
}
struct NegotiatedConnection {
reader: OwnedReadHalf,
writer: OwnedWriteHalf,
ack: HelloAck,
buffered_messages: Vec<DaemonMessage>,
}
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
* logs, so rendering never reaches into daemon-owned storage or connections. */
pub struct IpcClient {
state: ClientState,
writer: Mutex<OwnedWriteHalf>,
writer: Mutex<Option<ActiveWriter>>,
next_generation: AtomicU64,
next_request_id: AtomicU64,
pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>,
daemon_status: watch::Sender<DaemonStatus>,
path: PathBuf,
reconnector_started: AtomicBool,
cancellation: CancellationToken,
background_tasks: StdMutex<Vec<JoinHandle<()>>>,
}
impl IpcClient {
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let stream = Self::try_connect(&path).await?;
let (mut reader, writer) = stream.into_split();
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
Self::connect_until(&path, deadline).await
}
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting);
async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result<Arc<Self>> {
let path = path.to_path_buf();
let stream = Self::connect_stream(&path, deadline).await?;
let negotiated = Self::negotiate_stream(stream, deadline).await?;
// These values are visible before MainScreen subscribes. Do not
// publish the handshake into a channel with no retained receiver.
let initial_status = DaemonStatus {
version: negotiated.ack.daemon_version.clone(),
instance_id: negotiated.ack.instance_id.clone(),
startup_phase: Some(negotiated.ack.startup_phase),
degraded_reason: None,
lifecycle: Some(negotiated.ack.lifecycle),
health: negotiated.ack.health,
deployment_mode: Some(negotiated.ack.deployment_mode),
supervisor: Some(negotiated.ack.supervisor),
components: std::collections::BTreeMap::new(),
};
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
let (daemon_status_tx, _) = watch::channel(initial_status);
let client = Arc::new(Self {
state: ClientState::new(),
writer: Mutex::new(writer),
writer: Mutex::new(Some(ActiveWriter {
generation: 1,
writer: negotiated.writer,
})),
next_generation: AtomicU64::new(2),
next_request_id: AtomicU64::new(1),
pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx,
daemon_status: daemon_status_tx,
path: path.clone(),
reconnector_started: AtomicBool::new(false),
cancellation: CancellationToken::new(),
background_tasks: StdMutex::new(Vec::new()),
});
// --- Handshake: send Hello, read HelloAck ---
{
let mut w = client.writer.lock().await;
write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
)
.await?;
// Apply messages received while waiting for subscription confirmation
// before exposing the connection to the UI.
for message in negotiated.buffered_messages {
client.apply(message).await;
}
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = client
.connection_state
.send(IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
});
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Protocol version mismatch: daemon={}, minimum={}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
));
}
}
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck from daemon",
));
}
Err(e) => return Err(e),
}
let _ = client.connection_state.send(IpcConnectionState::Connected);
// Start reader task (continues reading after handshake)
let reader_client = client.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
let task = tokio::spawn(async move {
reader_client.read_loop(negotiated.reader, 1).await;
});
// Subscribe to events
client
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await?;
client.background_tasks.lock().unwrap().push(task);
Ok(client)
}
@ -121,165 +143,191 @@ impl IpcClient {
/// Try to connect with retries for socket activation.
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let max_attempts = 30;
for attempt in 0..max_attempts {
match Self::connect(&path).await {
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
let mut last_error = None;
while tokio::time::Instant::now() < deadline {
match Self::connect_until(&path, deadline).await {
Ok(client) => return Ok(client),
Err(error) => {
if attempt < max_attempts - 1 {
let delay = Duration::from_millis(100 + attempt as u64 * 100);
tokio::time::sleep(delay).await;
continue;
}
return Err(error);
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
}
unreachable!()
Err(last_error.unwrap_or_else(|| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon")
}))
}
async fn try_connect(path: &Path) -> Result<UnixStream> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result<UnixStream> {
tokio::time::timeout_at(deadline, UnixStream::connect(path))
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out connecting to daemon",
)
})?
}
async fn negotiate_stream(
stream: UnixStream,
deadline: tokio::time::Instant,
) -> Result<NegotiatedConnection> {
let (mut reader, mut writer) = stream.into_split();
tokio::time::timeout_at(
deadline,
write_msg(
&mut writer,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
),
)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello")
})??;
let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader))
.await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out waiting for IPC HelloAck",
));
}
Ok(Ok(DaemonMessage::HelloAck(ack))) => ack,
Ok(Ok(_)) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck as the first daemon message",
));
}
Ok(Err(error)) => return Err(error),
};
if !Self::is_compatible_version(ack.protocol_version) {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Unsupported daemon protocol version {}",
ack.protocol_version
),
));
}
tokio::time::timeout_at(
deadline,
write_msg(
&mut writer,
&ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
},
),
)
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out sending IPC subscription",
)
})??;
// The daemon may send its initial StateUpdate before the acknowledgement.
// Keep draining until the subscription itself is confirmed, otherwise a
// UI can report Connected while no state stream exists yet.
let mut buffered_messages = Vec::new();
loop {
match UnixStream::connect(path).await {
Ok(stream) => return Ok(stream),
Err(error) => {
if tokio::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(Duration::from_millis(200)).await;
match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out waiting for IPC subscription acknowledgement",
));
}
Ok(Ok(DaemonMessage::Subscribed)) => break,
Ok(Ok(message)) => buffered_messages.push(message),
Ok(Err(error)) => return Err(error),
}
}
Ok(NegotiatedConnection {
reader,
writer,
ack,
buffered_messages,
})
}
/// Start the reconnection actor.
pub fn spawn_reconnector(self: &Arc<Self>) {
if self.reconnector_started.swap(true, Ordering::AcqRel) {
return;
}
let client = self.clone();
tokio::spawn(async move {
let task = tokio::spawn(async move {
client.reconnection_loop().await;
});
self.background_tasks.lock().unwrap().push(task);
}
async fn reconnection_loop(self: Arc<Self>) {
let mut rx = self.connection_status();
loop {
// Wait until the connection enters the Disconnected state.
loop {
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
if disconnected {
break;
}
if rx.changed().await.is_err() {
return; // sender dropped
while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) {
if tokio::select! {
changed = rx.changed() => changed.is_err(),
_ = self.cancellation.cancelled() => true,
} {
return;
}
}
let mut backoff = INITIAL_BACKOFF;
let mut attempt: u32 = 0;
// Attempt reconnection until success or max attempts.
loop {
tokio::time::sleep(backoff).await;
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: "Max reconnection attempts exceeded".into(),
});
for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
if self.cancellation.is_cancelled() {
return;
}
let _ = self
.connection_state
.send(IpcConnectionState::Reconnecting { attempt });
match Self::try_connect(&self.path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
*self.writer.lock().await = writer;
// Re-handshake
{
let mut w = self.writer.lock().await;
if write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![
MIN_PROTOCOL_VERSION,
PROTOCOL_VERSION,
],
},
)
.await
.is_err()
{
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Read HelloAck
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = self.connection_state.send(
IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
},
);
return;
}
}
_ => {
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Clear pending requests with connection-lost errors
{
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request.response_tx.send(
ResponseResult::Error(
iota_ipc::IpcErrorCode::Disconnected,
),
);
}
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
// Start new reader loop
let reader_client = self.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
});
// Resubscribe
let _ = self
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await;
// Successfully reconnected; go back to waiting for
// the next disconnect.
tokio::select! {
_ = tokio::time::sleep(backoff) => {},
_ = self.cancellation.cancelled() => return,
}
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
let result = async {
let stream = Self::connect_stream(&self.path, deadline).await?;
Self::negotiate_stream(stream, deadline).await
}
.await;
match result {
Ok(connection) => {
self.install_connection(connection).await;
break;
}
Err(_) => {
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: error.to_string(),
});
return;
}
Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => {
let _ = self.connection_state.send(IpcConnectionState::Failed {
message: format!("Reconnect failed after {attempt} attempts: {error}"),
});
return;
}
Err(error) => {
eprintln!(
"IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}",
self.path.display(),
error.kind()
);
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
}
}
@ -287,16 +335,61 @@ impl IpcClient {
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
loop {
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(message) => self.apply(message).await,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
async fn install_connection(self: &Arc<Self>, connection: NegotiatedConnection) {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
*self.writer.lock().await = Some(ActiveWriter {
generation,
writer: connection.writer,
});
self.update_hello_ack(connection.ack);
for message in connection.buffered_messages {
self.apply(message).await;
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
let client = self.clone();
let task = tokio::spawn(async move {
client.read_loop(connection.reader, generation).await;
});
self.background_tasks.lock().unwrap().push(task);
}
async fn fail_pending_requests(&self) {
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request
.response_tx
.send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected));
}
}
async fn mark_disconnected(&self, generation: u64) {
let removed = {
let mut writer = self.writer.lock().await;
match writer.as_ref() {
Some(active) if active.generation == generation => {
writer.take();
true
}
Err(_) => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
_ => false,
}
};
if removed {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
self.fail_pending_requests().await;
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf, generation: u64) {
loop {
let result = tokio::select! {
result = read_msg::<_, DaemonMessage>(&mut reader) => result,
_ = self.cancellation.cancelled() => break,
};
match result {
Ok(message) => self.apply(message).await,
Err(error) => {
eprintln!("IPC reader for generation {generation} stopped: {error}");
self.mark_disconnected(generation).await;
break;
}
}
@ -315,6 +408,65 @@ impl IpcClient {
self.connection_state.borrow().clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status.subscribe()
}
/// Stop the IPC reader/reconnector and release the socket writer. This
/// is deliberately bounded so UI shutdown cannot hang on a peer.
pub async fn shutdown(&self) {
self.cancellation.cancel();
self.writer.lock().await.take();
let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap());
for mut task in tasks {
if tokio::time::timeout(Duration::from_secs(2), &mut task)
.await
.is_err()
{
task.abort();
}
}
}
fn is_compatible_version(version: u16) -> bool {
(MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version)
}
fn update_hello_ack(&self, ack: HelloAck) {
self.daemon_status.send_modify(|status| {
status.version = ack.daemon_version;
status.instance_id = ack.instance_id;
status.startup_phase = Some(ack.startup_phase);
status.lifecycle = Some(ack.lifecycle);
status.health = ack.health;
status.deployment_mode = Some(ack.deployment_mode);
status.supervisor = Some(ack.supervisor);
});
}
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
match code {
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.",
iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.",
iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.",
iota_ipc::IpcErrorCode::OmikronUnavailable => {
"Omikron is unavailable; try reconnecting."
}
iota_ipc::IpcErrorCode::UnsupportedVersion => {
"CLI and daemon versions are incompatible."
}
iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.",
iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.",
iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.",
iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.",
iota_ipc::IpcErrorCode::Unauthorized => {
"The daemon rejected this operation as unauthorized."
}
iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.",
}
}
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (response_tx, response_rx) = oneshot::channel();
@ -329,14 +481,17 @@ impl IpcClient {
protocol_version: PROTOCOL_VERSION,
request,
};
self.send(ClientMessage::Request(envelope)).await?;
if let Err(error) = self.send(ClientMessage::Request(envelope)).await {
self.pending.lock().await.remove(&request_id);
return Err(error);
}
match tokio::time::timeout(Duration::from_secs(30), response_rx).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
Err(_) => {
self.pending.lock().await.remove(&request_id);
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected))
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout))
}
}
}
@ -357,8 +512,12 @@ impl IpcClient {
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
}),
_ => None,
}
}
@ -371,11 +530,7 @@ impl IpcClient {
if trimmed == "ping" || trimmed.starts_with("ping ") {
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -387,11 +542,7 @@ impl IpcClient {
});
return Err(e);
}
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -407,14 +558,10 @@ impl IpcClient {
if let Some(request) = Self::parse_console_command(&line) {
match self.send_request(request).await {
Ok(result) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
let message = match &result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
@ -428,11 +575,7 @@ impl IpcClient {
Ok(())
}
Err(e) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -446,19 +589,15 @@ impl IpcClient {
}
}
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: if line.trim() == "help" {
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
.into()
} else {
format!("Unknown command: {}", line)
@ -470,18 +609,43 @@ impl IpcClient {
}
async fn send(&self, message: ClientMessage) -> Result<()> {
let mut writer = self.writer.lock().await;
write_msg(&mut *writer, &message).await
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock())
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out acquiring IPC writer",
)
})?;
let active = writer_guard.as_mut().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotConnected,
"IPC connection is not active",
)
})?;
let generation = active.generation;
let write_result =
tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message))
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out writing IPC message",
)
})
.and_then(|result| result);
drop(writer_guard);
if write_result.is_err() {
self.mark_disconnected(generation).await;
}
write_result
}
async fn apply(&self, message: DaemonMessage) {
match message {
DaemonMessage::LogEntry(entry) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
@ -490,11 +654,16 @@ impl IpcClient {
});
}
DaemonMessage::StateUpdate(snapshot) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
// Never hold a watch borrow while sending to that same
// channel: send waits for outstanding Ref guards.
self.daemon_status.send_modify(|status| {
status.startup_phase = Some(snapshot.startup_phase);
status.degraded_reason = snapshot.degraded_reason.clone();
status.lifecycle = Some(snapshot.lifecycle);
status.health = snapshot.overall_health;
status.components = snapshot.components.clone();
});
let mut state = self.state.app.lock().await;
state.cpu = snapshot.cpu;
state.ram = snapshot.ram;
state.ping = snapshot.ping;
@ -503,41 +672,21 @@ impl IpcClient {
state.sys_info = snapshot.sys_info;
}
DaemonMessage::MetricSample(sample) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
if let Some(cpu) = sample.cpu {
let idx = state.cpu.len() as f64;
state.cpu.push((idx, cpu));
if state.cpu.len() > iota_state::MAX_POINTS {
state.cpu.remove(0);
}
state.push_cpu((0.0, cpu));
}
if let Some(ram) = sample.ram {
let idx = state.ram.len() as f64;
state.ram.push((idx, ram));
if state.ram.len() > iota_state::MAX_POINTS {
state.ram.remove(0);
}
state.push_ram((0.0, ram));
}
if let Some(ping) = sample.ping {
state.push_ping_val(ping);
}
if let Some(net_up) = sample.net_up {
let idx = state.net_up.len() as f64;
state.net_up.push((idx, net_up));
if state.net_up.len() > iota_state::MAX_POINTS {
state.net_up.remove(0);
}
state.push_net_up((0.0, net_up));
}
if let Some(net_down) = sample.net_down {
let idx = state.net_down.len() as f64;
state.net_down.push((idx, net_down));
if state.net_down.len() > iota_state::MAX_POINTS {
state.net_down.remove(0);
}
state.push_net_down((0.0, net_down));
}
}
DaemonMessage::Response(response) => {
@ -545,14 +694,10 @@ impl IpcClient {
if let Some(request) = pending.remove(&response.request_id) {
let _ = request.response_tx.send(response.result);
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
@ -565,15 +710,12 @@ impl IpcClient {
});
}
}
DaemonMessage::HelloAck(_) => {}
DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack),
DaemonMessage::Subscribed => {}
DaemonMessage::Pong { .. } => {}
DaemonMessage::LifecycleEvent(event) => match event {
iota_ipc::LifecycleEvent::Shutdown { reason } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -584,14 +726,19 @@ impl IpcClient {
is_error: true,
});
}
_ => {}
iota_ipc::LifecycleEvent::StateChanged(status) => {
self.daemon_status.send_modify(|daemon_status| {
daemon_status.degraded_reason = match status {
iota_ipc::ConnectionStatus::Degraded => {
Some("A daemon dependency is degraded".into())
}
_ => None,
};
});
}
},
DaemonMessage::Gap { skipped } => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)

View file

@ -0,0 +1,52 @@
use ratatui::layout::Rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequiredSize {
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitLevel {
Preferred,
Compact,
Fallback,
}
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
if area.width >= preferred.width && area.height >= preferred.height {
FitLevel::Preferred
} else if area.width >= compact.width && area.height >= compact.height {
FitLevel::Compact
} else {
FitLevel::Fallback
}
}
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
let width = area.width.min(maximum.width);
let height = area.height.min(maximum.height);
Rect {
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
y: area
.y
.saturating_add(area.height.saturating_sub(height) / 2),
width,
height,
}
}
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
Some(Rect {
x: area.x,
y: area.y.checked_add(top)?,
width: area.width,
height,
})
}
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
Some(Rect {
x: area.x.checked_add(horizontal)?,
y: area.y.checked_add(vertical)?,
width,
height,
})
}

View file

@ -0,0 +1,2 @@
pub mod fit;
pub mod text_measure;

View file

@ -0,0 +1,10 @@
use unicode_width::UnicodeWidthStr;
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
if width == 0 {
return 0;
}
text.split('\n')
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
.sum::<usize>()
.min(u16::MAX as usize) as u16
}

View file

@ -5,6 +5,7 @@ pub mod elements {
pub mod log_card;
}
pub mod screens {
pub mod daemon_setup;
pub mod main_screen;
pub mod md_viewer;
pub mod screens;
@ -17,7 +18,12 @@ pub mod util {
pub mod terms_focus;
}
pub mod app_state;
pub mod controls;
pub mod input_handler;
pub mod ipc_client;
pub mod interaction_result;
pub mod ipc_client;
pub mod layout;
pub mod render_context;
pub mod theme;
pub mod ui;
pub use ui::TuiSession;

View file

@ -0,0 +1,6 @@
use crate::theme::ResolvedTheme;
/// Immutable state shared by every component during one render pass.
pub struct RenderContext<'a> {
pub theme: &'a ResolvedTheme,
}

View file

@ -0,0 +1,287 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
radio_group::{RadioGroup, RadioItem},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Text},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::any::Any;
use tokio::sync::oneshot;
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
/// setup choice screen is intentionally closed before its decision is sent,
/// so without this the terminal would otherwise be blank during startup.
pub struct DaemonStartingScreen;
impl Screen for DaemonStartingScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 48,
height: 5,
},
);
frame.render_widget(
Paragraph::new(
"Starting iota-daemon…\nWaiting for its IPC handshake.\nPress Ctrl+C to cancel.",
)
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(" Iota daemon ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal),
),
popup,
);
}
fn handle_input(&mut self, _: KeyEvent) -> InteractionResult {
InteractionResult::Handled
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonLaunchMode {
Once,
WithUi,
WithSystem,
}
#[derive(Debug, Clone)]
pub struct LaunchOption {
pub mode: DaemonLaunchMode,
pub enabled: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonSetupDecision {
Start(DaemonLaunchMode),
Exit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Options,
Exit,
Action,
}
/// The launcher owns the actual side effects. This screen only presents the
/// capabilities discovered for this machine, keeping disabled choices visible.
pub struct DaemonSetupScreen {
choices: RadioGroup<DaemonLaunchMode>,
focus: Focus,
sender: Option<oneshot::Sender<DaemonSetupDecision>>,
message: String,
}
impl DaemonSetupScreen {
pub fn new(
options: Vec<LaunchOption>,
message: impl Into<String>,
sender: oneshot::Sender<DaemonSetupDecision>,
) -> Result<Self, crate::controls::radio_group::RadioGroupError> {
let items: Vec<RadioItem<DaemonLaunchMode>> = options
.into_iter()
.map(|o| RadioItem {
value: o.mode,
label: match o.mode {
DaemonLaunchMode::Once => "Start once",
DaemonLaunchMode::WithUi => "Start with Iota UI",
DaemonLaunchMode::WithSystem => "Start with the system",
}
.into(),
description: o.reason,
enabled: o.enabled,
disabled_reason: None,
})
.collect();
let default = items
.iter()
.find(|item| item.enabled)
.map(|item| item.value)
.ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?;
let mut choices = RadioGroup::new(items, None, default)?;
choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include);
Ok(Self {
choices,
focus: Focus::Options,
sender: Some(sender),
message: message.into(),
})
}
fn complete(&mut self, d: DaemonSetupDecision) {
if let Some(tx) = self.sender.take() {
let _ = tx.send(d);
}
}
fn activate(&mut self) -> InteractionResult {
match self.focus {
Focus::Options => {
self.choices.select_focused();
InteractionResult::Handled
}
Focus::Exit => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
}
Focus::Action => {
let choice = *self.choices.selected();
if self
.choices
.items()
.iter()
.find(|i| i.value == choice)
.is_some_and(|i| i.enabled)
{
self.complete(DaemonSetupDecision::Start(choice));
InteractionResult::CloseScreen
} else {
InteractionResult::Handled
}
}
}
}
fn next(&mut self) {
self.focus = match self.focus {
Focus::Options => {
self.choices.focus_next();
if self.choices.focused_item().value == DaemonLaunchMode::Once {
Focus::Exit
} else {
Focus::Options
}
}
Focus::Exit => Focus::Action,
Focus::Action => Focus::Options,
};
}
fn previous(&mut self) {
self.focus = match self.focus {
Focus::Options => {
self.choices.focus_previous();
if self.choices.focused_item().value == DaemonLaunchMode::WithSystem {
Focus::Action
} else {
Focus::Options
}
}
Focus::Exit => Focus::Options,
Focus::Action => Focus::Exit,
};
}
}
impl Screen for DaemonSetupScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 68,
height: 16,
},
);
let mut lines = vec![Line::from(self.message.as_str()), Line::from("")];
for item in self.choices.items() {
lines.push(render_choice_line(
&item.label,
ChoiceKind::Radio,
self.choices.visual_state(&item.value),
context.theme,
));
if let Some(reason) = &item.description {
lines.push(Line::styled(
format!(" {reason}"),
context.theme.text.muted,
));
}
}
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(" Iota daemon setup ")
.borders(Borders::ALL),
),
rows[0],
);
let b = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_button(
frame,
b[0],
ActionButton {
label: "Exit",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Exit,
enabled: true,
},
context.theme,
);
let selected = *self.choices.selected();
let enabled = self
.choices
.items()
.iter()
.find(|i| i.value == selected)
.is_some_and(|i| i.enabled);
let label = match selected {
DaemonLaunchMode::Once => "Start once",
DaemonLaunchMode::WithUi => "Save and start",
DaemonLaunchMode::WithSystem => "Configure and start",
};
render_button(
frame,
b[1],
ActionButton {
label,
intent: if enabled {
ButtonIntent::Primary
} else {
ButtonIntent::Destructive
},
focused: self.focus == Focus::Action,
enabled,
},
context.theme,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
match event.code {
KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.next();
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
self.previous();
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
}

View file

@ -6,7 +6,8 @@ use crate::{
log_card::LogCard,
},
interaction_result::InteractionResult,
ipc_client::IpcConnectionState,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{NavDirection, Screen},
ui::UI,
};
@ -27,6 +28,7 @@ pub struct MainScreen {
selected_coords: (usize, usize),
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
}
impl MainScreen {
@ -39,10 +41,17 @@ impl MainScreen {
vec![Some(1), Some(4)],
];
let state = ui.client_state();
let state = ui
.client_state()
.await
.expect("MainScreen requires an attached daemon");
let mut log_card = LogCard::new(state.clone());
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let mut console_card = ConsoleCard::new("Console", "", ui.ipc());
let ipc = ui
.ipc()
.await
.expect("MainScreen requires an attached daemon");
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card));
@ -61,7 +70,8 @@ impl MainScreen {
let graphs_open = true;
let connection_status_rx = ui.ipc().connection_status();
let connection_status_rx = ipc.connection_status();
let daemon_status_rx = ipc.daemon_status();
let mut screen = MainScreen {
elements,
@ -69,6 +79,7 @@ impl MainScreen {
selected_coords: (1, 0),
graphs_open,
connection_status_rx,
daemon_status_rx,
};
screen.focus_current();
screen
@ -198,22 +209,46 @@ impl Screen for MainScreen {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
let status = self.connection_status_rx.borrow();
let status_text = match &*status {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
// A watch Ref blocks senders until it is dropped. Rendering may do
// terminal I/O, so retain only owned snapshots for the whole frame.
let status = self.connection_status_rx.borrow().clone();
let daemon = self.daemon_status_rx.borrow().clone();
let status_text = match status {
IpcConnectionState::Connected => "Connected".to_string(),
IpcConnectionState::Connecting => "Connecting...".to_string(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {})...", attempt)
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {}", message)
format!("Incompatible protocol: {}", message)
}
IpcConnectionState::Failed { message } => {
format!("Connection failed: {}", message)
}
IpcConnectionState::Disconnected => "Disconnected".to_string(),
};
let readiness = daemon
.startup_phase
.map(|phase| format!("{:?}", phase))
.unwrap_or_else(|| "Waiting for status".into());
let health = daemon
.degraded_reason
.as_deref()
.map(|reason| format!("{reason}"))
.unwrap_or_default();
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let main_block = Block::default()
.title(format!("Main [{}]", status_text))
.borders(Borders::ALL);
.title(format!(
"Iota{version} [{status_text}; {readiness}{health}]"
))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
f.render_widget(main_block, rect);
let inner = rect.inner(Margin {
@ -221,7 +256,11 @@ impl Screen for MainScreen {
horizontal: 1,
});
let graphs_width = if self.graphs_open { 30 } else { 2 };
let graphs_width = if self.graphs_open && inner.width >= 70 {
30
} else {
2
};
let main_width = inner.width.saturating_sub(graphs_width);
let horizontal_chunks = Layout::default()
@ -239,11 +278,11 @@ impl Screen for MainScreen {
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[0]);
log.as_element().render(f, left_rows[0], context);
}
if let Some(console) = self.elements.get(1) {
console.as_element().render(f, left_rows[1]);
console.as_element().render(f, left_rows[1], context);
}
let graph_elements: Vec<_> = self
@ -262,7 +301,7 @@ impl Screen for MainScreen {
.split(right_area);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area);
el.as_element().render(f, *area, context);
}
}
}

View file

@ -7,11 +7,16 @@ use ratatui::{
};
use std::{any::Any, time::Duration};
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
pub struct FileViewer {
title: String,
text: Vec<DisplayLine>,
content: String,
scroll: u16,
scroll_x: u16,
}
@ -24,8 +29,8 @@ impl Screen for FileViewer {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
self.draw(f, rect);
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
self.draw(f, rect, context.theme);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
@ -52,7 +57,7 @@ impl FileViewer {
pub fn new(title: String, content: &str) -> Self {
Self {
title,
text: parse_document(content.to_owned()),
content: content.to_owned(),
scroll: 0,
scroll_x: 0,
}
@ -62,7 +67,7 @@ impl FileViewer {
terminal
.draw(|f| {
let area = f.area();
self.draw(f, area);
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
})
.unwrap();
@ -77,12 +82,13 @@ impl FileViewer {
}
terminal
}
fn draw(&self, f: &mut Frame, area: Rect) {
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
use ratatui::text::Text;
let mut rendered_lines = Vec::new();
let text = parse_document(&self.content, theme);
for display_line in &self.text {
for display_line in &text {
if display_line.scrollable {
let content: String = display_line
.line
@ -153,7 +159,7 @@ impl FileViewer {
}
}
}
fn parse_document(input: String) -> Vec<DisplayLine> {
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
let mut lines_vec = Vec::new();
let mut in_code_block = false;
let liness: Vec<String> = input.lines().map(String::from).collect();
@ -172,7 +178,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
format!("────────{}────────", code),
Style::default().fg(Color::DarkGray),
theme.markdown.divider,
)),
scrollable: false,
});
@ -182,10 +188,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
if in_code_block {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.to_string(),
Style::default().fg(Color::Yellow),
)),
line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)),
scrollable: false,
});
i += 1;
@ -195,9 +198,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("### ").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -208,9 +215,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("## ").to_string(),
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -221,9 +232,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("# ").to_string(),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -254,13 +269,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
}
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
lines_vec.extend(table_to_lines(table));
lines_vec.extend(table_to_lines(table, theme));
i = j;
continue;
}
lines_vec.push(DisplayLine {
line: Line::from(parse_inline(raw.as_str())),
line: Line::from(parse_inline(raw.as_str(), theme)),
scrollable: false,
});
i += 1;
@ -269,7 +284,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec
}
fn parse_inline(input: &str) -> Vec<Span<'static>> {
fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut buf = String::new();
@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
};
if let Some(kind) = toggle {
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
match kind {
"bold" => bold = !bold,
@ -308,24 +327,21 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
buf.push(c);
}
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
spans
}
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
let mut style = Style::default();
if bold {
style = style.add_modifier(Modifier::BOLD);
}
if underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if code {
style = style.fg(Color::Yellow);
}
style
fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style {
let base = if code {
theme.markdown.code
} else {
theme.markdown.normal
};
theme.apply_text_semantics(base, TextSemantics { bold, underline })
}
#[derive(Clone)]
pub struct DisplayLine {
@ -333,7 +349,7 @@ pub struct DisplayLine {
scrollable: bool,
}
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
fn table_to_lines(table: Vec<Vec<String>>, theme: &ResolvedTheme) -> Vec<DisplayLine> {
if table.len() < 2 {
return vec![];
}
@ -377,7 +393,7 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
.join("─┼─");
lines.push(DisplayLine {
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
line: Line::from(Span::styled(divider, theme.markdown.divider)),
scrollable: true,
});
continue;
@ -403,11 +419,15 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
}
let style = if row_idx == 0 {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
theme.apply_text_semantics(
theme.markdown.table_header,
TextSemantics {
bold: true,
underline: false,
},
)
} else {
Style::default().fg(Color::Green)
theme.markdown.table_text
};
lines.push(DisplayLine {

View file

@ -3,7 +3,7 @@ use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect};
use crate::interaction_result::InteractionResult;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
@ -20,6 +20,6 @@ pub trait Screen: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, rect: Rect);
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
}

View file

@ -1,22 +1,19 @@
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
ui::UI,
util::{
buttons::{checkbox, draw_buttons},
terms_focus::Focus,
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use crossterm::event::{KeyCode, KeyEvent};
use iota_terms::{TermsType, get_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::{any::Any, pin::Pin, sync::Arc};
use std::{any::Any, pin::Pin};
use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -26,9 +23,7 @@ pub enum UserChoice {
AcceptAll,
}
#[allow(dead_code)] // ui is unused
pub struct TermsCheckerScreen {
_ui: Arc<UI>,
sender: Option<oneshot::Sender<UserChoice>>,
eula: bool,
@ -39,9 +34,8 @@ pub struct TermsCheckerScreen {
}
impl TermsCheckerScreen {
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self {
Self {
_ui: ui,
sender,
eula: false,
tos: false,
@ -59,7 +53,7 @@ impl Screen for TermsCheckerScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -169,9 +163,36 @@ impl Screen for TermsCheckerScreen {
)
};
let mut text_lines = vec![
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
render_choice_line(
eula_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.eula,
focused: self.focus == Focus::Eula,
enabled: true,
},
context.theme,
),
render_choice_line(
tos_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.tos,
focused: self.focus == Focus::Tos,
enabled: self.eula,
},
context.theme,
),
render_choice_line(
pp_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.pp,
focused: self.focus == Focus::Pp,
enabled: self.eula,
},
context.theme,
),
Line::from(""),
Line::from("¹ Necessary required to run the program"),
Line::from("² Optional required only for Tensamin services"),
@ -191,19 +212,19 @@ impl Screen for TermsCheckerScreen {
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 19 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= 13 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -245,6 +266,7 @@ impl Screen for TermsCheckerScreen {
true,
false,
true,
context.theme,
);
}

View file

@ -1,11 +1,10 @@
use crate::screens::terms_checker::UserChoice;
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
util::{
buttons::{checkbox, draw_buttons},
terms_focus::Focus,
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
@ -13,7 +12,6 @@ use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
@ -123,7 +121,19 @@ impl Screen for TermsUpdaterScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
let checkbox = |label, selected, focused, enabled| {
render_choice_line(
label,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected,
focused,
enabled,
},
context.theme,
)
};
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -523,19 +533,19 @@ impl Screen for TermsUpdaterScreen {
};
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 20 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= (header_lines as u16 + 10) {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -579,6 +589,7 @@ impl Screen for TermsUpdaterScreen {
self.update_needed,
downgrade_scenario,
self.pp_needed || self.tos_needed,
context.theme,
);
}

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
}
}
}

View file

@ -1,144 +1,262 @@
use crate::{
input_handler::setup_input_handler, interaction_result::InteractionResult,
ipc_client::IpcClient, screens::screens::Screen,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::Screen,
theme::{self, ResolvedTheme, ThemeName},
};
use crossterm::event::KeyEvent;
use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend, init};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
collections::VecDeque,
io,
io::Stdout,
panic::PanicHookInfo,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{sync::RwLock, time::Instant};
use tokio::sync::{Notify, RwLock};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
/// UI state and rendering
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
pub struct UI {
ipc: Arc<IpcClient>,
shutdown: AtomicBool,
ipc: RwLock<Option<Arc<IpcClient>>>,
shutdown_on_empty: bool,
cancellation: CancellationToken,
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
theme: RwLock<Arc<ResolvedTheme>>,
pub(crate) invalidation: Notify,
failure: Arc<Mutex<Option<String>>>,
}
pub fn start_tui(ipc: Arc<IpcClient>) -> Arc<UI> {
let ui = Arc::new(UI::new(ipc));
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi))
}
pub fn start_tui_with_theme(ipc: Arc<IpcClient>, theme: ResolvedTheme) -> io::Result<TuiSession> {
start_session(UI::new(Some(ipc), true, theme)?)
}
pub fn start_bootstrap_tui() -> io::Result<TuiSession> {
start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi))
}
pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSession> {
start_session(UI::new(None, false, theme)?)
}
fn start_session(ui: UI) -> io::Result<TuiSession> {
let ui = Arc::new(ui);
let uic = ui.clone();
tokio::spawn(async move {
let mut last_render = Instant::now();
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
let mut fps_sum = 0.0;
let mut skip_sum: u32 = 0;
let mut skipped = 0;
loop {
if uic.is_shutdown() {
break;
let renderer_task = tokio::spawn(async move {
let cancellation = uic.cancellation_token();
let result: io::Result<()> = loop {
tokio::select! {
_ = cancellation.cancelled() => break Ok(()),
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
}
if skipped > 5 {
uic.render().await;
skip_samples.push_back(skipped);
skip_sum += skipped as u32;
if skip_samples.len() > 20 {
if let Some(old) = skip_samples.pop_front() {
skip_sum -= old as u32;
}
}
skipped = 0;
let elapsed = last_render.elapsed().as_secs_f64();
if elapsed > 0.0 {
let fps = 1.0 / elapsed;
fps_samples.push_back(fps);
fps_sum += fps;
if fps_samples.len() > 20 {
if let Some(old) = fps_samples.pop_front() {
fps_sum -= old;
}
}
}
let avg_fps = if !fps_samples.is_empty() {
fps_sum / fps_samples.len() as f64
} else {
0.0
};
let avg_skips_percentage = if !skip_samples.is_empty() {
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
let total_iterations = avg_skipped + 1.0;
(avg_skipped / total_iterations) * 100.0
} else {
0.0
};
*FPS.write().await = (avg_fps, avg_skips_percentage);
last_render = Instant::now();
} else {
skipped += 1;
}
tokio::time::sleep(Duration::from_millis(16)).await;
};
if let Err(error) = &result {
*uic.failure.lock().unwrap() = Some(error.to_string());
uic.request_shutdown();
}
ratatui::restore();
result
});
setup_input_handler(ui.clone());
ui
let input_task = setup_input_handler(ui.clone());
// Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw
// mode. Keep this independent of key-event handling for bootstrap work.
let signal_task = {
#[cfg(unix)]
{
let signal_ui = ui.clone();
Some(tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
signal_ui.request_shutdown();
}
}))
}
#[cfg(not(unix))]
{
None
}
};
let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook())));
let hook_for_panic = previous_hook.clone();
std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| {
ratatui::restore();
if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() {
hook(info);
}
}));
Ok(TuiSession {
ui,
renderer_task,
input_task,
signal_task,
restored: AtomicBool::new(false),
previous_hook,
})
}
pub struct TuiSession {
ui: Arc<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
signal_task: Option<JoinHandle<()>>,
restored: AtomicBool,
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
}
impl TuiSession {
pub fn ui(&self) -> Arc<UI> {
self.ui.clone()
}
pub async fn shutdown(mut self) -> Option<String> {
self.ui.request_shutdown();
// Restore raw-mode state before waiting on cooperative tasks. A
// misbehaving task must never leave the invoking shell unusable.
self.restore_terminal_once();
let renderer =
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
let input =
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
if renderer.is_err() {
self.renderer_task.abort();
}
if input.is_err() {
self.input_task.abort();
}
if let Some(task) = self.signal_task.as_mut() {
task.abort();
let _ = task.await;
}
self.restore_panic_hook();
match renderer {
Err(_) => Some("renderer did not stop within 2 seconds".into()),
Ok(Err(error)) => Some(format!("renderer task failed: {error}")),
Ok(Ok(Err(error))) => Some(error.to_string()),
Ok(Ok(Ok(()))) => match input {
Err(_) => Some("input handler did not stop within 2 seconds".into()),
Ok(Err(error)) => Some(format!("input handler failed: {error}")),
Ok(Ok(Err(error))) => Some(error),
Ok(Ok(Ok(()))) => None,
},
}
}
fn restore_terminal_once(&self) {
if !self.restored.swap(true, Ordering::AcqRel) {
ratatui::restore();
}
}
fn restore_panic_hook(&self) {
if let Some(hook) = self.previous_hook.lock().unwrap().take() {
std::panic::set_hook(hook);
}
}
}
impl Drop for TuiSession {
fn drop(&mut self) {
self.ui.request_shutdown();
self.renderer_task.abort();
self.input_task.abort();
if let Some(task) = self.signal_task.as_ref() {
task.abort();
}
self.restore_panic_hook();
self.restore_terminal_once();
}
}
impl UI {
pub fn new(ipc: Arc<IpcClient>) -> Self {
let terminal = init();
Self {
ipc,
shutdown: AtomicBool::new(false),
pub(crate) fn new(
ipc: Option<Arc<IpcClient>>,
shutdown_on_empty: bool,
theme: ResolvedTheme,
) -> io::Result<Self> {
let terminal = ratatui::try_init()?;
Ok(Self {
ipc: RwLock::new(ipc),
shutdown_on_empty,
cancellation: CancellationToken::new(),
terminal: Arc::new(Mutex::new(terminal)),
screen_stack: Arc::new(RwLock::new(Vec::new())),
}
theme: RwLock::new(Arc::new(theme)),
invalidation: Notify::new(),
failure: Arc::new(Mutex::new(None)),
})
}
pub fn ipc(&self) -> Arc<IpcClient> {
self.ipc.clone()
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
self.ipc.read().await.clone()
}
pub fn client_state(&self) -> iota_state::ClientState {
self.ipc.state()
pub async fn client_state(&self) -> Option<iota_state::ClientState> {
self.ipc.read().await.as_ref().map(|ipc| ipc.state())
}
pub async fn attach_daemon(&self, ipc: Arc<IpcClient>) {
*self.ipc.write().await = Some(ipc);
}
pub async fn set_theme(&self, theme: ResolvedTheme) {
*self.theme.write().await = Arc::new(theme);
self.invalidate();
}
pub async fn theme_name(&self) -> ThemeName {
self.theme.read().await.name
}
pub fn is_shutdown(&self) -> bool {
self.shutdown.load(Ordering::Relaxed)
self.cancellation.is_cancelled()
}
pub fn request_shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
self.cancellation.cancel();
self.invalidate();
}
pub fn invalidate(&self) {
self.invalidation.notify_one();
}
pub fn failure(&self) -> Option<String> {
self.failure.lock().ok().and_then(|f| f.clone())
}
pub async fn handle_paste(&self, _text: String) {
self.invalidate();
}
pub async fn send_restart(&self) -> std::io::Result<()> {
self.ipc.send_command(0, "restart".into()).await
/// Lets bootstrap operations race their work against Ctrl+C without
/// blocking the input task or leaving the terminal in raw mode.
pub async fn wait_for_shutdown(&self) {
self.cancellation.cancelled().await;
}
pub fn cancellation_token(&self) -> CancellationToken {
self.cancellation.clone()
}
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
self.screen_stack.write().await.push(screen);
self.invalidate();
}
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
let mut stack = self.screen_stack.write().await;
stack.pop();
stack.clear();
stack.push(screen);
self.invalidate();
}
pub async fn set_root_screen(&self, screen: Box<dyn Screen>) {
let mut stack = self.screen_stack.write().await;
stack.clear();
stack.push(screen);
self.invalidate();
}
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
let result = {
@ -155,30 +273,41 @@ impl UI {
}
InteractionResult::OpenFutureScreen { screen: fut } => {
let ui = self.clone();
let screen = fut.await;
ui.set_screen(screen).await;
tokio::select! {
screen = fut => ui.set_screen(screen).await,
_ = ui.cancellation.cancelled() => return,
}
}
InteractionResult::CloseScreen => {
let mut stack = self.screen_stack.write().await;
stack.pop();
if stack.is_empty() {
if stack.is_empty() && self.shutdown_on_empty {
self.request_shutdown();
}
}
InteractionResult::Handled => {}
InteractionResult::Unhandled => {}
}
self.invalidate();
}
pub async fn render(&self) {
pub async fn render(&self) -> io::Result<()> {
let theme = self.theme.read().await.clone();
let context = RenderContext {
theme: theme.as_ref(),
};
// The renderer is the only task that takes the terminal lock. Screen
// mutations use the stack lock briefly before invalidating a frame.
if let Some(screen) = self.screen_stack.read().await.last() {
let mut terminal = self.terminal.lock().unwrap();
terminal
.draw(|f| {
screen.render(f, f.area());
})
.unwrap();
let mut terminal = self
.terminal
.lock()
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
terminal.draw(|f| {
screen.render(f, f.area(), &context);
})?;
}
Ok(())
}
}

View file

@ -3,13 +3,20 @@ use ratatui::prelude::*;
use ratatui::style::Style;
use ratatui::widgets::Borders;
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) {
frame
.buffer_mut()
.set_string(x, y, c.to_string(), Style::default());
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) {
frame.buffer_mut().set_string(x, y, c.to_string(), style);
}
pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) {
pub fn draw_block_joins(
frame: &mut Frame,
area: Rect,
borders: Borders,
joins: Borders,
style: Style,
) {
if area.width == 0 || area.height == 0 {
return;
}
let x0 = area.x;
let y0 = area.y;
let x1 = area.x + area.width - 1;
@ -22,7 +29,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┬',
(false, false) => '┌',
};
set_join_char(frame, x0, y0, top_left);
set_join_char(frame, x0, y0, top_left, style);
}
if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) {
@ -32,7 +39,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┬',
(false, false) => '┐',
};
set_join_char(frame, x1, y0, top_right);
set_join_char(frame, x1, y0, top_right, style);
}
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) {
@ -45,7 +52,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┴',
(false, false) => '└',
};
set_join_char(frame, x0, y1, bottom_left);
set_join_char(frame, x0, y1, bottom_left, style);
}
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) {
@ -58,6 +65,6 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┴',
(false, false) => '┘',
};
set_join_char(frame, x1, y1, bottom_right);
set_join_char(frame, x1, y1, bottom_right, style);
}
}

View file

@ -1,55 +1,22 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
use ratatui::layout::Rect;
use crate::{
controls::button::{
ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button,
},
theme::ResolvedTheme,
util::terms_focus::Focus,
};
use crate::util::terms_focus::Focus;
#[allow(mismatched_lifetime_syntaxes)]
pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
let box_char = if checked { "[x]" } else { "[ ]" };
let (box_style, text_style) = if active {
if allowed {
(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
}
} else {
(Style::default(), Style::default())
};
Line::from(vec![
Span::styled(box_char, box_style),
Span::raw(" "),
Span::styled(label, text_style),
])
}
pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
let p = Paragraph::new(Span::styled(label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
pub fn draw_buttons(
f: &mut ratatui::Frame,
frame: &mut ratatui::Frame,
area: Rect,
current_focus: Focus,
state: (bool, bool),
update_needed: bool,
downgrade_scenario: bool,
tos_or_privacy: bool,
theme: &ResolvedTheme,
) {
let cancel_text = if update_needed {
"[Q] Quit"
@ -69,107 +36,40 @@ pub fn draw_buttons(
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
}
let padding = 2;
let min_widths: Vec<u16> = buttons
let minimums = buttons
.iter()
.map(|(label, _)| label.len() as u16 + padding)
.collect();
let widths = compute_widths(area.width, &min_widths);
.map(|(label, _)| button_minimum_width(label))
.collect::<Vec<_>>();
let Some(widths) = horizontal_button_widths(area.width, &minimums) else {
return;
};
let mut x = area.x;
for ((label, focus), width) in buttons.iter().zip(widths) {
let chunk = Rect {
let button_area = Rect {
x,
y: area.y,
width,
height: area.height,
};
x += width;
x = x.saturating_add(width);
let is_focused = current_focus == *focus;
let style = match focus {
Focus::Cancel => {
if is_focused {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
}
}
Focus::Continue => {
if is_focused && state.0 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
Focus::ContinueAll => {
if is_focused && state.1 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.1 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
_ => Style::default().fg(Color::DarkGray),
let (intent, enabled) = match focus {
Focus::Cancel => (ButtonIntent::Cancel, true),
Focus::Continue => (ButtonIntent::Primary, state.0),
Focus::ContinueAll => (ButtonIntent::Primary, state.1),
_ => (ButtonIntent::Neutral, false),
};
draw_button(f, chunk, label, style);
render_button(
frame,
button_area,
ActionButton {
label,
intent,
focused: current_focus == *focus,
enabled,
},
theme,
);
}
}
pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
let mut widths = vec![0; min_widths.len()];
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
let mut remaining_width = area_width;
while !remaining.is_empty() {
let count = remaining.len() as u16;
let equal = remaining_width / count;
let mut clamped = Vec::new();
for &i in &remaining {
if min_widths[i] > equal {
widths[i] = min_widths[i];
remaining_width -= min_widths[i];
clamped.push(i);
}
}
if clamped.is_empty() {
let mut remainder = remaining_width % count;
for &i in &remaining {
widths[i] = equal
+ if remainder > 0 {
remainder -= 1;
1
} else {
0
};
}
break;
}
remaining.retain(|i| !clamped.contains(i));
}
widths
}

View file

@ -0,0 +1,15 @@
use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths};
#[test]
fn width_allocation_handles_exact_spare_and_insufficient_space() {
assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4]));
assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5]));
assert_eq!(horizontal_button_widths(6, &[3, 4]), None);
assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new()));
}
#[test]
fn minimum_width_uses_terminal_columns() {
assert_eq!(button_minimum_width("é"), 3);
assert_eq!(button_minimum_width(""), 4);
}

View file

@ -0,0 +1,71 @@
use iota_cli::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
theme::{ThemeName, resolve},
};
use ratatui::style::{Color, Modifier};
#[test]
fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() {
let theme = resolve(ThemeName::Ansi);
let line = render_choice_line(
"Terms",
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: false,
focused: true,
enabled: true,
},
&theme,
);
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
"[ ] Terms"
);
assert_eq!(line.spans[1].style.fg, Some(Color::Yellow));
assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD));
let disabled = render_choice_line(
"Terms",
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: false,
focused: true,
enabled: false,
},
&theme,
);
assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray));
assert_eq!(disabled.spans[3].style.fg, Some(Color::Red));
}
#[test]
fn colourless_themes_keep_state_and_focus_visible() {
for name in [ThemeName::Monospace, ThemeName::Binary] {
let theme = resolve(name);
let line = render_choice_line(
"Mode",
ChoiceKind::Radio,
ChoiceVisualState {
selected: true,
focused: true,
enabled: true,
},
&theme,
);
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
"> (x) Mode <"
);
assert!(
line.spans
.iter()
.all(|span| span.style.fg.is_none() && span.style.bg.is_none())
);
}
}

View file

@ -0,0 +1,109 @@
use iota_cli::controls::{
checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem},
navigation::DisabledFocusPolicy,
radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem},
};
fn checkbox(value: u8, enabled: bool) -> CheckboxItem<u8> {
CheckboxItem {
value,
label: value.to_string(),
description: None,
enabled,
disabled_reason: None,
}
}
fn radio(value: u8, enabled: bool) -> RadioItem<u8> {
RadioItem {
value,
label: value.to_string(),
description: None,
enabled,
disabled_reason: None,
}
}
#[test]
fn checkbox_selection_and_disabled_focus_are_independent() {
let mut group = CheckboxGroup::new(
vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)],
[1, 99],
)
.unwrap();
assert_eq!(
group.selected().iter().copied().collect::<Vec<_>>(),
vec![1]
);
assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1));
group.focus_next();
assert_eq!(group.focused_item().unwrap().value, 3);
group.set_focus_policy(DisabledFocusPolicy::Include);
group.focus_previous();
assert_eq!(group.focused_item().unwrap().value, 2);
assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2));
}
#[test]
fn checkbox_non_wrapping_navigation_stops_at_the_edge() {
let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap();
group.set_wrap_navigation(false);
group.focus_previous();
assert_eq!(group.focused_item().unwrap().value, 1);
}
#[test]
fn radio_validates_default_and_preserves_one_selection() {
assert!(matches!(
RadioGroup::new(Vec::<RadioItem<u8>>::new(), None, 1),
Err(RadioGroupError::Empty)
));
assert!(matches!(
RadioGroup::new(vec![radio(1, true)], None, 2),
Err(RadioGroupError::DefaultMissing)
));
assert!(matches!(
RadioGroup::new(vec![radio(1, false)], None, 1),
Err(RadioGroupError::DefaultDisabled)
));
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
assert_eq!(group.selected(), &2);
group.focus_next();
assert_eq!(group.selected(), &2);
assert_eq!(group.select_focused(), RadioChange::Unchanged(2));
group.focus_previous();
assert_eq!(
group.select_focused(),
RadioChange::Changed {
previous: 2,
selected: 1
}
);
assert_eq!(group.selected(), &1);
}
#[test]
fn groups_initially_focus_the_first_enabled_item() {
let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap();
assert_eq!(checkboxes.focused_item().unwrap().value, 2);
let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap();
assert_eq!(radios.focused_item().value, 2);
}
#[test]
fn disabling_a_selected_radio_obeys_the_configured_policy() {
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
group.set_enabled(&2, false).unwrap();
assert_eq!(group.selected(), &1);
group.set_enabled(&2, true).unwrap();
group.focus_next();
group.select_focused();
group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError);
assert_eq!(
group.set_enabled(&2, false),
Err(RadioGroupError::SelectedItemDisabled)
);
assert_eq!(group.selected(), &2);
}

View file

@ -0,0 +1,45 @@
use iota_cli::layout::fit::{
FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level,
};
use ratatui::layout::Rect;
#[test]
fn selects_fit_by_both_dimensions() {
let preferred = RequiredSize {
width: 80,
height: 20,
};
let compact = RequiredSize {
width: 50,
height: 12,
};
assert_eq!(
select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact),
FitLevel::Preferred
);
assert_eq!(
select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact),
FitLevel::Compact
);
assert_eq!(
select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact),
FitLevel::Fallback
);
}
#[test]
fn rectangle_helpers_do_not_underflow() {
let zero = Rect::new(4, 5, 0, 0);
assert_eq!(
centered_rect(
zero,
RequiredSize {
width: 10,
height: 10
}
),
zero
);
assert_eq!(reserve_vertical(zero, 1, 0), None);
assert_eq!(inset_checked(zero, 1, 1), None);
}