[Wip] CLI & Daemon
This commit is contained in:
parent
3bc5cc959a
commit
6a535099bb
44 changed files with 4417 additions and 303 deletions
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
62
iota-cli/src/controls/header.rs
Normal file
62
iota-cli/src/controls/header.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
23
iota-cli/src/controls/panel.rs
Normal file
23
iota-cli/src/controls/panel.rs
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
20
iota-cli/src/controls/scroll.rs
Normal file
20
iota-cli/src/controls/scroll.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: _ },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
131
iota-cli/src/screens/metrics.rs
Normal file
131
iota-cli/src/screens/metrics.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
282
iota-cli/src/screens/overview.rs
Normal file
282
iota-cli/src/screens/overview.rs
Normal 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" },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
390
iota-cli/src/screens/settings.rs
Normal file
390
iota-cli/src/screens/settings.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
705
iota-cli/src/screens/users.rs
Normal file
705
iota-cli/src/screens/users.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue