[Imp] UI & UX

This commit is contained in:
Alex 2026-07-28 02:20:15 +02:00
commit 7399cf8fc3
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
18 changed files with 1635 additions and 193 deletions

View file

@ -1,25 +1,58 @@
use crate::ipc_client::{DaemonStatus, IpcConnectionState};
use crate::theme::ResolvedTheme;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
controls::button::ButtonIntent,
screens::screens::{AppAction, HitMap},
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::Span,
text::{Line, Span},
widgets::Paragraph,
};
/// Shared application bar. The brand cell is deliberately an action so it is
/// a reliable way home from every screen.
fn connection_badge(state: &IpcConnectionState, theme: &ResolvedTheme) -> (&'static str, ratatui::style::Style) {
match state {
IpcConnectionState::Connected => ("OK", theme.status.success),
IpcConnectionState::Connecting => ("..", theme.status.warning),
IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => ("FAIL", theme.status.error),
IpcConnectionState::Disconnected => ("WARN", theme.status.warning),
}
}
fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) {
use iota_ipc::ComponentId;
let health = daemon.components.get(&ComponentId::Omikron);
let (label, style) = match health.map(|h| h.status) {
Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success),
Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning),
Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error),
None => ("--", theme.text.muted),
};
let detail = health
.and_then(|h| h.message.as_deref())
.map(|m| format!(" {m}"))
.unwrap_or_default();
(format!("{label}{detail}"), style)
}
pub fn render_header(
frame: &mut Frame,
area: Rect,
title: &str,
connection: &IpcConnectionState,
daemon: &DaemonStatus,
theme: &ResolvedTheme,
hits: &mut HitMap,
focused_action: Option<usize>,
) {
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let rows = Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
@ -27,36 +60,75 @@ pub fn render_header(
Constraint::Length(12),
Constraint::Length(8),
])
.split(area);
.split(rows[0]);
let cells2 = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[1]);
let (ipc_label, ipc_style) = connection_badge(connection, theme);
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
let brand_line1 = Line::from(vec![
Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar),
Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style),
]);
let brand_line2 = Line::from(vec![
Span::styled(" Omikron: ", theme.surfaces.toolbar),
Span::styled(format!("[{omikron_text}]"), omikron_style),
]);
let brand_area = Rect {
x: area.x,
y: area.y,
width: cells[0].width,
height: area.height,
};
frame.render_widget(
Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)),
cells[0],
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
brand_area,
);
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),
hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [
(cells[1], cells2[1], "Overview", ButtonIntent::Primary, AppAction::OpenOverview),
(cells[2], cells2[2], "Users", ButtonIntent::Neutral, AppAction::OpenUsers),
(cells[3], cells2[3], "Settings", ButtonIntent::Neutral, AppAction::OpenSettings),
(cells[4], cells2[4], "Quit", ButtonIntent::Destructive, 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,
let button_area = Rect {
x: top.x,
y: top.y,
width: top.width,
height: area.height,
};
let style = match (intent, focused_action == Some(index)) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
};
let display_label = if focused_action == Some(index) {
format!(" {label}")
} else {
label.to_owned()
};
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(display_label, style)),
Line::from(""),
]),
button_area,
);
hits.register(area, action);
hits.register(button_area, action);
}
}