[WIP] Daemon & CLI
This commit is contained in:
parent
56aad3a023
commit
8b158108bb
100 changed files with 6519 additions and 1596 deletions
|
|
@ -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"
|
||||
|
|
|
|||
7
iota-cli/src/controls/action.rs
Normal file
7
iota-cli/src/controls/action.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ControlAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Select,
|
||||
Activate,
|
||||
}
|
||||
72
iota-cli/src/controls/button.rs
Normal file
72
iota-cli/src/controls/button.rs
Normal 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
|
||||
}
|
||||
135
iota-cli/src/controls/checkbox_group.rs
Normal file
135
iota-cli/src/controls/checkbox_group.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
42
iota-cli/src/controls/choice.rs
Normal file
42
iota-cli/src/controls/choice.rs
Normal 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),
|
||||
])
|
||||
}
|
||||
6
iota-cli/src/controls/mod.rs
Normal file
6
iota-cli/src/controls/mod.rs
Normal 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;
|
||||
6
iota-cli/src/controls/navigation.rs
Normal file
6
iota-cli/src/controls/navigation.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DisabledFocusPolicy {
|
||||
Include,
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
194
iota-cli/src/controls/radio_group.rs
Normal file
194
iota-cli/src/controls/radio_group.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(×tamp);
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
iota-cli/src/layout/fit.rs
Normal file
52
iota-cli/src/layout/fit.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
2
iota-cli/src/layout/mod.rs
Normal file
2
iota-cli/src/layout/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod fit;
|
||||
pub mod text_measure;
|
||||
10
iota-cli/src/layout/text_measure.rs
Normal file
10
iota-cli/src/layout/text_measure.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
6
iota-cli/src/render_context.rs
Normal file
6
iota-cli/src/render_context.rs
Normal 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,
|
||||
}
|
||||
287
iota-cli/src/screens/daemon_setup.rs
Normal file
287
iota-cli/src/screens/daemon_setup.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
163
iota-cli/src/theme/config.rs
Normal file
163
iota-cli/src/theme/config.rs
Normal 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
12
iota-cli/src/theme/mod.rs
Normal 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
149
iota-cli/src/theme/model.rs
Normal 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
|
||||
}
|
||||
}
|
||||
47
iota-cli/src/theme/name.rs
Normal file
47
iota-cli/src/theme/name.rs
Normal 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()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
301
iota-cli/src/theme/presets.rs
Normal file
301
iota-cli/src/theme/presets.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
15
iota-cli/tests/button_layout.rs
Normal file
15
iota-cli/tests/button_layout.rs
Normal 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);
|
||||
}
|
||||
71
iota-cli/tests/choice_rendering.rs
Normal file
71
iota-cli/tests/choice_rendering.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
109
iota-cli/tests/control_state.rs
Normal file
109
iota-cli/tests/control_state.rs
Normal 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);
|
||||
}
|
||||
45
iota-cli/tests/layout_fit.rs
Normal file
45
iota-cli/tests/layout_fit.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue