[Wip] CLI & Daemon

This commit is contained in:
Alex 2026-07-25 18:23:36 +02:00
commit 6a535099bb
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
44 changed files with 4417 additions and 303 deletions

View file

@ -3,7 +3,7 @@ use ratatui::{
Frame,
layout::{Alignment, Rect},
text::Span,
widgets::{Block, Borders, Paragraph},
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -39,9 +39,8 @@ pub fn render_button(
}
};
frame.render_widget(
Paragraph::new(Span::styled(button.label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL)),
Paragraph::new(Span::styled(if button.focused { format!(" {}", button.label) } else { button.label.to_owned() }, style))
.alignment(Alignment::Center),
area,
);
}

View file

@ -0,0 +1,62 @@
use crate::theme::ResolvedTheme;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
screens::screens::{AppAction, HitMap},
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::Span,
widgets::Paragraph,
};
/// Shared application bar. The brand cell is deliberately an action so it is
/// a reliable way home from every screen.
pub fn render_header(
frame: &mut Frame,
area: Rect,
title: &str,
theme: &ResolvedTheme,
hits: &mut HitMap,
focused_action: Option<usize>,
) {
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(area);
frame.render_widget(
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)),
cells[0],
);
hits.register(cells[0], AppAction::OpenMain);
for (index, (area, label, action)) in [
(cells[1], "Overview", AppAction::OpenOverview),
(cells[2], "Users", AppAction::OpenUsers),
(cells[3], "Settings", AppAction::OpenSettings),
(cells[4], "Quit", AppAction::Quit),
]
.into_iter()
.enumerate()
{
render_button(
frame,
area,
ActionButton {
label,
intent: if action == AppAction::Quit {
ButtonIntent::Destructive
} else {
ButtonIntent::Neutral
},
focused: focused_action == Some(index),
enabled: true,
},
theme,
);
hits.register(area, action);
}
}

View file

@ -1,6 +1,9 @@
pub mod action;
pub mod button;
pub mod checkbox_group;
pub mod header;
pub mod choice;
pub mod navigation;
pub mod panel;
pub mod radio_group;
pub mod scroll;

View file

@ -0,0 +1,23 @@
use crate::theme::{ChromeMode, ResolvedTheme};
use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}};
/// Draw a conventional outlined panel or a filled surface from the same call
/// site. Screens can migrate without embedding theme branches in layouts.
pub fn render_panel(frame: &mut Frame, area: Rect, title: &str, focused: bool, theme: &ResolvedTheme) -> Rect {
match theme.chrome {
ChromeMode::Bordered => {
let block = Block::default().title(title).borders(Borders::ALL).border_style(if focused { theme.borders.focused } else { theme.borders.normal });
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
ChromeMode::Surfaces => {
frame.render_widget(Block::default().style(if focused { theme.surfaces.panel_focused } else { theme.surfaces.panel }), area);
let header = Rect { x: area.x, y: area.y, width: area.width, height: area.height.min(1) };
frame.render_widget(Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), header);
// Surface panels use a single header row. A one-cell inset keeps
// compact controls such as the console usable at height three.
Rect { x: area.x.saturating_add(1), y: area.y.saturating_add(1), width: area.width.saturating_sub(2), height: area.height.saturating_sub(1) }
}
}
}

View file

@ -0,0 +1,20 @@
use ratatui::{Frame, layout::Rect, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}};
/// Reusable viewport policy for long, vertically stacked terminal content.
#[derive(Clone, Copy, Debug)]
pub struct ScrollOptions { pub show_scrollbar: bool, pub render_partial_components: bool }
impl Default for ScrollOptions { fn default() -> Self { Self { show_scrollbar: true, render_partial_components: true } } }
#[derive(Clone, Debug, Default)]
pub struct ScrollField { pub offset: u16, pub options: ScrollOptions }
impl ScrollField {
pub fn up(&mut self, amount: u16) { self.offset = self.offset.saturating_sub(amount); }
pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { self.offset = (self.offset.saturating_add(amount)).min(content_height.saturating_sub(viewport_height)); }
pub fn render(&self, frame: &mut Frame, area: Rect, content: Paragraph<'_>, content_height: u16) {
frame.render_widget(content.scroll((self.offset, 0)), area);
if self.options.show_scrollbar && content_height > area.height {
let mut state = ScrollbarState::new(content_height as usize).position(self.offset as usize);
frame.render_stateful_widget(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, &mut state);
}
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
@ -35,6 +35,10 @@ pub struct ConsoleCard {
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
message: Option<String>,
}
impl ConsoleCard {
@ -51,6 +55,10 @@ impl ConsoleCard {
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
history: Vec::new(),
history_index: None,
history_draft: String::new(),
message: None,
}
}
@ -151,6 +159,9 @@ impl ConsoleCard {
}
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(message) = &self.message {
return vec![Span::styled(message.clone(), theme.console.error)];
}
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
@ -163,11 +174,17 @@ impl ConsoleCard {
fn is_destructive(command: &str) -> bool {
matches!(
command.trim_start_matches('/').trim(),
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
"restart"
| "reload"
| "stop"
| "shutdown"
| "regenerate keys"
| "identity rotate"
) || command
.trim_start_matches('/')
.trim_start()
.starts_with("user remove ")
.split_once(" remove ")
.is_some_and(|(noun, _)| matches!(noun, "user" | "users"))
}
fn dispatch_command(&self, command: String) {
@ -214,6 +231,69 @@ impl ConsoleCard {
self.content.insert(idx, c);
self.cursor_position += 1;
}
pub fn handle_paste(&mut self, text: &str) {
let sanitized = text.replace(['\r', '\n'], " ");
let index = self.byte_index();
self.content.insert_str(index, &sanitized);
self.cursor_position += sanitized.chars().count();
self.message = None;
}
fn set_editor(&mut self, value: String) {
self.content = value;
self.cursor_position = self.content.chars().count();
}
fn history_previous(&mut self) {
if self.history.is_empty() {
return;
}
let index = match self.history_index {
None => {
self.history_draft = self.content.clone();
self.history.len() - 1
}
Some(index) => index.saturating_sub(1),
};
self.history_index = Some(index);
self.set_editor(self.history[index].clone());
self.message = None;
}
fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
self.history_index = Some(index + 1);
self.set_editor(self.history[index + 1].clone());
} else {
self.history_index = None;
let draft = std::mem::take(&mut self.history_draft);
self.set_editor(draft);
}
self.message = None;
}
fn complete(&mut self) -> bool {
let completions = iota_ipc::text_commands::completions(&self.content);
if completions.len() == 1 {
let leading_slash = self.content.starts_with('/');
self.set_editor(format!(
"{}{}",
if leading_slash { "/" } else { "" },
completions[0]
));
self.message = None;
true
} else if completions.len() > 1 {
self.message = Some(format!("Matches: {}", completions.join(", ")));
true
} else {
false
}
}
}
impl Element for ConsoleCard {
@ -226,6 +306,21 @@ impl Element for ConsoleCard {
}
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
let inner = crate::controls::panel::render_panel(
f,
r,
&self.title,
self.focused,
context.theme,
);
f.render_widget(
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
.style(context.theme.console.text),
inner,
);
return;
}
let block = Block::default()
.borders(self.borders)
.title(self.title.clone())
@ -304,6 +399,7 @@ impl InteractableElement for ConsoleCard {
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
self.content = restored;
self.cursor_position = self.content.chars().count();
self.message = Some("Command failed; restored for retry.".into());
}
if let Some(command) = self.pending_confirmation.take() {
@ -320,6 +416,22 @@ impl InteractableElement for ConsoleCard {
}
let command = self.content.clone();
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
self.message = Some(error);
return InteractionResult::Handled;
}
if command.trim_start_matches('/').trim() == "help" {
self.message = Some(format!(
"Commands: {}",
iota_ipc::text_commands::COMMANDS.join(", ")
));
return InteractionResult::Handled;
}
if self.history.last() != Some(&command) {
self.history.push(command.clone());
}
self.history_index = None;
self.history_draft.clear();
self.content.clear();
self.cursor_position = 0;
if Self::is_destructive(&command) {
@ -330,10 +442,12 @@ impl InteractableElement for ConsoleCard {
InteractionResult::Handled
}
KeyCode::Backspace => {
self.message = None;
self.delete_at_cursor();
InteractionResult::Handled
}
KeyCode::Delete => {
self.message = None;
let len = self.content.chars().count();
if self.cursor_position < len {
let start = self.byte_index();
@ -363,15 +477,36 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
InteractionResult::Handled
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
KeyCode::Up => {
self.history_previous();
InteractionResult::Handled
}
KeyCode::Down => {
self.history_next();
InteractionResult::Handled
}
KeyCode::Tab if !self.content.is_empty() => {
if self.complete() {
InteractionResult::Handled
} else {
InteractionResult::Unhandled
self.message = Some("No command completion.".into());
InteractionResult::Handled
}
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
self.message = None;
return InteractionResult::Handled;
}
}
InteractionResult::Unhandled
}
}
}

View file

@ -34,15 +34,15 @@ impl GRAPHS {
}
}
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> {
let state = match state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
match self {
GRAPHS::Ram => state.with_width(28).ram.clone(),
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
GRAPHS::Ping => state.with_width(28).ping.clone(),
GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(),
GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(),
GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(),
}
}
@ -69,6 +69,7 @@ pub struct GraphCard {
joins: Borders,
open: bool,
sample_width: usize,
}
impl GraphCard {
@ -82,12 +83,17 @@ impl GraphCard {
borders: Borders::ALL,
joins: Borders::NONE,
open: true,
sample_width: 28,
}
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
pub fn set_sample_width(&mut self, sample_width: usize) {
self.sample_width = sample_width.max(1);
}
}
impl Element for GraphCard {
fn as_any(&self) -> &dyn Any {
@ -100,7 +106,24 @@ impl Element for GraphCard {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if self.open {
let graph = self.graph_type.get_graph(&self.state);
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
if graph.is_empty() {
let block = Block::default()
.title(format!(" {} ", self.title))
.borders(self.borders)
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
context.theme.graphs.border
});
f.render_widget(
ratatui::widgets::Paragraph::new("No metric samples yet.")
.style(context.theme.text.muted)
.block(block),
r,
);
return;
}
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);
@ -117,16 +140,19 @@ impl Element for GraphCard {
GRAPHS::Ping => (max_y * 1.2).max(10.0),
};
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
let title = format!("{}: {}{} {}min/{}max", self.title, graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, unit, min_y as i64, max_y as i64);
let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r };
let block = Block::default()
.title(format!(
.title(if surface { String::new() } else { format!(
"{}:─{}{}─{}min/{}max",
self.title,
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
unit,
min_y as i64,
max_y as i64,
))
.borders(self.borders)
) })
.borders(if surface { Borders::NONE } else { self.borders })
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
@ -148,7 +174,7 @@ impl Element for GraphCard {
});
}
});
f.render_widget(canvas, r);
f.render_widget(canvas, plot_area);
} else {
let block = Block::default()
.title("")
@ -160,7 +186,7 @@ impl Element for GraphCard {
});
f.render_widget(block, r);
}
draw_block_joins(
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
f,
r,
self.borders,
@ -170,7 +196,7 @@ impl Element for GraphCard {
} else {
context.theme.borders.normal
},
);
); }
}
}

View file

@ -1,7 +1,7 @@
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
@ -10,7 +10,10 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy)]
@ -55,8 +58,11 @@ pub struct LogCard {
focused: bool,
selected: bool,
scroll_offset: usize,
last_total_lines: usize,
last_visible_height: usize,
last_total_lines: AtomicUsize,
last_visible_height: AtomicUsize,
last_width: AtomicUsize,
filter: String,
filtering: bool,
pub borders: Borders,
pub joins: Borders,
}
@ -68,8 +74,11 @@ impl LogCard {
focused: false,
selected: false,
scroll_offset: 0,
last_total_lines: 0,
last_visible_height: 10,
last_total_lines: AtomicUsize::new(0),
last_visible_height: AtomicUsize::new(1),
last_width: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
borders: Borders::ALL,
joins: Borders::NONE,
}
@ -80,9 +89,15 @@ impl LogCard {
Ok(state) => state,
Err(_) => return Vec::new(),
};
let needle = self.filter.to_ascii_lowercase();
state
.get_logs()
.iter()
.filter(|entry| {
needle.is_empty()
|| entry.sender.to_ascii_lowercase().contains(&needle)
|| entry.message.to_ascii_lowercase().contains(&needle)
})
.map(|e| UiLogEntry {
timestamp_ms: e.timestamp_ms,
sender: e.sender.clone(),
@ -214,11 +229,13 @@ impl LogCard {
}
fn get_title_hints(&self) -> (bool, bool) {
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
if total_lines == 0 || total_lines <= visible_height {
return (false, false);
}
let max_offset = self.last_total_lines - self.last_visible_height;
let max_offset = total_lines - visible_height;
let can_scroll_up = self.scroll_offset < max_offset;
let can_scroll_down = self.scroll_offset > 0;
@ -226,6 +243,12 @@ impl LogCard {
}
fn build_title(&self) -> String {
if self.filtering {
return format!("Logs filter: {}_", self.filter);
}
if !self.filter.is_empty() {
return format!("Logs [filter: {}]", self.filter);
}
if !self.focused {
return "Logs".to_string();
}
@ -250,7 +273,8 @@ impl LogCard {
fn scroll_up(&mut self) {
let max_offset = self
.last_total_lines
.saturating_sub(self.last_visible_height);
.load(Ordering::Relaxed)
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
}
@ -297,17 +321,14 @@ impl Element for LogCard {
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 {
context.theme.logs.focused_border
} else {
context.theme.logs.border
});
let inner_area = block.inner(area);
f.render_widget(block, area);
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, context.theme)
} else {
let block = Block::default().title(self.build_title()).borders(self.borders).border_style(if self.focused { context.theme.logs.focused_border } else { context.theme.logs.border });
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if inner_area.width == 0 || inner_area.height == 0 {
draw_block_joins(
@ -327,6 +348,11 @@ impl Element for LogCard {
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
let total_lines = all_lines.len();
let visible_height = inner_area.height as usize;
self.last_width
.store(inner_area.width as usize, Ordering::Relaxed);
self.last_total_lines.store(total_lines, Ordering::Relaxed);
self.last_visible_height
.store(visible_height, Ordering::Relaxed);
let (start, end) = self.calculate_view_window(total_lines, visible_height);
let visible_lines = &all_lines[start..end];
@ -337,6 +363,7 @@ impl Element for LogCard {
let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line);
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { "" } else { prefix };
if !prefix.is_empty() {
spans.push(Span::styled(
@ -377,7 +404,7 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins(
f,
area,
self.borders,
@ -387,7 +414,7 @@ impl Element for LogCard {
} else {
context.theme.borders.normal
},
);
); }
}
}
@ -435,14 +462,44 @@ impl InteractableElement for LogCard {
}
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
if self.filtering {
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
}
_ => {}
}
self.scroll_offset = 0;
return InteractionResult::Handled;
}
let entries = self.get_logs();
let estimated_width = 80usize;
let all_lines = self.build_all_lines(entries, estimated_width);
let width = self.last_width.load(Ordering::Relaxed).max(1);
let all_lines = self.build_all_lines(entries, width);
self.last_total_lines = all_lines.len();
let visible_height = self.last_visible_height.max(1);
self.last_total_lines
.store(all_lines.len(), Ordering::Relaxed);
let total_lines = all_lines.len();
let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1);
match key.code {
KeyCode::Char('/') => {
self.filtering = true;
self.filter.clear();
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.selected = !self.selected;
InteractionResult::Handled
@ -476,8 +533,8 @@ impl InteractableElement for LogCard {
InteractionResult::Handled
}
KeyCode::Home => {
if self.last_total_lines > visible_height {
self.scroll_offset = self.last_total_lines - visible_height;
if total_lines > visible_height {
self.scroll_offset = total_lines - visible_height;
}
InteractionResult::Handled
}

View file

@ -1,4 +1,4 @@
use crate::ui::UI;
use crate::{screens::screens::UiEvent, ui::UI};
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::time::Duration;
@ -27,8 +27,9 @@ pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
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(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
Some(_) => {},
None => break,
},
@ -55,6 +56,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
{
ui.request_shutdown();
} else {
ui.handle_input(key).await;
ui.handle_event(UiEvent::Key(key)).await;
}
}

View file

@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use crate::screens::screens::Screen;
use crate::screens::screens::{Screen, UiEvent};
#[allow(unused)]
pub enum InteractionResult {
@ -13,6 +13,9 @@ pub enum InteractionResult {
OpenFutureScreen {
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
},
AppTask {
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
},
Handled,
Unhandled,
}
@ -22,6 +25,7 @@ impl Debug for InteractionResult {
match self {
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
InteractionResult::Handled => write!(f, "Handled"),
InteractionResult::Unhandled => write!(f, "Unhandled"),
@ -36,6 +40,9 @@ impl PartialEq for InteractionResult {
InteractionResult::OpenScreen { screen: _ },
InteractionResult::OpenScreen { screen: _ },
) => true,
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
true
}
(
InteractionResult::OpenFutureScreen { screen: _ },
InteractionResult::OpenFutureScreen { screen: _ },

View file

@ -1,6 +1,6 @@
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
@ -443,6 +443,92 @@ impl IpcClient {
});
}
fn format_payload(payload: &ResponsePayload) -> String {
match payload {
ResponsePayload::Status(status) => {
let mut msg = format!("Phase: {}", status.phase);
if !status.tasks.is_empty() {
msg.push_str(&format!(", Tasks: {}", status.tasks.join(", ")));
}
if let Some(reason) = &status.degraded_reason {
msg.push_str(&format!(", Degraded: {reason}"));
}
msg
}
ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() {
"No active tasks.".into()
} else {
tasks.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
}
}
ResponsePayload::Users(users) => {
if users.is_empty() {
"No users.".into()
} else {
users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::<Vec<_>>().join("\n")
}
}
ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id)
}
ResponsePayload::UserRemoved { user_id } => {
format!("Removed user {}", user_id)
}
ResponsePayload::Acknowledged { message } => message.clone(),
ResponsePayload::DaemonStatus(status) => status.formatted.clone(),
ResponsePayload::Config(config) => config.yaml.clone(),
ResponsePayload::OmikronStatus(status) => {
let mut msg = format!("Connected: {}", status.connected);
if let Some(id) = status.iota_id {
msg.push_str(&format!("\nIota ID: {}", id));
}
msg
}
ResponsePayload::Components(components) => {
if components.is_empty() {
"No component health data available.".into()
} else {
components.iter().map(|c| {
let status_str = match c.status {
iota_ipc::HealthStatus::Healthy => "healthy",
iota_ipc::HealthStatus::Degraded => "degraded",
iota_ipc::HealthStatus::Failed => "failed",
};
format!("{:?}: {}", c.id, status_str)
}).collect::<Vec<_>>().join("\n")
}
}
ResponsePayload::UserDetail(user) => {
let mut msg = format!("User: {} ({})", user.username, user.user_id);
if let Some(ref name) = user.display_name {
msg.push_str(&format!("\nDisplay Name: {name}"));
}
msg.push_str(&format!("\nCreated At: {}", user.created_at));
if !user.trusted_apps.is_empty() {
msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", ")));
}
msg
}
ResponsePayload::LogEntries(logs) => {
logs.entries.iter().map(|e| {
let level = if e.is_error { "ERR" } else { "INF" };
format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message)
}).collect::<Vec<_>>().join("\n")
}
ResponsePayload::UpdateStatus(status) => {
if status.available { "Update available.".into() } else { "Up to date.".into() }
}
ResponsePayload::Communities(communities) => {
if communities.is_empty() {
"No communities.".into()
} else {
communities.iter().map(|c| format!("{} ({})", c.title, c.name)).collect::<Vec<_>>().join("\n")
}
}
}
}
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
match code {
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
@ -496,29 +582,9 @@ impl IpcClient {
}
/// Parse a legacy console command string into a typed request.
/// Delegates to the shared parser in iota-ipc.
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["tasks"] => Some(LocalRequest::ListTasks),
["user", "add", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user", "remove", user_id_str] => {
let user_id = user_id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id })
}
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
}),
_ => None,
}
iota_ipc::text_commands::parse(line)
}
/// Legacy command interface: parse text command, send as typed request.
@ -559,7 +625,7 @@ impl IpcClient {
Ok(result) => {
let mut state = self.state.app.lock().await;
let message = match &result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Ok(payload) => Self::format_payload(payload),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
@ -595,8 +661,8 @@ impl IpcClient {
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components"
.into()
} else {
format!("Unknown command: {}", line)
@ -695,7 +761,7 @@ impl IpcClient {
} else {
let mut state = self.state.app.lock().await;
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Ok(payload) => Self::format_payload(payload),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {

View file

@ -8,9 +8,13 @@ pub mod screens {
pub mod daemon_setup;
pub mod main_screen;
pub mod md_viewer;
pub mod metrics;
pub mod overview;
pub mod screens;
pub mod settings;
pub mod terms_checker;
pub mod terms_updater;
pub mod users;
}
pub mod util {
pub mod borders;

View file

@ -6,9 +6,9 @@ use crate::{
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
screens::screens::{HitMap, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@ -29,7 +29,7 @@ impl Screen for DaemonStartingScreen {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
@ -51,7 +51,7 @@ impl Screen for DaemonStartingScreen {
popup,
);
}
fn handle_input(&mut self, _: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
InteractionResult::Handled
}
}
@ -191,7 +191,7 @@ impl Screen for DaemonSetupScreen {
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
@ -266,7 +266,8 @@ impl Screen for DaemonSetupScreen {
context.theme,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; };
match event.code {
KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit);

View file

@ -1,4 +1,5 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
elements::{
console_card::ConsoleCard,
elements::{InteractableElement, JoinableElement},
@ -8,19 +9,28 @@ use crate::{
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{NavDirection, Screen},
screens::{
overview::OverviewScreen,
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Layout, Margin, Rect},
widgets::{Block, Borders},
layout::{Constraint, Layout, Rect},
widgets::Borders,
};
use tokio::sync::watch;
use std::{any::Any, sync::Arc};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU16, Ordering},
},
};
pub struct MainScreen {
elements: Vec<Box<dyn InteractableElement>>,
@ -29,9 +39,38 @@ pub struct MainScreen {
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
layout_width: AtomicU16,
}
impl MainScreen {
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_status_rx.clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status_rx.clone()
}
fn status_summary(&self) -> String {
let connection = match self.connection_status_rx.borrow().clone() {
IpcConnectionState::Connected => "[OK] Connected".to_owned(),
IpcConnectionState::Connecting => "[..] Connecting".to_owned(),
IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
"[FAIL] Failed".to_owned()
}
IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(),
};
let daemon = self.daemon_status_rx.borrow().clone();
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let ready = daemon
.startup_phase
.map(|phase| format!(" {:?}", phase))
.unwrap_or_default();
format!("IOTA{version} {connection}{ready}")
}
pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
@ -80,6 +119,7 @@ impl MainScreen {
graphs_open,
connection_status_rx,
daemon_status_rx,
layout_width: AtomicU16::new(0),
};
screen.focus_current();
screen
@ -172,6 +212,11 @@ impl MainScreen {
let mut seen: Vec<Option<usize>> = Vec::new();
for (y, row) in self.nav_grid.iter().enumerate() {
for (x, elem_opt) in row.iter().enumerate() {
if x == 1
&& (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70)
{
continue;
}
if elem_opt.is_some() && !seen.contains(elem_opt) {
seen.push(*elem_opt);
positions.push((y, x));
@ -209,7 +254,8 @@ impl Screen for MainScreen {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
self.layout_width.store(rect.width, Ordering::Relaxed);
// 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();
@ -242,25 +288,15 @@ impl Screen for MainScreen {
} else {
format!(" v{}", daemon.version)
};
let main_block = Block::default()
.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 _ = (status_text, readiness, health, version);
f.render_widget(
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
rect,
);
let inner = rect;
let inner = rect.inner(Margin {
vertical: 1,
horizontal: 1,
});
let graphs_width = if self.graphs_open && inner.width >= 70 {
30
} else {
2
};
let metrics_visible = self.graphs_open && inner.width >= 70;
let graphs_width = if metrics_visible { 30 } else { 0 };
let main_width = inner.width.saturating_sub(graphs_width);
let horizontal_chunks = Layout::default()
@ -273,9 +309,39 @@ impl Screen for MainScreen {
let left_area = horizontal_chunks[0];
let right_area = horizontal_chunks[1];
hits.register(left_area, AppAction::FocusLogs);
if metrics_visible {
hits.register(right_area, AppAction::FocusMetrics);
}
if inner.width >= 70 {
let metrics_button = Rect {
x: right_area.x,
y: right_area.y,
width: right_area.width,
height: 1,
};
render_button(
f,
metrics_button,
ActionButton {
label: if self.graphs_open {
"Hide metrics"
} else {
"Show metrics"
},
intent: ButtonIntent::Neutral,
focused: false,
enabled: true,
},
context.theme,
);
hits.register(metrics_button, AppAction::ToggleMetrics);
}
let left_rows =
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
hits.register(left_rows[1], AppAction::FocusConsole);
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[0], context);
@ -291,7 +357,7 @@ impl Screen for MainScreen {
.filter(|el| el.as_any().is::<GraphCard>())
.collect();
if !graph_elements.is_empty() {
if metrics_visible && !graph_elements.is_empty() {
let graph_chunks = Layout::vertical(
graph_elements
.iter()
@ -306,7 +372,40 @@ impl Screen for MainScreen {
}
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
if let UiEvent::Paste(text) = &event {
if self.selected_coords == (2, 0) {
if let Some(console) = self
.elements
.get_mut(1)
.and_then(|element| element.as_any_mut().downcast_mut::<ConsoleCard>())
{
console.handle_paste(text);
return InteractionResult::Handled;
}
}
return InteractionResult::Unhandled;
}
if let UiEvent::Resize(width, _) = &event {
self.layout_width.store(*width, Ordering::Relaxed);
if *width < 70 && self.selected_coords.1 == 1 {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.focus_current();
}
return InteractionResult::Handled;
}
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
// A focused console consumes text and cursor keys before dashboard
// shortcuts; commands such as `users` must remain typeable.
if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
{
if let Some(console) = self.elements.get_mut(1) {
return console.interact(event);
}
}
match event.code {
KeyCode::Tab => {
self.navigate_focus(true);
@ -316,6 +415,27 @@ impl Screen for MainScreen {
self.navigate_focus(false);
return InteractionResult::Handled;
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let conn_rx = self.connection_status_rx.clone();
let daemon_rx = self.daemon_status_rx.clone();
return InteractionResult::OpenScreen {
screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)),
};
}
KeyCode::Char('u') | KeyCode::Char('U') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenUsers)
}),
};
}
KeyCode::Char('m') | KeyCode::Char('M') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics)
}),
};
}
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
@ -347,4 +467,70 @@ impl Screen for MainScreen {
InteractionResult::Handled
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::ToggleMetrics => {
self.graphs_open = !self.graphs_open;
for element in &mut self.elements {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
InteractionResult::Handled
}
AppAction::OpenOverview => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o'))))
}
AppAction::OpenUsers => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u'))))
}
AppAction::FocusLogs => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusConsole => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (2, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusMetrics => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 1);
self.focus_current();
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn app_title(&self) -> String {
self.status_summary()
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) {
vec![
KeyHint { keys: "Enter", action: "Send" },
KeyHint { keys: "Up/Down", action: "History" },
KeyHint { keys: "Tab", action: "Complete" },
KeyHint { keys: "F6", action: "Header" },
]
} else if self.selected_coords == (0, 0) {
vec![
KeyHint { keys: "J/K", action: "Scroll logs" },
KeyHint { keys: "Enter", action: "Lock scroll" },
KeyHint { keys: "/", action: "Filter" },
KeyHint { keys: "M", action: "Metrics screen" },
KeyHint { keys: "Tab", action: "Next panel" },
KeyHint { keys: "F6", action: "Header" },
]
} else {
vec![
KeyHint { keys: "Enter", action: "Toggle metrics" },
KeyHint { keys: "Tab", action: "Next panel" },
KeyHint { keys: "F6", action: "Header" },
]
}
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
DefaultTerminal,
prelude::*,
@ -10,7 +10,7 @@ use std::{any::Any, time::Duration};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
screens::screens::{HitMap, Screen, UiEvent},
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
@ -29,11 +29,12 @@ impl Screen for FileViewer {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
self.draw(f, rect, context.theme);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; };
match event.code {
KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen;

View file

@ -0,0 +1,131 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
widgets::{Block, Borders, Paragraph},
};
use crate::{
elements::{
elements::Element,
graph_card::{GRAPHS, GraphCard},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
ui::UI,
};
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
pub struct MetricsScreen {
graphs: Vec<GraphCard>,
range_index: usize,
}
impl MetricsScreen {
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
let state = ui.client_state().await?;
let mut screen = Self {
graphs: vec![
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()),
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()),
GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()),
],
range_index: 0,
};
screen.apply_range();
Some(screen)
}
fn apply_range(&mut self) {
let width = RANGES[self.range_index].0;
for graph in &mut self.graphs {
graph.set_sample_width(width);
}
}
fn change_range(&mut self, delta: isize) {
self.range_index =
(self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize;
self.apply_range();
}
}
impl Screen for MetricsScreen {
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<'_>, hits: &mut HitMap) {
let block = Block::default()
.title(" Metrics ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!(
"Range: {} ({} samples) Left/Right to change",
RANGES[self.range_index].1,
RANGES[self.range_index].0
))
.style(context.theme.text.heading),
rows[0],
);
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
graph.render(frame, *graph_area, context);
}
hits.register(rows[0], AppAction::OpenMetrics);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
self.change_range(-1);
InteractionResult::Handled
}
KeyCode::Right => {
self.change_range(1);
InteractionResult::Handled
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Left/Right",
action: "Range",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -0,0 +1,282 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::sync::watch;
pub struct OverviewScreen {
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
_focus: Focus,
scroll_offset: usize,
content_height: AtomicUsize,
viewport_height: AtomicUsize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Back,
}
impl OverviewScreen {
pub fn new(
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
) -> Self {
Self {
connection_rx,
daemon_rx,
_focus: Focus::Back,
scroll_offset: 0,
content_height: AtomicUsize::new(0),
viewport_height: AtomicUsize::new(1),
}
}
fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Line<'static>> {
let conn = self.connection_rx.borrow().clone();
let daemon = self.daemon_rx.borrow().clone();
let mut lines = Vec::new();
lines.push(Line::from(Span::styled(
"Connection",
theme.text.heading,
)));
lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Daemon",
theme.text.heading,
)));
lines.push(Line::from(format!(
" Version: {}",
version_or_unknown(&daemon.version)
)));
lines.push(Line::from(format!(
" Instance: {}",
truncate_id(&daemon.instance_id)
)));
let phase = daemon
.startup_phase
.map(|p| format!("{:?}", p))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Phase: {}", phase)));
let lifecycle = daemon
.lifecycle
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Lifecycle: {}", lifecycle)));
let health = match daemon.health {
iota_ipc::HealthStatus::Healthy => "[OK] Healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] Degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] Failed",
};
lines.push(Line::from(format!(" Health: {health}")));
if let Some(ref reason) = daemon.degraded_reason {
lines.push(Line::from(Span::styled(
format!(" Degraded: {reason}"),
theme.status.warning,
)));
}
let mode = daemon
.deployment_mode
.map(|m| format!("{:?}", m))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Deployment: {mode}")));
let supervisor = daemon
.supervisor
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Supervisor: {supervisor}")));
if !daemon.components.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Components",
theme.text.heading,
)));
for (id, health) in &daemon.components {
let status_str = match health.status {
iota_ipc::HealthStatus::Healthy => "[OK] healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] failed",
};
let suffix = health
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Esc or B to return to the dashboard",
theme.text.muted,
)));
lines
}
}
fn connection_label(conn: &IpcConnectionState) -> String {
match conn {
IpcConnectionState::Connected => "Connected".into(),
IpcConnectionState::Connecting => "Connecting...".into(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {attempt})...")
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {message}")
}
IpcConnectionState::Failed { message } => format!("Failed: {message}"),
IpcConnectionState::Disconnected => "Disconnected".into(),
}
}
fn version_or_unknown(v: &str) -> String {
if v.is_empty() {
"Unknown".into()
} else {
v.into()
}
}
fn truncate_id(id: &str) -> String {
if id.len() > 8 {
format!("{}", &id[..8])
} else {
id.into()
}
}
impl Screen for OverviewScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let block = Block::default()
.title(" Overview ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) } else { let inner = block.inner(rect); f.render_widget(block, rect); inner };
let rows = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Min(1),
ratatui::layout::Constraint::Length(1),
])
.split(inner);
let lines = self.build_lines(context.theme);
self.content_height.store(lines.len(), Ordering::Relaxed);
self.viewport_height
.store(rows[0].height as usize, Ordering::Relaxed);
let par = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((self.scroll_offset as u16, 0));
f.render_widget(par, rows[0]);
render_button(
f,
rows[1],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self._focus == Focus::Back,
enabled: true,
},
context.theme,
);
_hits.register(rows[1], AppAction::Back);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Char('j') => {
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
InteractionResult::Handled
}
KeyCode::PageDown => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(page);
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
InteractionResult::Handled
}
KeyCode::PageUp => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.scroll_offset = self.scroll_offset.saturating_sub(page);
InteractionResult::Handled
}
KeyCode::Home => {
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::End => {
self.scroll_offset = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
if action == AppAction::Back {
InteractionResult::CloseScreen
} else {
InteractionResult::Unhandled
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint { keys: "Up/Down", action: "Scroll" },
KeyHint { keys: "PgUp/PgDn", action: "Page" },
KeyHint { keys: "Esc/B", action: "Back" },
KeyHint { keys: "F6", action: "Header" },
]
}
}

