[Imp] UI & UX
This commit is contained in:
parent
a97092d653
commit
7399cf8fc3
18 changed files with 1635 additions and 193 deletions
|
|
@ -49,28 +49,6 @@ impl MainScreen {
|
|||
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();
|
||||
|
||||
|
|
@ -254,39 +232,6 @@ impl Screen for MainScreen {
|
|||
|
||||
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();
|
||||
let daemon = self.daemon_status_rx.borrow().clone();
|
||||
let status_text = match status {
|
||||
IpcConnectionState::Connected => "Connected".to_string(),
|
||||
IpcConnectionState::Connecting => "Connecting...".to_string(),
|
||||
IpcConnectionState::Reconnecting { attempt } => {
|
||||
format!("Reconnecting (attempt {})...", attempt)
|
||||
}
|
||||
IpcConnectionState::Incompatible { message } => {
|
||||
format!("Incompatible protocol: {}", message)
|
||||
}
|
||||
IpcConnectionState::Failed { message } => {
|
||||
format!("Connection failed: {}", message)
|
||||
}
|
||||
IpcConnectionState::Disconnected => "Disconnected".to_string(),
|
||||
};
|
||||
let readiness = daemon
|
||||
.startup_phase
|
||||
.map(|phase| format!("{:?}", phase))
|
||||
.unwrap_or_else(|| "Waiting for status".into());
|
||||
let health = daemon
|
||||
.degraded_reason
|
||||
.as_deref()
|
||||
.map(|reason| format!(" — {reason}"))
|
||||
.unwrap_or_default();
|
||||
let version = if daemon.version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" v{}", daemon.version)
|
||||
};
|
||||
let _ = (status_text, readiness, health, version);
|
||||
f.render_widget(
|
||||
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
|
||||
rect,
|
||||
|
|
@ -503,9 +448,6 @@ impl Screen for MainScreen {
|
|||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
fn app_title(&self) -> String {
|
||||
self.status_summary()
|
||||
}
|
||||
fn key_hints(&self) -> Vec<KeyHint> {
|
||||
if self.selected_coords == (2, 0) {
|
||||
vec![
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ pub enum AppEvent {
|
|||
theme: crate::theme::ThemeName,
|
||||
color: crate::theme::TerminalPolicy,
|
||||
unicode: crate::theme::TerminalPolicy,
|
||||
cli_output: crate::theme::CliOutputFormat,
|
||||
cli_require_confirmation: bool,
|
||||
},
|
||||
ThemeSaved(Result<(), String>),
|
||||
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
|
||||
|
|
@ -118,9 +120,6 @@ pub trait Screen: Send + Sync + Any {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ use crate::{
|
|||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
|
||||
theme::{TerminalPolicy, ThemeName, UiConfig},
|
||||
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Theme,
|
||||
CliOutput,
|
||||
CliConfirm,
|
||||
RegenerateKeys,
|
||||
Back,
|
||||
}
|
||||
|
|
@ -34,6 +36,8 @@ pub struct SettingsScreen {
|
|||
message: String,
|
||||
color: TerminalPolicy,
|
||||
unicode: TerminalPolicy,
|
||||
cli_output: CliOutputFormat,
|
||||
cli_require_confirmation: bool,
|
||||
focus: Focus,
|
||||
dialog: Option<Dialog>,
|
||||
pending: bool,
|
||||
|
|
@ -45,16 +49,15 @@ impl SettingsScreen {
|
|||
.iter()
|
||||
.position(|theme| *theme == current)
|
||||
.unwrap_or(0);
|
||||
let config = UiConfig::load_or_default();
|
||||
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(),
|
||||
color: config.color,
|
||||
unicode: config.unicode,
|
||||
cli_output: config.cli_output,
|
||||
cli_require_confirmation: config.cli_require_confirmation,
|
||||
focus: Focus::Theme,
|
||||
dialog: None,
|
||||
pending: false,
|
||||
|
|
@ -82,7 +85,9 @@ impl SettingsScreen {
|
|||
|
||||
fn next_focus(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Theme => Focus::RegenerateKeys,
|
||||
Focus::Theme => Focus::CliOutput,
|
||||
Focus::CliOutput => Focus::CliConfirm,
|
||||
Focus::CliConfirm => Focus::RegenerateKeys,
|
||||
Focus::RegenerateKeys => Focus::Back,
|
||||
Focus::Back => Focus::Theme,
|
||||
};
|
||||
|
|
@ -92,7 +97,9 @@ impl SettingsScreen {
|
|||
self.focus = match self.focus {
|
||||
Focus::Theme => Focus::Back,
|
||||
Focus::Back => Focus::RegenerateKeys,
|
||||
Focus::RegenerateKeys => Focus::Theme,
|
||||
Focus::RegenerateKeys => Focus::CliConfirm,
|
||||
Focus::CliConfirm => Focus::CliOutput,
|
||||
Focus::CliOutput => Focus::Theme,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -114,24 +121,44 @@ impl SettingsScreen {
|
|||
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::CliOutput => {
|
||||
self.message = "Output format updated.".into();
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
Focus::CliConfirm => {
|
||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
||||
self.message = format!(
|
||||
"Confirm: {}",
|
||||
if self.cli_require_confirmation {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
},
|
||||
);
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
Focus::RegenerateKeys => {
|
||||
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
|
||||
InteractionResult::Handled
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
Focus::Back => InteractionResult::CloseScreen,
|
||||
Focus::Back => return InteractionResult::CloseScreen,
|
||||
}
|
||||
let theme = self.selected_theme();
|
||||
let color = self.color;
|
||||
let unicode = self.unicode;
|
||||
let cli_output = self.cli_output;
|
||||
let cli_require_confirmation = self.cli_require_confirmation;
|
||||
InteractionResult::AppTask {
|
||||
task: Box::pin(async move {
|
||||
UiEvent::App(AppEvent::SaveSettings {
|
||||
theme,
|
||||
color,
|
||||
unicode,
|
||||
cli_output,
|
||||
cli_require_confirmation,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -152,13 +179,16 @@ impl Screen for SettingsScreen {
|
|||
context: &RenderContext<'_>,
|
||||
hits: &mut HitMap,
|
||||
) {
|
||||
let block = Block::default()
|
||||
let header_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);
|
||||
let inner = header_block.inner(area);
|
||||
frame.render_widget(header_block, area);
|
||||
|
||||
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)])
|
||||
.split(inner);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
|
||||
|
|
@ -169,18 +199,28 @@ impl Screen for SettingsScreen {
|
|||
" [preview]"
|
||||
},
|
||||
self.color,
|
||||
self.unicode
|
||||
self.unicode,
|
||||
))
|
||||
.style(context.theme.text.heading),
|
||||
rows[0],
|
||||
sections[0],
|
||||
);
|
||||
|
||||
let cli_line = format!(
|
||||
"CLI output: {:?} (L) Confirm: {} (K)",
|
||||
self.cli_output,
|
||||
if self.cli_require_confirmation {
|
||||
"required"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
);
|
||||
|
||||
let bottom_rows =
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]);
|
||||
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(&self.message, context.theme.text.normal)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(&cli_line, context.theme.text.normal)),
|
||||
Line::from("Preview"),
|
||||
Line::from("[OK] Healthy"),
|
||||
Line::from("[WARN] Degraded"),
|
||||
|
|
@ -352,6 +392,23 @@ impl Screen for SettingsScreen {
|
|||
self.unicode = Self::next_policy(self.unicode);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Char('L') => {
|
||||
self.cli_output = self.cli_output.next();
|
||||
self.message = format!("CLI output: {:?}", self.cli_output);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||
self.cli_require_confirmation = !self.cli_require_confirmation;
|
||||
self.message = format!(
|
||||
"CLI confirm: {}",
|
||||
if self.cli_require_confirmation {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
},
|
||||
);
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
|
|
@ -405,6 +462,10 @@ impl Screen for SettingsScreen {
|
|||
keys: "C/U",
|
||||
action: "Color/Unicode",
|
||||
},
|
||||
KeyHint {
|
||||
keys: "L/K",
|
||||
action: "CLI Out/Confirm",
|
||||
},
|
||||
KeyHint {
|
||||
keys: "Esc/B",
|
||||
action: "Back",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ use std::{
|
|||
any::Any,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
atomic::{AtomicU8, AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -56,6 +56,7 @@ pub struct UsersScreen {
|
|||
viewport_height: AtomicUsize,
|
||||
filter: String,
|
||||
filtering: bool,
|
||||
tick: AtomicU8,
|
||||
}
|
||||
|
||||
impl UsersScreen {
|
||||
|
|
@ -74,6 +75,7 @@ impl UsersScreen {
|
|||
viewport_height: AtomicUsize::new(1),
|
||||
filter: String::new(),
|
||||
filtering: false,
|
||||
tick: AtomicU8::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,7 +117,12 @@ impl UsersScreen {
|
|||
};
|
||||
|
||||
if self.loading {
|
||||
f.render_widget(Paragraph::new("Loading users…"), inner);
|
||||
const SPINNERS: &[u8] = b"|/-\\";
|
||||
let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()];
|
||||
f.render_widget(
|
||||
Paragraph::new(format!("{ch} Loading users…")),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if visible_indices.is_empty() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue