[Imp] UI & UX
This commit is contained in:
parent
a97092d653
commit
7399cf8fc3
18 changed files with 1635 additions and 193 deletions
271
iota-cli/src/controls/dialog.rs
Normal file
271
iota-cli/src/controls/dialog.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
use crossterm::event::KeyCode;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
controls::button::{ActionButton, ButtonIntent, render_button},
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::screens::{HitMap, KeyHint, Screen, UiEvent},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DialogButton {
|
||||
Cancel,
|
||||
Confirm,
|
||||
Custom(usize),
|
||||
}
|
||||
|
||||
pub struct ConfirmDialog {
|
||||
title: String,
|
||||
message: Vec<String>,
|
||||
buttons: Vec<DialogButtonConfig>,
|
||||
focused_button: usize,
|
||||
on_confirm: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
||||
on_cancel: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
|
||||
}
|
||||
|
||||
struct DialogButtonConfig {
|
||||
label: String,
|
||||
intent: ButtonIntent,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl ConfirmDialog {
|
||||
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
message: vec![message.into()],
|
||||
buttons: vec![
|
||||
DialogButtonConfig {
|
||||
label: "Cancel".to_owned(),
|
||||
intent: ButtonIntent::Cancel,
|
||||
enabled: true,
|
||||
},
|
||||
DialogButtonConfig {
|
||||
label: "Confirm".to_owned(),
|
||||
intent: ButtonIntent::Primary,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
focused_button: 0,
|
||||
on_confirm: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
message: vec![message.into()],
|
||||
buttons: vec![
|
||||
DialogButtonConfig {
|
||||
label: "Cancel".to_owned(),
|
||||
intent: ButtonIntent::Cancel,
|
||||
enabled: true,
|
||||
},
|
||||
DialogButtonConfig {
|
||||
label: "Delete".to_owned(),
|
||||
intent: ButtonIntent::Destructive,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
focused_button: 0,
|
||||
on_confirm: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_message_line(mut self, line: impl Into<String>) -> Self {
|
||||
self.message.push(line.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_button(mut self, label: impl Into<String>, intent: ButtonIntent) -> Self {
|
||||
self.buttons.push(DialogButtonConfig {
|
||||
label: label.into(),
|
||||
intent,
|
||||
enabled: true,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_confirm_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
||||
mut self,
|
||||
action: F,
|
||||
) -> Self {
|
||||
self.on_confirm = Some(Box::new(action));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cancel_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
|
||||
mut self,
|
||||
action: F,
|
||||
) -> Self {
|
||||
self.on_cancel = Some(Box::new(action));
|
||||
self
|
||||
}
|
||||
|
||||
fn activate(&self) -> InteractionResult {
|
||||
match self.focused_button {
|
||||
0 => {
|
||||
if let Some(action) = &self.on_cancel {
|
||||
action()
|
||||
} else {
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if let Some(action) = &self.on_confirm {
|
||||
action()
|
||||
} else {
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
}
|
||||
_ => InteractionResult::CloseScreen,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_button(&mut self) {
|
||||
self.focused_button = (self.focused_button + 1) % self.buttons.len();
|
||||
}
|
||||
|
||||
fn prev_button(&mut self) {
|
||||
if self.focused_button == 0 {
|
||||
self.focused_button = self.buttons.len() - 1;
|
||||
} else {
|
||||
self.focused_button -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Screen for ConfirmDialog {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||
let area = crate::layout::fit::centered_rect(
|
||||
rect,
|
||||
crate::layout::fit::RequiredSize {
|
||||
width: 50,
|
||||
height: (self.message.len() + 8) as u16,
|
||||
},
|
||||
);
|
||||
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", self.title))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(context.theme.borders.focused)
|
||||
.style(context.theme.surfaces.overlay);
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Min(self.message.len() as u16),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let lines: Vec<Line> = self
|
||||
.message
|
||||
.iter()
|
||||
.map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal)))
|
||||
.collect();
|
||||
f.render_widget(Paragraph::new(lines), rows[0]);
|
||||
|
||||
let buttons_area = rows[2];
|
||||
let button_widths: Vec<u16> = self
|
||||
.buttons
|
||||
.iter()
|
||||
.map(|b| {
|
||||
crate::controls::button::button_minimum_width(&b.label)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_width: u16 = button_widths.iter().sum();
|
||||
let spacing = self.buttons.len().saturating_sub(1) as u16;
|
||||
let available = buttons_area.width;
|
||||
let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2;
|
||||
|
||||
let mut x = start_x;
|
||||
for (i, (button_config, &width)) in
|
||||
self.buttons.iter().zip(&button_widths).enumerate()
|
||||
{
|
||||
let button_area = Rect {
|
||||
x,
|
||||
y: buttons_area.y,
|
||||
width,
|
||||
height: 1,
|
||||
};
|
||||
x = x.saturating_add(width + 1);
|
||||
|
||||
render_button(
|
||||
f,
|
||||
button_area,
|
||||
ActionButton {
|
||||
label: &button_config.label,
|
||||
intent: button_config.intent,
|
||||
focused: self.focused_button == i,
|
||||
enabled: button_config.enabled,
|
||||
},
|
||||
context.theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
|
||||
let UiEvent::Key(key) = event else {
|
||||
return InteractionResult::Unhandled;
|
||||
};
|
||||
match key.code {
|
||||
KeyCode::Esc => InteractionResult::CloseScreen,
|
||||
KeyCode::Tab => {
|
||||
self.next_button();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::BackTab => {
|
||||
self.prev_button();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.prev_button();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.next_button();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
fn key_hints(&self) -> Vec<KeyHint> {
|
||||
vec![
|
||||
KeyHint {
|
||||
keys: "Tab",
|
||||
action: "Switch button",
|
||||
},
|
||||
KeyHint {
|
||||
keys: "Enter",
|
||||
action: "Confirm",
|
||||
},
|
||||
KeyHint {
|
||||
keys: "Esc",
|
||||
action: "Cancel",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod action;
|
|||
pub mod button;
|
||||
pub mod checkbox_group;
|
||||
pub mod choice;
|
||||
pub mod dialog;
|
||||
pub mod header;
|
||||
pub mod navigation;
|
||||
pub mod panel;
|
||||
|
|
|
|||
Loading…
Reference in a new issue