View file

@ -1,10 +1,103 @@
use std::any::Any;
use crossterm::event::KeyEvent;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{Frame, layout::Rect};
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
/// All terminal input that can affect the UI. Keeping this as one type makes
/// it impossible for screens to accidentally ignore a newly supported event.
#[derive(Debug, Clone)]
pub enum UiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Resize(u16, u16),
App(AppEvent),
}
/// Completion of background UI work. Keeping it in the regular event stream
/// gives screens an explicit success/failure path instead of detached tasks.
#[derive(Debug, Clone)]
pub enum AppEvent {
OpenUsers,
OpenMetrics,
ApplyTheme {
theme: crate::theme::ThemeName,
persist: bool,
},
SaveSettings {
theme: crate::theme::ThemeName,
color: crate::theme::TerminalPolicy,
unicode: crate::theme::TerminalPolicy,
},
ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>),
UserRemoved {
user_id: i64,
result: Result<(), String>,
},
RegenerateKeysRequested,
KeysRegenerated(Result<(), String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppAction {
OpenOverview,
OpenUsers,
OpenSettings,
OpenMetrics,
ToggleMetrics,
AddUser,
RemoveUser,
Back,
Quit,
FocusLogs,
FocusConsole,
FocusMetrics,
OpenMain,
SelectUser(usize),
ConfirmDialog,
CancelDialog,
RegenerateKeys,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyHint {
pub keys: &'static str,
pub action: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitRegion {
pub area: Rect,
pub action: AppAction,
}
#[derive(Debug, Default, Clone)]
pub struct HitMap {
regions: Vec<HitRegion>,
}
impl HitMap {
pub fn register(&mut self, area: Rect, action: AppAction) {
self.regions.push(HitRegion { area, action });
}
pub fn action_at(&self, column: u16, row: u16) -> Option<AppAction> {
self.regions
.iter()
.rev()
.find(|region| {
column >= region.area.x
&& column < region.area.x.saturating_add(region.area.width)
&& row >= region.area.y
&& row < region.area.y.saturating_add(region.area.height)
})
.map(|region| region.action)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
Up,
@ -20,6 +113,32 @@ 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, context: &RenderContext<'_>);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap);
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
InteractionResult::Unhandled
}
fn app_title(&self) -> String {
"IOTA".to_owned()
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "Enter",
action: "Activate",
},
KeyHint {
keys: "Esc",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -0,0 +1,390 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
theme::{TerminalPolicy, ThemeName, UiConfig},
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Theme,
RegenerateKeys,
Back,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dialog {
ConfirmRegenerateKeys,
}
pub struct SettingsScreen {
selected: usize,
saved: ThemeName,
message: String,
color: TerminalPolicy,
unicode: TerminalPolicy,
focus: Focus,
dialog: Option<Dialog>,
pending: bool,
}
impl SettingsScreen {
pub fn new(current: ThemeName) -> Self {
let selected = ThemeName::ALL
.iter()
.position(|theme| *theme == current)
.unwrap_or(0);
Self {
selected,
saved: current,
message: "Left/Right previews. Enter saves.".into(),
color: UiConfig::load().map(|config| config.color).unwrap_or_default(),
unicode: UiConfig::load().map(|config| config.unicode).unwrap_or_default(),
focus: Focus::Theme,
dialog: None,
pending: false,
}
}
fn selected_theme(&self) -> ThemeName {
ThemeName::ALL[self.selected]
}
fn apply(&self, persist: bool) -> InteractionResult {
let theme = self.selected_theme();
InteractionResult::AppTask {
task: Box::pin(async move {
UiEvent::App(AppEvent::ApplyTheme { theme, persist })
}),
}
}
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
match policy { TerminalPolicy::Auto => TerminalPolicy::Always, TerminalPolicy::Always => TerminalPolicy::Never, TerminalPolicy::Never => TerminalPolicy::Auto }
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::Back,
Focus::Back => Focus::Theme,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::Back,
Focus::Back => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::Theme,
};
}
fn activate(&mut self) -> InteractionResult {
if self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::ConfirmRegenerateKeys => {
self.pending = true;
self.message = "Regenerating keys…".into();
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(AppEvent::RegenerateKeysRequested)
}),
};
}
}
}
match self.focus {
Focus::Theme => {
self.message = "Saving theme…".into();
let theme = self.selected_theme();
let color = self.color;
let unicode = self.unicode;
InteractionResult::AppTask { task: Box::pin(async move { UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) }) }
}
Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
InteractionResult::Handled
}
Focus::Back => InteractionResult::CloseScreen,
}
}
}
impl Screen for SettingsScreen {
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<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Settings ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows =
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget(
Paragraph::new(format!(
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
self.selected_theme(),
if self.selected_theme() == self.saved {
" [saved]"
} else {
" [preview]"
}, self.color, self.unicode
))
.style(context.theme.text.heading),
rows[0],
);
let bottom_rows =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]);
let lines = vec![
Line::from(Span::styled(&self.message, context.theme.text.normal)),
Line::from(""),
Line::from("Preview"),
Line::from("[OK] Healthy"),
Line::from("[WARN] Degraded"),
Line::from("[FAIL] Failed"),
Line::from("> Focused action <"),
];
frame.render_widget(
Paragraph::new(lines).style(context.theme.text.normal),
bottom_rows[0],
);
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(bottom_rows[1]);
render_button(
frame,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back && self.dialog.is_none(),
enabled: true,
},
context.theme,
);
hits.register(buttons_area[0], AppAction::Back);
render_button(
frame,
buttons_area[1],
ActionButton {
label: "Regenerate Keys",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
enabled: !self.pending,
},
context.theme,
);
hits.register(buttons_area[1], AppAction::RegenerateKeys);
if self.dialog.is_some() {
frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area);
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
frame.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
frame.render_widget(
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
frame,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
frame,
dialog_buttons[1],
ActionButton {
label: "Regenerate",
intent: ButtonIntent::Destructive,
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::ThemeSaved(result)) => {
match result {
Ok(()) => {
self.saved = self.selected_theme();
self.message = "Theme saved to ui.yaml.".into();
}
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
self.pending = false;
self.dialog = None;
match result {
Ok(()) => self.message = "Keys regenerated successfully.".into(),
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
event => event,
};
if self.dialog.is_some() {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
return match key.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
if self.focus == Focus::Theme {
self.selected = self.selected.saturating_sub(1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Right => {
if self.focus == Focus::Theme {
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); InteractionResult::Handled }
KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled }
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::RegenerateKeys => {
self.focus = Focus::RegenerateKeys;
self.activate()
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Left/Right",
action: "Preview theme",
},
KeyHint {
keys: "Enter",
action: "Save/Activate",
},
KeyHint { keys: "Tab", action: "Move focus" },
KeyHint { keys: "C/U", action: "Color/Unicode" },
KeyHint {
keys: "Esc/B",
action: "Back",
},
]
}
}
}

View file

@ -2,10 +2,10 @@ use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use iota_terms::{TermsType, get_link, get_terms};
use ratatui::{
Frame,
@ -53,7 +53,7 @@ impl Screen for TermsCheckerScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -270,7 +270,8 @@ impl Screen for TermsCheckerScreen {
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; };
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if self.eula {

View file

@ -3,11 +3,11 @@ use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
screens::{md_viewer::FileViewer, screens::{HitMap, Screen, UiEvent}},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
use ratatui::{
Frame,
@ -121,7 +121,7 @@ impl Screen for TermsUpdaterScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let checkbox = |label, selected, focused, enabled| {
render_choice_line(
label,
@ -593,7 +593,8 @@ impl Screen for TermsUpdaterScreen {
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; };
let mut possible_states = Vec::new();
if self.eula_needed {

View file

@ -0,0 +1,705 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
#[derive(Clone, Debug)]
pub struct UserEntry {
pub user_id: i64,
pub username: String,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
AddButton,
RemoveButton,
Back,
}
#[derive(Clone, Debug)]
enum Dialog {
Add { username: String },
Remove { user: UserEntry },
}
pub struct UsersScreen {
users: Vec<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
Self {
users,
focused_index: 0,
focus: Focus::List,
ipc,
message: None,
dialog: None,
pending_dialog: None,
loading: false,
pending: false,
scroll_offset: 0,
viewport_height: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
}
}
pub fn loading(ipc: Arc<IpcClient>) -> Self {
let mut screen = Self::new(ipc, Vec::new());
screen.loading = true;
screen.message = Some("Loading users…".into());
screen
}
fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let visible_indices = self.filtered_indices();
let title = if self.filter.is_empty() {
format!("Users ({})", self.users.len())
} else {
format!("Users ({}/{}) filter: {}", visible_indices.len(), self.users.len(), self.filter)
};
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(
f,
area,
&title,
self.focus == Focus::List,
context.theme,
)
} else {
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if self.loading {
f.render_widget(Paragraph::new("Loading users…"), inner);
return;
}
if visible_indices.is_empty() {
let par = Paragraph::new(if self.users.is_empty() {
"No users found."
} else {
"No users match the filter."
});
f.render_widget(par, inner);
return;
}
let mut lines = Vec::new();
self.viewport_height.store(inner.height as usize, Ordering::Relaxed);
let labels: Vec<(usize, String)> = visible_indices
.iter()
.skip(self.scroll_offset)
.take(inner.height as usize)
.map(|user_index| {
let user = &self.users[*user_index];
(*user_index, format!("{:>6} {}", user.user_id, user.username))
})
.collect();
for (user_index, label) in &labels {
let visual = crate::controls::choice::ChoiceVisualState {
selected: false,
focused: self.focus == Focus::List && *user_index == self.focused_index,
enabled: !self.loading && !self.pending,
};
lines.push(render_choice_line(
&label,
ChoiceKind::Radio,
visual,
context.theme,
));
}
let par = Paragraph::new(lines);
f.render_widget(par, inner);
}
fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
if let Some(msg) = &self.message {
let par = Paragraph::new(Line::from(Span::styled(
msg.as_str(),
context.theme.text.muted,
)));
f.render_widget(par, rows[0]);
}
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(rows[1]);
render_button(
f,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[1],
ActionButton {
label: "Add",
intent: ButtonIntent::Primary,
focused: self.focus == Focus::AddButton,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[2],
ActionButton {
label: "Remove",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RemoveButton,
enabled: !self.loading && !self.pending && !self.users.is_empty(),
},
context.theme,
);
}
fn activate(&mut self) -> InteractionResult {
if self.loading || self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::Add { username } if !username.trim().is_empty() => {
let name = username.trim().to_owned();
self.pending_dialog = Some(Dialog::Add { username });
self.pending = true;
self.message = Some("Creating user…".into());
let ipc = self.ipc.clone();
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
Err(error) => Err(format!("Cannot create user: {error}")),
};
UiEvent::App(AppEvent::UserCreated(result))
}),
};
}
Dialog::Remove { user } => {
self.pending_dialog = Some(Dialog::Remove { user: user.clone() });
let ipc = self.ipc.clone();
let id = user.user_id;
self.pending = true;
self.message = Some(format!("Removing {}", user.username));
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()),
Err(error) => Err(format!("Cannot remove user: {error}")),
};
UiEvent::App(AppEvent::UserRemoved {
user_id: id,
result,
})
}),
};
}
Dialog::Add { .. } => self.message = Some("A username is required.".into()),
}
return InteractionResult::Handled;
}
match self.focus {
Focus::Back => InteractionResult::CloseScreen,
Focus::AddButton => {
self.dialog = Some(Dialog::Add {
username: String::new(),
});
InteractionResult::Handled
}
Focus::RemoveButton => {
if let Some(user) = self.users.get(self.focused_index) {
self.dialog = Some(Dialog::Remove { user: user.clone() });
}
InteractionResult::Handled
}
Focus::List => InteractionResult::Handled,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::AddButton,
Focus::AddButton => Focus::RemoveButton,
Focus::RemoveButton => Focus::Back,
Focus::Back => Focus::List,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Back,
Focus::Back => Focus::RemoveButton,
Focus::RemoveButton => Focus::AddButton,
Focus::AddButton => Focus::List,
};
}
fn keep_focused_user_visible(&mut self) {
let indices = self.filtered_indices();
let Some(position) = indices.iter().position(|index| *index == self.focused_index) else {
self.scroll_offset = 0;
return;
};
let height = self.viewport_height.load(Ordering::Relaxed).max(1);
if position < self.scroll_offset {
self.scroll_offset = position;
} else if position >= self.scroll_offset + height {
self.scroll_offset = position + 1 - height;
}
}
fn move_user_focus(&mut self, index: usize) {
if !self.users.is_empty() {
self.focused_index = index.min(self.users.len() - 1);
self.keep_focused_user_visible();
}
}
fn filtered_indices(&self) -> Vec<usize> {
let needle = self.filter.to_ascii_lowercase();
self.users
.iter()
.enumerate()
.filter(|(_, user)| {
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
})
.map(|(index, _)| index)
.collect()
}
fn move_visible(&mut self, delta: isize) {
let indices = self.filtered_indices();
if indices.is_empty() {
return;
}
let current = indices
.iter()
.position(|index| *index == self.focused_index)
.unwrap_or(0);
let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize;
self.move_user_focus(indices[next]);
}
fn reset_focus_to_filter(&mut self) {
self.scroll_offset = 0;
if let Some(index) = self.filtered_indices().first().copied() {
self.focused_index = index;
}
}
}
impl Screen for UsersScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
let outer_block = Block::default()
.title(" Users ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Users", false, context.theme)
} else {
let inner = outer_block.inner(rect);
f.render_widget(outer_block, rect);
inner
};
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
self.render_user_list(f, chunks[0], context);
self.render_actions(f, chunks[1], context);
if let Some(dialog) = &self.dialog {
f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect);
let popup = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let text = match dialog {
Dialog::Add { username } => {
format!("Add user\nUsername: {username}")
}
Dialog::Remove { user } => format!(
"Remove user {} (ID {})?\nThis removes the local user record.",
user.username, user.user_id
),
};
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
f.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
f.render_widget(
Paragraph::new(text).style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
f,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
f,
dialog_buttons[1],
ActionButton {
label: match dialog {
Dialog::Add { .. } => "Create",
Dialog::Remove { .. } => "Remove",
},
intent: match dialog {
Dialog::Add { .. } => ButtonIntent::Primary,
Dialog::Remove { .. } => ButtonIntent::Destructive,
},
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
let buttons = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(chunks[1]);
if self.dialog.is_none() {
hits.register(buttons[0], AppAction::Back);
}
if self.dialog.is_none() && !self.loading && !self.pending {
hits.register(buttons[1], AppAction::AddUser);
}
if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() {
hits.register(buttons[2], AppAction::RemoveUser);
}
if self.dialog.is_none() {
let list_height = chunks[0].height.saturating_sub(2) as usize;
let filtered_indices = self.filtered_indices();
for visible in 0..list_height {
let position = self.scroll_offset + visible;
let Some(index) = filtered_indices.get(position).copied() else {
break;
};
hits.register(
Rect {
x: chunks[0].x.saturating_add(1),
y: chunks[0].y.saturating_add(1 + visible as u16),
width: chunks[0].width.saturating_sub(2),
height: 1,
},
AppAction::SelectUser(index),
);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::UsersLoaded(result)) => {
self.loading = false;
match result {
Ok(users) => {
self.users = users;
self.message = None;
}
Err(error) => self.message = Some(error),
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserCreated(result)) => {
self.pending = false;
match result {
Ok(user) => {
self.pending_dialog = None;
self.focused_index = self.users.len();
self.users.push(user.clone());
self.message = Some(format!(
"Created user {} ({}).",
user.username, user.user_id
));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserRemoved { user_id, result }) => {
self.pending = false;
match result {
Ok(()) => {
self.pending_dialog = None;
self.users.retain(|user| user.user_id != user_id);
self.focused_index =
self.focused_index.min(self.users.len().saturating_sub(1));
self.message = Some(format!("Removed user {user_id}."));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => {
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
username.push_str(&text.replace(['\r', '\n'], " "));
}
return InteractionResult::Handled;
}
UiEvent::Key(event) => event,
_ => return InteractionResult::Unhandled,
};
if self.filtering && self.dialog.is_none() {
match event.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.reset_focus_to_filter();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.reset_focus_to_filter();
}
KeyCode::Char(c)
if !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
self.reset_focus_to_filter();
}
_ => {}
}
return InteractionResult::Handled;
}
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
match event.code {
KeyCode::Esc => {
self.dialog = None;
return InteractionResult::Handled;
}
KeyCode::Enter => return self.activate(),
KeyCode::Backspace => {
username.pop();
return InteractionResult::Handled;
}
KeyCode::Char(c)
if !c.is_control()
&& !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
username.push(c);
return InteractionResult::Handled;
}
_ => return InteractionResult::Handled,
}
}
if self.dialog.is_some() {
return match event.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
match event.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('/') if self.focus == Focus::List => {
self.filtering = true;
self.filter.clear();
self.reset_focus_to_filter();
InteractionResult::Handled
}
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == Focus::List {
self.move_visible(1);
}
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == Focus::List {
self.move_visible(-1);
}
InteractionResult::Handled
}
KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(page as isize);
InteractionResult::Handled
}
KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(-(page as isize));
InteractionResult::Handled
}
KeyCode::Home if self.focus == Focus::List => {
if let Some(index) = self.filtered_indices().first().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => {
if let Some(index) = self.filtered_indices().last().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::AddUser => {
self.focus = Focus::AddButton;
self.activate()
}
AppAction::RemoveUser => {
self.focus = Focus::RemoveButton;
self.activate()
}
AppAction::SelectUser(index) if self.dialog.is_none() => {
self.focus = Focus::List;
self.move_user_focus(index);
InteractionResult::Handled
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Up/Down",
action: "Select user",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -13,6 +13,19 @@ pub struct UiConfig {
/// Whether opening the interactive UI should launch a locally installed daemon.
#[serde(default)]
pub daemon_start_policy: DaemonStartPolicy,
#[serde(default)]
pub color: TerminalPolicy,
#[serde(default)]
pub unicode: TerminalPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TerminalPolicy {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]

View file

@ -3,10 +3,81 @@ mod model;
mod name;
mod presets;
pub use config::{DaemonStartPolicy, UiConfig};
pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig};
pub use model::*;
pub use name::ThemeName;
pub fn resolve(name: ThemeName) -> ResolvedTheme {
presets::resolve(name)
}
pub fn resolve_with_capabilities(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
) -> ResolvedTheme {
let mut theme = if color_enabled {
presets::resolve(name)
} else {
presets::resolve(ThemeName::Monospace)
};
theme.name = name;
theme.unicode = unicode_enabled;
if !unicode_enabled {
if matches!(theme.console.cursor, CursorPresentation::Character { .. }) {
theme.console.cursor = CursorPresentation::Character {
glyph: "|",
style: theme.console.text,
};
}
}
theme
}
/// Resolve a theme against the terminal's color depth. Surface uses RGB
/// colors, so a portable ANSI preset is selected when truecolor is absent.
pub fn resolve_with_terminal_profile(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
truecolor_enabled: bool,
) -> ResolvedTheme {
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
ThemeName::Ansi
} else {
name
};
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn no_color_policy_removes_palette_dependencies() {
let theme = resolve_with_capabilities(ThemeName::Surface, false, true);
assert_eq!(theme.name, ThemeName::Surface);
assert_eq!(theme.status.error.fg, None);
assert_eq!(theme.surfaces.panel.bg, None);
}
#[test]
fn ascii_policy_replaces_character_cursor() {
let theme = resolve_with_capabilities(ThemeName::Monospace, false, false);
assert!(!theme.unicode);
match theme.console.cursor {
CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"),
CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"),
}
assert_ne!(theme.graphs.ram, Color::Blue);
}
#[test]
fn surface_uses_ansi_fallback_without_truecolor() {
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
assert_eq!(theme.name, ThemeName::Ansi);
assert_eq!(theme.surfaces.panel.bg, None);
}
}

View file

@ -24,6 +24,13 @@ pub struct BorderStyles {
pub title: Style,
}
#[derive(Clone, Debug)]
pub struct SurfaceStyles {
pub canvas: Style, pub toolbar: Style, pub panel: Style, pub panel_alternate: Style,
pub panel_focused: Style, pub panel_selected: Style, pub footer: Style, pub overlay: Style,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChromeMode { Bordered, Surfaces }
#[derive(Clone, Debug)]
pub struct ChoiceItemStyle {
pub marker: Style,
pub label: Style,
@ -119,6 +126,9 @@ pub struct TextSemantics {
#[derive(Clone, Debug)]
pub struct ResolvedTheme {
pub name: ThemeName,
pub unicode: bool,
pub surfaces: SurfaceStyles,
pub chrome: ChromeMode,
pub text: TextStyles,
pub status: StatusStyles,
pub choices: ChoiceStyles,

View file

@ -1,6 +1,6 @@
use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles,
ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles,
ThemeName,
};
use ratatui::style::{Color, Modifier, Style};
@ -45,6 +45,9 @@ fn base(
};
ResolvedTheme {
name,
unicode: true,
surfaces: SurfaceStyles { canvas: Style::default(), toolbar: Style::default(), panel: Style::default(), panel_alternate: Style::default(), panel_focused: focused, panel_selected: selected, footer: Style::default(), overlay: Style::default() },
chrome: ChromeMode::Bordered,
text: TextStyles {
normal,
muted,
@ -295,6 +298,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme {
);
theme.console.cursor =
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
theme.chrome = ChromeMode::Surfaces;
theme.surfaces = SurfaceStyles {
canvas: plain.bg(Color::Black), toolbar: plain.fg(Color::White).bg(Color::DarkGray),
panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)),
panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)),
panel_selected: selected, footer: plain.fg(Color::DarkGray).bg(Color::Black), overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
};
theme
}
}

View file

@ -1,14 +1,28 @@
use crate::{
controls::header::render_header,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::Screen,
screens::{
main_screen::MainScreen,
metrics::MetricsScreen,
overview::OverviewScreen,
screens::{AppAction, AppEvent, HitMap, Screen, UiEvent},
settings::SettingsScreen,
users::{UserEntry, UsersScreen},
},
theme::{self, ResolvedTheme, ThemeName},
};
use crossterm::event::KeyEvent;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind,
};
use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout},
};
use std::{
io,
io::Stdout,
@ -18,7 +32,7 @@ use std::{
atomic::{AtomicBool, Ordering},
},
};
use tokio::sync::{Notify, RwLock};
use tokio::sync::{Notify, RwLock, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
@ -35,6 +49,10 @@ pub struct UI {
theme: RwLock<Arc<ResolvedTheme>>,
pub(crate) invalidation: Notify,
failure: Arc<Mutex<Option<String>>>,
hits: Mutex<HitMap>,
app_event_tx: mpsc::UnboundedSender<UiEvent>,
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
header_focus: Mutex<Option<usize>>,
}
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
@ -55,6 +73,24 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSes
fn start_session(ui: UI) -> io::Result<TuiSession> {
let ui = Arc::new(ui);
let mut app_event_rx = ui
.app_event_rx
.lock()
.map_err(|_| io::Error::other("application event queue poisoned"))?
.take()
.ok_or_else(|| io::Error::other("application event queue already started"))?;
let app_ui = ui.clone();
let app_event_task = tokio::spawn(async move {
loop {
tokio::select! {
_ = app_ui.cancellation.cancelled() => break,
event = app_event_rx.recv() => match event {
Some(event) => app_ui.clone().handle_event(event).await,
None => break,
},
}
}
});
let uic = ui.clone();
let renderer_task = tokio::spawn(async move {
let cancellation = uic.cancellation_token();
@ -62,7 +98,6 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
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 let Err(error) = &result {
@ -101,6 +136,7 @@ fn start_session(ui: UI) -> io::Result<TuiSession> {
ui,
renderer_task,
input_task,
app_event_task,
signal_task,
restored: AtomicBool::new(false),
previous_hook,
@ -111,6 +147,7 @@ pub struct TuiSession {
ui: Arc<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
app_event_task: JoinHandle<()>,
signal_task: Option<JoinHandle<()>>,
restored: AtomicBool,
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
@ -129,6 +166,7 @@ impl TuiSession {
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;
self.app_event_task.abort();
if renderer.is_err() {
self.renderer_task.abort();
}
@ -154,6 +192,7 @@ impl TuiSession {
}
fn restore_terminal_once(&self) {
if !self.restored.swap(true, Ordering::AcqRel) {
let _ = crossterm::execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
}
}
@ -168,6 +207,7 @@ impl Drop for TuiSession {
self.ui.request_shutdown();
self.renderer_task.abort();
self.input_task.abort();
self.app_event_task.abort();
if let Some(task) = self.signal_task.as_ref() {
task.abort();
}
@ -182,6 +222,8 @@ impl UI {
theme: ResolvedTheme,
) -> io::Result<Self> {
let terminal = ratatui::try_init()?;
crossterm::execute!(io::stdout(), EnableMouseCapture)?;
let (app_event_tx, app_event_rx) = mpsc::unbounded_channel();
Ok(Self {
ipc: RwLock::new(ipc),
shutdown_on_empty,
@ -191,6 +233,10 @@ impl UI {
theme: RwLock::new(Arc::new(theme)),
invalidation: Notify::new(),
failure: Arc::new(Mutex::new(None)),
hits: Mutex::new(HitMap::default()),
app_event_tx,
app_event_rx: Mutex::new(Some(app_event_rx)),
header_focus: Mutex::new(None),
})
}
@ -228,10 +274,6 @@ impl UI {
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();
}
/// 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) {
@ -259,10 +301,157 @@ impl UI {
self.invalidate();
}
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
self.handle_event(UiEvent::Key(key_event)).await;
}
pub async fn handle_event(self: Arc<Self>, event: UiEvent) {
if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) {
self.open_users().await;
return;
}
if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) {
if let Some(screen) = MetricsScreen::new(self.clone()).await {
self.set_screen(Box::new(screen)).await;
}
return;
}
if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event {
self.set_theme(theme::resolve(*theme)).await;
if *persist {
let mut config = theme::UiConfig::load().unwrap_or_default();
config.theme = *theme;
let result = config
.save()
.map_err(|error| format!("Could not save UI settings: {error}"));
let _ = self
.app_event_tx
.send(UiEvent::App(AppEvent::ThemeSaved(result)));
}
return;
}
if let UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) = &event {
self.set_theme(theme::resolve(*theme)).await;
let mut config = theme::UiConfig::load().unwrap_or_default();
config.theme = *theme;
config.color = *color;
config.unicode = *unicode;
let result = config
.save()
.map_err(|error| format!("Could not save UI settings: {error}"));
let _ = self.app_event_tx.send(UiEvent::App(AppEvent::ThemeSaved(result)));
return;
}
if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) {
let Some(ipc) = self.ipc().await else {
let _ = self
.app_event_tx
.send(UiEvent::App(AppEvent::KeysRegenerated(Err(
"Not connected to daemon.".into(),
))));
return;
};
let sender = self.app_event_tx.clone();
tokio::spawn(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::RotateIotaIdentity)
.await
{
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Cannot regenerate keys: {error}"))
}
Err(error) => Err(format!("Cannot regenerate keys: {error}")),
};
let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result)));
});
return;
}
if let UiEvent::Key(key) = &event {
let header_is_focused = self
.header_focus
.lock()
.map(|focus| focus.is_some())
.unwrap_or(false);
if key.code == KeyCode::F(6) {
if let Ok(mut focus) = self.header_focus.lock() {
*focus = if focus.is_some() { None } else { Some(0) };
}
self.invalidate();
return;
}
if header_is_focused {
let mut action = None;
if let Ok(mut focus) = self.header_focus.lock() {
let index = focus.unwrap_or(0);
match key.code {
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
KeyCode::Enter | KeyCode::Char(' ') => {
action = Some([
AppAction::OpenOverview,
AppAction::OpenUsers,
AppAction::OpenSettings,
AppAction::Quit,
][index]);
*focus = None;
}
KeyCode::Esc => *focus = None,
_ => {}
}
}
if let Some(action) = action {
self.dispatch_action(action).await;
} else {
self.invalidate();
}
return;
}
}
if let UiEvent::Mouse(mouse) = &event {
if matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
let action = self
.hits
.lock()
.ok()
.and_then(|hits| hits.action_at(mouse.column, mouse.row));
if action == Some(AppAction::FocusLogs) {
self.dispatch_action(AppAction::FocusLogs).await;
let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) {
KeyCode::Up
} else {
KeyCode::Down
};
// Log scrolling is a local, handled interaction; route it
// directly rather than recursively constructing another
// async UI event future.
if let Some(screen) = self.screen_stack.write().await.last_mut() {
let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key)));
}
self.invalidate();
return;
}
}
if matches!(
mouse.kind,
MouseEventKind::Down(crossterm::event::MouseButton::Left)
) {
if let Some(action) = self
.hits
.lock()
.ok()
.and_then(|hits| hits.action_at(mouse.column, mouse.row))
{
self.dispatch_action(action).await;
return;
}
}
}
let result = {
let mut stack = self.screen_stack.write().await;
if let Some(screen) = stack.last_mut() {
screen.handle_input(key_event)
screen.handle_event(event)
} else {
return;
}
@ -278,6 +467,13 @@ impl UI {
_ = ui.cancellation.cancelled() => return,
}
}
InteractionResult::AppTask { task } => {
let sender = self.app_event_tx.clone();
tokio::spawn(async move {
let event = task.await;
let _ = sender.send(event);
});
}
InteractionResult::CloseScreen => {
let mut stack = self.screen_stack.write().await;
stack.pop();
@ -292,6 +488,78 @@ impl UI {
self.invalidate();
}
async fn dispatch_action(self: &Arc<Self>, action: AppAction) {
match action {
AppAction::Quit => self.request_shutdown(),
AppAction::OpenMain => {
let mut stack = self.screen_stack.write().await;
if stack.len() > 1 {
stack.truncate(1);
}
drop(stack);
self.invalidate();
}
AppAction::OpenOverview => {
let status = {
let stack = self.screen_stack.read().await;
stack
.iter()
.rev()
.find_map(|s| s.as_any().downcast_ref::<MainScreen>())
.map(|main| (main.connection_status(), main.daemon_status()))
};
if let Some((connection, daemon)) = status {
self.set_screen(Box::new(OverviewScreen::new(connection, daemon)))
.await;
}
}
AppAction::OpenUsers => self.open_users().await,
AppAction::OpenSettings => {
let current = self.theme_name().await;
self.set_screen(Box::new(SettingsScreen::new(current))).await;
}
AppAction::OpenMetrics => {
if let Some(screen) = MetricsScreen::new(self.clone()).await {
self.set_screen(Box::new(screen)).await;
}
}
action => {
let result = {
let mut stack = self.screen_stack.write().await;
stack.last_mut().map(|screen| screen.handle_action(action))
};
if matches!(result, Some(InteractionResult::CloseScreen)) {
let mut stack = self.screen_stack.write().await;
stack.pop();
}
self.invalidate();
}
}
}
async fn open_users(self: &Arc<Self>) {
let Some(ipc) = self.ipc().await else { return };
self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
.await;
let sender = self.app_event_tx.clone();
tokio::spawn(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
Ok(users
.into_iter()
.map(|u| UserEntry {
user_id: u.user_id,
username: u.username,
})
.collect())
}
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
Err(error) => Err(format!("Cannot load users: {error}")),
};
let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
});
}
pub async fn render(&self) -> io::Result<()> {
let theme = self.theme.read().await.clone();
let context = RenderContext {
@ -304,9 +572,54 @@ impl UI {
.terminal
.lock()
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
let mut hits = HitMap::default();
terminal.draw(|f| {
screen.render(f, f.area(), &context);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(f.area());
let header_title = self
.screen_stack
.try_read()
.ok()
.and_then(|stack| {
stack
.iter()
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
.map(|main| main.app_title())
})
.unwrap_or_else(|| screen.app_title());
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
render_header(
f,
rows[0],
&header_title,
context.theme,
&mut hits,
header_focus,
);
let hints = if header_focus.is_some() {
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
} else {
screen
.key_hints()
.into_iter()
.map(|hint| format!("{}: {}", hint.keys, hint.action))
.collect::<Vec<_>>()
.join(" ")
};
f.render_widget(
ratatui::widgets::Paragraph::new(format!(" {hints}"))
.style(context.theme.surfaces.footer.patch(context.theme.text.muted)),
rows[2],
);
screen.render(f, rows[1], &context, &mut hits);
})?;
if let Ok(mut current) = self.hits.lock() {
*current = hits;
}
}
Ok(())
}