[Fix] Connection Management

This commit is contained in:
Alex Emmet 2026-09-13 20:58:41 +02:00
commit 3f2ac18333
No known key found for this signature in database
122 changed files with 19970 additions and 5263 deletions

View file

@ -1,44 +1,36 @@
use crate::theme::ResolvedTheme;
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
radio_group::{RadioGroup, RadioItem},
choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, Screen, UiEvent},
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
};
use crossterm::event::KeyCode;
use ratatool::{DisabledFocusPolicy, RadioGroup, RadioGroupError, RadioItem};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Text},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::any::Any;
use tokio::sync::oneshot;
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
/// setup choice screen is intentionally closed before its decision is sent,
/// so without this the terminal would otherwise be blank during startup.
pub struct DaemonStartingScreen;
impl Screen for DaemonStartingScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
impl IotaScreen for DaemonStartingScreen {
fn render_legacy(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
theme: &ResolvedTheme,
_hits: &mut HitMap,
) {
let popup = crate::layout::fit::centered_rect(
let popup = ratatool::layout::centered_rect(
area,
crate::layout::fit::RequiredSize {
ratatool::layout::RequiredSize {
width: 48,
height: 5,
},
@ -52,13 +44,13 @@ impl Screen for DaemonStartingScreen {
Block::default()
.title(" Iota daemon ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal),
.border_style(theme.borders.normal),
),
popup,
);
}
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
InteractionResult::Handled
fn handle_event(&mut self, _: &UiEvent) -> IotaInteraction {
IotaInteraction::Changed
}
}
@ -99,7 +91,7 @@ impl DaemonSetupScreen {
options: Vec<LaunchOption>,
message: impl Into<String>,
sender: oneshot::Sender<DaemonSetupDecision>,
) -> Result<Self, crate::controls::radio_group::RadioGroupError> {
) -> Result<Self, RadioGroupError> {
let items: Vec<RadioItem<DaemonLaunchMode>> = options
.into_iter()
.map(|o| RadioItem {
@ -119,9 +111,9 @@ impl DaemonSetupScreen {
.iter()
.find(|item| item.enabled)
.map(|item| item.value)
.ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?;
.ok_or(RadioGroupError::NoEnabledItems)?;
let mut choices = RadioGroup::new(items, None, default)?;
choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include);
choices.set_focus_policy(DisabledFocusPolicy::Include);
Ok(Self {
choices,
focus: Focus::Options,
@ -134,15 +126,15 @@ impl DaemonSetupScreen {
let _ = tx.send(d);
}
}
fn activate(&mut self) -> InteractionResult {
fn activate(&mut self) -> IotaInteraction {
match self.focus {
Focus::Options => {
self.choices.select_focused();
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Exit => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
IotaInteraction::Pop
}
Focus::Action => {
let choice = *self.choices.selected();
@ -154,9 +146,9 @@ impl DaemonSetupScreen {
.is_some_and(|i| i.enabled)
{
self.complete(DaemonSetupDecision::Start(choice));
InteractionResult::CloseScreen
IotaInteraction::Pop
} else {
InteractionResult::Handled
IotaInteraction::Changed
}
}
}
@ -190,23 +182,17 @@ impl DaemonSetupScreen {
};
}
}
impl Screen for DaemonSetupScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
impl IotaScreen for DaemonSetupScreen {
fn render_legacy(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
theme: &ResolvedTheme,
_hits: &mut HitMap,
) {
let popup = crate::layout::fit::centered_rect(
let popup = ratatool::layout::centered_rect(
area,
crate::layout::fit::RequiredSize {
ratatool::layout::RequiredSize {
width: 68,
height: 16,
},
@ -216,14 +202,18 @@ impl Screen for DaemonSetupScreen {
lines.push(render_choice_line(
&item.label,
ChoiceKind::Radio,
self.choices.visual_state(&item.value),
context.theme,
{
let state = self.choices.visual_state(&item.value);
ChoiceVisualState {
selected: state.selected,
focused: state.focused,
enabled: state.enabled,
}
},
theme,
));
if let Some(reason) = &item.description {
lines.push(Line::styled(
format!(" {reason}"),
context.theme.text.muted,
));
lines.push(Line::styled(format!(" {reason}"), theme.text.muted));
}
}
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
@ -248,7 +238,7 @@ impl Screen for DaemonSetupScreen {
focused: self.focus == Focus::Exit,
enabled: true,
},
context.theme,
theme,
);
let selected = *self.choices.selected();
let enabled = self
@ -275,28 +265,29 @@ impl Screen for DaemonSetupScreen {
focused: self.focus == Focus::Action,
enabled,
},
context.theme,
theme,
);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
match event.code {
KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
IotaInteraction::Pop
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.next();
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
self.previous();
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}
}

View file

@ -1,3 +1,4 @@
use crate::theme::ResolvedTheme;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
elements::{
@ -6,97 +7,97 @@ use crate::{
graph_card::{GRAPHS, GraphCard},
log_card::LogCard,
},
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::{
overview::OverviewScreen,
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
screens::screens::{
AppAction, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint,
NavDirection, UiEvent,
},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, MouseEventKind};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
widgets::Borders,
};
use tokio::sync::watch;
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU16, Ordering},
},
use std::sync::{
Arc,
atomic::{AtomicU16, Ordering},
};
pub struct MainScreen {
elements: Vec<Box<dyn InteractableElement>>,
nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize),
focus_graph: ratatool::FocusGraph<MainFocus>,
focus: MainFocus,
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
layout_width: AtomicU16,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
enum MainFocus {
#[default]
Logs,
Console,
Ram,
Cpu,
Ping,
}
impl MainFocus {
const ORDER: [Self; 5] = [Self::Logs, Self::Ram, Self::Cpu, Self::Console, Self::Ping];
const fn element_index(self) -> usize {
match self {
Self::Logs => 0,
Self::Console => 1,
Self::Ram => 2,
Self::Cpu => 3,
Self::Ping => 4,
}
}
const fn is_metric(self) -> bool {
matches!(self, Self::Ram | Self::Cpu | Self::Ping)
}
}
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()
}
pub async fn new(ui: Arc<UI>) -> Self {
pub fn new(ipc: Arc<crate::ipc_client::IpcClient>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
let nav_grid = vec![
vec![Some(0), Some(2)],
vec![Some(0), Some(3)],
vec![Some(1), Some(4)],
];
let mut focus_graph = ratatool::FocusGraph::default();
focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Down, MainFocus::Console);
focus_graph.connect_bidirectional(MainFocus::Logs, NavDirection::Right, MainFocus::Ram);
focus_graph.connect_bidirectional(MainFocus::Ram, NavDirection::Down, MainFocus::Cpu);
focus_graph.connect_bidirectional(MainFocus::Cpu, NavDirection::Down, MainFocus::Ping);
focus_graph.connect_bidirectional(MainFocus::Console, NavDirection::Right, MainFocus::Ping);
let state = ui
.client_state()
.await
.expect("MainScreen requires an attached daemon");
let state = ipc.state();
let mut log_card = LogCard::new(state.clone());
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let ipc = ui
.ipc()
.await
.expect("MainScreen requires an attached daemon");
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card));
elements.push(Box::new(console_card));
let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into());
let mut ram_graph = GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into());
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
elements.push(Box::new(ram_graph));
let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into());
let mut cpu_graph = GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into());
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
cpu_graph.set_joins(Borders::TOP);
elements.push(Box::new(cpu_graph));
let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into());
let mut ping_graph = GraphCard::new(state, GRAPHS::Ping, "Ping".into());
ping_graph.set_joins(Borders::TOP);
elements.push(Box::new(ping_graph));
let graphs_open = true;
let connection_status_rx = ipc.connection_status();
let daemon_status_rx = ipc.daemon_status();
let mut screen = MainScreen {
elements,
nav_grid,
selected_coords: (1, 0),
focus_graph,
focus: MainFocus::Logs,
graphs_open,
connection_status_rx,
daemon_status_rx,
layout_width: AtomicU16::new(0),
};
screen.focus_current();
@ -104,106 +105,43 @@ impl MainScreen {
}
fn focus_current(&mut self) {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
if element.can_focus() {
element.focus(true);
}
}
if let Some(element) = self.elements.get_mut(self.focus.element_index())
&& element.can_focus()
{
element.focus(true);
}
}
fn unfocus_current(&mut self, y: usize, x: usize) {
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
element.focus(false);
}
fn unfocus_current(&mut self) {
if let Some(element) = self.elements.get_mut(self.focus.element_index()) {
element.focus(false);
}
}
fn navigate(&mut self, direction: NavDirection) {
let (current_row, current_col) = self.selected_coords;
let current_element = self.nav_grid[current_row][current_col];
self.unfocus_current(current_row, current_col);
let (delta_row, delta_col) = match direction {
NavDirection::Up => (-1isize, 0),
NavDirection::Down => (1, 0),
NavDirection::Left => (0, -1),
NavDirection::Right => (0, 1),
_ => (0, 0),
let Some(target) = self.focus_graph.target(&self.focus, direction).copied() else {
return;
};
let mut next_row = current_row as isize;
let mut next_col = current_col as isize;
loop {
next_row += delta_row;
next_col += delta_col;
if next_row < 0 || next_col < 0 {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
let next_row_u = next_row as usize;
let next_col_u = next_col as usize;
if next_row_u >= self.nav_grid.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(row) = self.nav_grid.get(next_row_u) {
if next_col_u >= row.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(next_element) = row[next_col_u] {
if Some(next_element) != current_element {
self.selected_coords = (next_row_u, next_col_u);
self.focus_current();
return;
}
}
}
if target.is_metric()
&& (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70)
{
return;
}
self.unfocus_current();
self.focus = target;
self.focus_current();
}
/// Cycle focus between unique elements in the navigation grid.
fn navigate_focus(&mut self, forward: bool) {
// Collect unique elements in grid order.
let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col)
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));
}
}
}
let current = self.selected_coords;
let current_pos = positions
.iter()
.position(|&(r, c)| r == current.0 && c == current.1);
let positions = MainFocus::ORDER
.into_iter()
.filter(|focus| {
!focus.is_metric()
|| (self.graphs_open && self.layout_width.load(Ordering::Relaxed) >= 70)
})
.collect::<Vec<_>>();
let current_pos = positions.iter().position(|focus| *focus == self.focus);
let next_pos = if let Some(idx) = current_pos {
if forward {
@ -215,25 +153,17 @@ impl MainScreen {
0
};
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = positions[next_pos];
self.unfocus_current();
self.focus = positions[next_pos];
self.focus_current();
}
}
impl Screen for MainScreen {
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) {
impl IotaScreen for MainScreen {
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, hits: &mut HitMap) {
self.layout_width.store(rect.width, Ordering::Relaxed);
f.render_widget(
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
ratatui::widgets::Block::default().style(theme.surfaces.canvas),
rect,
);
let inner = rect;
@ -252,9 +182,9 @@ impl Screen for MainScreen {
let left_area = horizontal_chunks[0];
let right_area = horizontal_chunks[1];
hits.register(left_area, AppAction::FocusLogs);
hits.register(left_area, IotaMessage::Action(AppAction::FocusLogs));
if metrics_visible {
hits.register(right_area, AppAction::FocusMetrics);
hits.register(right_area, IotaMessage::Action(AppAction::FocusMetrics));
}
if inner.width >= 70 {
@ -277,21 +207,24 @@ impl Screen for MainScreen {
focused: false,
enabled: true,
},
context.theme,
theme,
);
hits.register(
metrics_button,
IotaMessage::Action(AppAction::ToggleMetrics),
);
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);
hits.register(left_rows[1], IotaMessage::Action(AppAction::FocusConsole));
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[0], context);
if let Some(log) = self.elements.first() {
log.as_element().render(f, left_rows[0], theme);
}
if let Some(console) = self.elements.get(1) {
console.as_element().render(f, left_rows[1], context);
console.as_element().render(f, left_rows[1], theme);
}
let graph_elements: Vec<_> = self
@ -310,107 +243,114 @@ impl Screen for MainScreen {
.split(right_area);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area, context);
el.as_element().render(f, *area, theme);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
if let UiEvent::Pointer {
event,
hit: Some(IotaMessage::Action(AppAction::FocusLogs)),
} = &event
{
let Some(log) = self
.elements
.get_mut(0)
.and_then(|element| element.as_any_mut().downcast_mut::<LogCard>())
else {
return IotaInteraction::Unhandled;
};
match event.kind {
MouseEventKind::ScrollUp => log.scroll_older(),
MouseEventKind::ScrollDown => log.scroll_newer(),
_ => return IotaInteraction::Unhandled,
}
self.unfocus_current();
self.focus = MainFocus::Logs;
self.focus_current();
return IotaInteraction::Changed;
}
if let UiEvent::Paste(text) = &event {
if self.selected_coords == (2, 0) {
if let Some(console) = self
if self.focus == MainFocus::Console
&& 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;
}
{
console.handle_paste(text);
return IotaInteraction::Changed;
}
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
}
if let UiEvent::Resize(width, _) = &event {
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);
if *width < 70 && self.focus.is_metric() {
self.unfocus_current();
self.focus = MainFocus::Logs;
self.focus_current();
}
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::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 self.focus == MainFocus::Console
&& !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
&& let Some(console) = self.elements.get_mut(1)
{
if let Some(console) = self.elements.get_mut(1) {
return console.interact(event);
}
return console.interact(event);
}
match event.code {
KeyCode::Tab => {
self.navigate_focus(true);
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
KeyCode::BackTab => {
self.navigate_focus(false);
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
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)),
};
return IotaInteraction::Effect(IotaEffect::OpenOverview);
}
KeyCode::Char('u') | KeyCode::Char('U') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenUsers)
}),
};
return IotaInteraction::Effect(IotaEffect::OpenUsers);
}
KeyCode::Char('m') | KeyCode::Char('M') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics)
}),
};
return IotaInteraction::Effect(IotaEffect::OpenMetrics);
}
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
KeyCode::Enter | KeyCode::Char(' ') if self.focus.is_metric() => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
_ => {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
if let Some(el) = self.elements.get_mut(*index) {
let result = el.interact(event);
if matches!(result, InteractionResult::Unhandled) {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
_ => {}
}
if let Some(el) = self.elements.get_mut(self.focus.element_index()) {
let result = el.interact(event);
if matches!(result, IotaInteraction::Unhandled) {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
_ => {}
}
return result;
}
return result;
}
}
}
InteractionResult::Handled
IotaInteraction::Changed
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
match action {
AppAction::ToggleMetrics => {
self.graphs_open = !self.graphs_open;
@ -419,37 +359,33 @@ impl Screen for MainScreen {
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'))))
IotaInteraction::Changed
}
AppAction::OpenOverview => IotaInteraction::Effect(IotaEffect::OpenOverview),
AppAction::OpenUsers => IotaInteraction::Effect(IotaEffect::OpenUsers),
AppAction::FocusLogs => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.unfocus_current();
self.focus = MainFocus::Logs;
self.focus_current();
InteractionResult::Handled
IotaInteraction::Changed
}
AppAction::FocusConsole => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (2, 0);
self.unfocus_current();
self.focus = MainFocus::Console;
self.focus_current();
InteractionResult::Handled
IotaInteraction::Changed
}
AppAction::FocusMetrics => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 1);
self.unfocus_current();
self.focus = MainFocus::Ram;
self.focus_current();
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) {
if self.focus == MainFocus::Console {
vec![
KeyHint {
keys: "Enter",
@ -468,7 +404,7 @@ impl Screen for MainScreen {
action: "Header",
},
]
} else if self.selected_coords == (0, 0) {
} else if self.focus == MainFocus::Logs {
vec![
KeyHint {
keys: "J/K",

View file

@ -1,18 +1,13 @@
use crossterm::event::{self, Event, KeyCode};
use crate::{
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
theme::{ResolvedTheme, TextSemantics},
};
use crossterm::event::KeyCode;
use ratatui::{
DefaultTerminal,
prelude::*,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{any::Any, time::Duration};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, Screen, UiEvent},
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
pub struct FileViewer {
title: String,
@ -21,25 +16,19 @@ pub struct FileViewer {
scroll_x: u16,
}
impl Screen for FileViewer {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
impl IotaScreen for FileViewer {
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
self.draw(f, rect, theme);
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
self.draw(f, rect, context.theme);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
match event.code {
KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen;
return IotaInteraction::Pop;
}
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
@ -52,7 +41,7 @@ impl Screen for FileViewer {
_ => {}
}
InteractionResult::Unhandled
IotaInteraction::Unhandled
}
}
@ -65,26 +54,6 @@ impl FileViewer {
scroll_x: 0,
}
}
pub fn force_popup(mut self, mut terminal: DefaultTerminal) -> DefaultTerminal {
loop {
terminal
.draw(|f| {
let area = f.area();
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
})
.unwrap();
if event::poll(Duration::from_millis(100)).unwrap() {
let ev = event::read().unwrap();
self.handle_event(&ev);
if matches!(ev, Event::Key(k) if k.code == KeyCode::Char('q')) {
break;
}
}
}
terminal
}
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
use ratatui::text::Text;
@ -147,20 +116,6 @@ impl FileViewer {
f.render_widget(paragraph, area);
}
pub fn handle_event(&mut self, event: &Event) {
if let Event::Key(key) = event {
match key.code {
KeyCode::Down => self.scroll = self.scroll.saturating_add(1),
KeyCode::Up => self.scroll = self.scroll.saturating_sub(1),
KeyCode::PageDown => self.scroll = self.scroll.saturating_add(10),
KeyCode::PageUp => self.scroll = self.scroll.saturating_sub(10),
KeyCode::Right => self.scroll_x = self.scroll_x.saturating_add(2),
KeyCode::Left => self.scroll_x = self.scroll_x.saturating_sub(2),
_ => {}
}
}
}
}
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
let mut lines_vec = Vec::new();

View file

@ -1,5 +1,3 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
@ -7,15 +5,15 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
};
use crate::theme::ResolvedTheme;
use crate::{
elements::{
elements::Element,
graph_card::{GRAPHS, GraphCard},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
ui::UI,
screens::screens::{
AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent,
},
};
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
@ -26,18 +24,17 @@ pub struct MetricsScreen {
}
impl MetricsScreen {
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
let state = ui.client_state().await?;
pub fn from_state(state: iota_state::ClientState) -> Self {
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()),
GraphCard::new(state.clone(), GRAPHS::Ram, "RAM".into()),
GraphCard::new(state.clone(), GRAPHS::Cpu, "CPU".into()),
GraphCard::new(state, GRAPHS::Ping, "Ping".into()),
],
range_index: 0,
};
screen.apply_range();
Some(screen)
screen
}
fn apply_range(&mut self) {
@ -54,26 +51,18 @@ impl MetricsScreen {
}
}
impl Screen for MetricsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
impl IotaScreen for MetricsScreen {
fn render_legacy(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
theme: &ResolvedTheme,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Metrics ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
.border_style(theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
@ -88,32 +77,31 @@ impl Screen for MetricsScreen {
"Range: {} ({} samples) Left/Right to change",
RANGES[self.range_index].1, RANGES[self.range_index].0
))
.style(context.theme.text.heading),
.style(theme.text.heading),
rows[0],
);
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
graph.render(frame, *graph_area, context);
graph.render(frame, *graph_area, theme);
}
hits.register(rows[0], AppAction::OpenMetrics);
hits.register(rows[0], IotaMessage::Action(AppAction::OpenMetrics));
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
match key.code {
KeyCode::Left => {
self.change_range(-1);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Right => {
self.change_range(1);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
_ => IotaInteraction::Unhandled,
}
}

View file

@ -1,9 +1,10 @@
use crate::theme::ResolvedTheme;
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},
screens::screens::{
AppAction, HitMap, IotaInteraction, IotaMessage, IotaScreen, KeyHint, UiEvent,
},
};
use crossterm::event::KeyCode;
use ratatui::{
@ -12,10 +13,7 @@ use ratatui::{
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::watch;
pub struct OverviewScreen {
@ -175,22 +173,15 @@ fn truncate_id(id: &str) -> String {
}
}
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) {
impl IotaScreen for OverviewScreen {
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _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)
.border_style(theme.borders.normal)
.title_style(theme.borders.title);
let inner = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Overview", false, theme)
} else {
let inner = block.inner(rect);
f.render_widget(block, rect);
@ -202,7 +193,7 @@ impl Screen for OverviewScreen {
])
.split(inner);
let lines = self.build_lines(context.theme);
let lines = self.build_lines(theme);
self.content_height.store(lines.len(), Ordering::Relaxed);
self.viewport_height
.store(rows[0].height as usize, Ordering::Relaxed);
@ -219,30 +210,29 @@ impl Screen for OverviewScreen {
focused: self._focus == Focus::Back,
enabled: true,
},
context.theme,
theme,
);
_hits.register(rows[1], AppAction::Back);
_hits.register(rows[1], IotaMessage::Action(AppAction::Back));
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
match event.code {
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
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
IotaInteraction::Changed
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::PageDown => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
@ -251,32 +241,32 @@ impl Screen for OverviewScreen {
.load(Ordering::Relaxed)
.saturating_sub(page);
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::PageUp => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.scroll_offset = self.scroll_offset.saturating_sub(page);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Home => {
self.scroll_offset = 0;
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::End => {
self.scroll_offset = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
if action == AppAction::Back {
InteractionResult::CloseScreen
IotaInteraction::Pop
} else {
InteractionResult::Unhandled
IotaInteraction::Unhandled
}
}
fn key_hints(&self) -> Vec<KeyHint> {

View file

@ -1,45 +1,21 @@
use std::any::Any;
use std::{fmt, path::PathBuf};
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{Frame, layout::Rect};
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crate::theme::{CliOutputFormat, ResolvedTheme, TerminalPolicy, ThemeName};
/// 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,
cli_output: crate::theme::CliOutputFormat,
cli_require_confirmation: bool,
},
ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
TuInspected(Result<iota_ipc::TuCredentialPreview, String>),
UserOperationFinished(Result<String, String>),
CredentialExportFinished(Result<String, String>),
RegenerateKeysRequested,
KeysRegenerated(Result<(), String>),
TermsLoaded {
kind: iota_terms::TermsType,
result: Result<String, String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -65,61 +41,122 @@ pub enum AppAction {
RegenerateKeys,
}
#[derive(Clone, Debug)]
pub enum IotaEffect {
OpenUsers,
OpenMetrics,
OpenOverview,
ApplyTheme {
theme: ThemeName,
persist: bool,
},
SaveSettings {
theme: ThemeName,
color: TerminalPolicy,
unicode: TerminalPolicy,
cli_output: CliOutputFormat,
cli_require_confirmation: bool,
},
RegenerateKeys,
InspectTu {
credential: iota_ipc::SecretString,
},
AttachUserFromTu {
credential: iota_ipc::SecretString,
},
RunUserOperation {
action: crate::screens::users::model::UserAction,
user_id: i64,
},
ExportCredential {
user_id: i64,
destination: PathBuf,
},
CreateUser {
username: String,
},
CreateInvitation {
authority: iota_ipc::InvitationAuthority,
},
RefreshUsers,
LoadNewestTerms {
kind: iota_terms::TermsType,
},
}
#[derive(Clone, Debug)]
pub enum IotaMessage {
Action(AppAction),
Effect(IotaEffect),
Event(AppEvent),
Invalidate,
}
pub type UiEvent = ratatool::UiEvent<IotaMessage>;
pub type HitMap = ratatool::HitMap<IotaMessage>;
pub type NavDirection = ratatool::Direction;
#[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,
pub enum IotaInteraction {
Unhandled,
Changed,
Push {
screen: Box<dyn IotaScreen>,
presentation: ratatool::ScreenPresentation,
},
Pop,
Effect(IotaEffect),
Exit,
}
#[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)
impl fmt::Debug for IotaInteraction {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unhandled => formatter.write_str("Unhandled"),
Self::Changed => formatter.write_str("Changed"),
Self::Push { presentation, .. } => {
formatter.debug_tuple("Push").field(presentation).finish()
}
Self::Pop => formatter.write_str("Pop"),
Self::Effect(effect) => formatter.debug_tuple("Effect").field(effect).finish(),
Self::Exit => formatter.write_str("Exit"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
Up,
Down,
Left,
Right,
Next,
Prev,
}
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<'_>, hits: &mut HitMap);
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
InteractionResult::Unhandled
pub trait IotaScreen: Send {
fn render(
&mut self,
ui: &mut ratatool::UiFrame<'_, '_, IotaMessage>,
area: Rect,
theme: &ResolvedTheme,
) {
let mut hits = HitMap::new();
self.render_legacy(ui.frame(), area, theme, &mut hits);
for region in hits.regions() {
ui.hit_map().register(region.area, region.action.clone());
}
}
fn render_legacy(
&self,
frame: &mut Frame,
area: Rect,
theme: &ResolvedTheme,
hits: &mut HitMap,
);
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction;
fn handle_action(&mut self, _action: AppAction) -> IotaInteraction {
IotaInteraction::Unhandled
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
@ -141,3 +178,7 @@ pub trait Screen: Send + Sync + Any {
]
}
}
pub fn action_message(action: AppAction) -> IotaMessage {
IotaMessage::Action(action)
}

View file

@ -1,5 +1,3 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
@ -8,11 +6,13 @@ use ratatui::{
widgets::{Block, Borders, Paragraph},
};
use crate::theme::ResolvedTheme;
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
screens::screens::{
AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, KeyHint,
UiEvent,
},
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
};
@ -68,11 +68,9 @@ impl SettingsScreen {
ThemeName::ALL[self.selected]
}
fn apply(&self, persist: bool) -> InteractionResult {
fn apply(&self, persist: bool) -> IotaInteraction {
let theme = self.selected_theme();
InteractionResult::AppTask {
task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
}
IotaInteraction::Effect(IotaEffect::ApplyTheme { theme, persist })
}
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
@ -103,18 +101,16 @@ impl SettingsScreen {
};
}
fn activate(&mut self) -> InteractionResult {
fn activate(&mut self) -> IotaInteraction {
if self.pending {
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
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) }),
};
return IotaInteraction::Effect(IotaEffect::RegenerateKeys);
}
}
}
@ -124,7 +120,7 @@ impl SettingsScreen {
}
Focus::CliOutput => {
self.message = "Output format updated.".into();
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
Focus::CliConfirm => {
self.cli_require_confirmation = !self.cli_require_confirmation;
@ -136,53 +132,41 @@ impl SettingsScreen {
"Off"
},
);
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
Focus::Back => return InteractionResult::CloseScreen,
Focus::Back => return IotaInteraction::Pop,
}
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,
})
}),
}
IotaInteraction::Effect(IotaEffect::SaveSettings {
theme,
color,
unicode,
cli_output,
cli_require_confirmation,
})
}
}
impl Screen for SettingsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
impl IotaScreen for SettingsScreen {
fn render_legacy(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
theme: &ResolvedTheme,
hits: &mut HitMap,
) {
let header_block = Block::default()
.title(" Settings ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused);
.border_style(theme.borders.focused);
let inner = header_block.inner(area);
frame.render_widget(header_block, area);
@ -200,7 +184,7 @@ impl Screen for SettingsScreen {
self.color,
self.unicode,
))
.style(context.theme.text.heading),
.style(theme.text.heading),
sections[0],
);
@ -218,8 +202,8 @@ impl Screen for SettingsScreen {
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(Span::styled(&cli_line, context.theme.text.normal)),
Line::from(Span::styled(&self.message, theme.text.normal)),
Line::from(Span::styled(&cli_line, theme.text.normal)),
Line::from("Preview"),
Line::from("[OK] Healthy"),
Line::from("[WARN] Degraded"),
@ -227,7 +211,7 @@ impl Screen for SettingsScreen {
Line::from("> Focused action <"),
];
frame.render_widget(
Paragraph::new(lines).style(context.theme.text.normal),
Paragraph::new(lines).style(theme.text.normal),
bottom_rows[0],
);
@ -247,9 +231,9 @@ impl Screen for SettingsScreen {
focused: self.focus == Focus::Back && self.dialog.is_none(),
enabled: true,
},
context.theme,
theme,
);
hits.register(buttons_area[0], AppAction::Back);
hits.register(buttons_area[0], IotaMessage::Action(AppAction::Back));
render_button(
frame,
@ -260,15 +244,18 @@ impl Screen for SettingsScreen {
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
enabled: !self.pending,
},
context.theme,
theme,
);
hits.register(
buttons_area[1],
IotaMessage::Action(AppAction::RegenerateKeys),
);
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(
frame.render_widget(Block::default().style(theme.surfaces.overlay), area);
let popup = ratatool::layout::centered_rect(
area,
crate::layout::fit::RequiredSize {
ratatool::layout::RequiredSize {
width: 42,
height: 7,
},
@ -276,14 +263,14 @@ impl Screen for SettingsScreen {
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
.border_style(theme.borders.focused)
.style(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),
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
@ -298,7 +285,7 @@ impl Screen for SettingsScreen {
focused: false,
enabled: true,
},
context.theme,
theme,
);
render_button(
frame,
@ -309,16 +296,23 @@ impl Screen for SettingsScreen {
focused: true,
enabled: true,
},
context.theme,
theme,
);
hits.register(
dialog_buttons[0],
IotaMessage::Action(AppAction::CancelDialog),
);
hits.register(
dialog_buttons[1],
IotaMessage::Action(AppAction::ConfirmDialog),
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let event = match event {
UiEvent::App(AppEvent::ThemeSaved(result)) => {
UiEvent::Message(IotaMessage::Event(AppEvent::ThemeSaved(result))) => {
match result {
Ok(()) => {
self.saved = self.selected_theme();
@ -326,36 +320,36 @@ impl Screen for SettingsScreen {
}
Err(error) => self.message = error,
}
return InteractionResult::Handled;
return IotaInteraction::Changed;
}
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
UiEvent::Message(IotaMessage::Event(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;
return IotaInteraction::Changed;
}
event => event,
};
if self.dialog.is_some() {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
return match key.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
_ => IotaInteraction::Changed,
};
}
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
match key.code {
KeyCode::Left => {
@ -363,7 +357,7 @@ impl Screen for SettingsScreen {
self.selected = self.selected.saturating_sub(1);
self.apply(false)
} else {
InteractionResult::Handled
IotaInteraction::Changed
}
}
KeyCode::Right => {
@ -371,30 +365,30 @@ impl Screen for SettingsScreen {
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
self.apply(false)
} else {
InteractionResult::Handled
IotaInteraction::Changed
}
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.color = Self::next_policy(self.color);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.unicode = Self::next_policy(self.unicode);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Char('l') | KeyCode::Char('L') => {
self.cli_output = self.cli_output.next();
self.message = format!("CLI output: {:?}", self.cli_output);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Char('k') | KeyCode::Char('K') => {
self.cli_require_confirmation = !self.cli_require_confirmation;
@ -406,18 +400,16 @@ impl Screen for SettingsScreen {
"Off"
},
);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => IotaInteraction::Pop,
_ => IotaInteraction::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
fn handle_action(&mut self, action: AppAction) -> IotaInteraction {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::Back => IotaInteraction::Pop,
AppAction::RegenerateKeys => {
self.focus = Focus::RegenerateKeys;
self.activate()
@ -425,9 +417,9 @@ impl Screen for SettingsScreen {
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}

View file

@ -1,10 +1,9 @@
use crate::theme::ResolvedTheme;
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{
md_viewer::FileViewer,
screens::{HitMap, Screen, UiEvent},
screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
@ -16,7 +15,6 @@ use ratatui::{
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::{any::Any, pin::Pin};
use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -50,15 +48,8 @@ impl TermsCheckerScreen {
}
}
impl Screen for TermsCheckerScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
impl IotaScreen for TermsCheckerScreen {
fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -176,7 +167,7 @@ impl Screen for TermsCheckerScreen {
focused: self.focus == Focus::Eula,
enabled: true,
},
context.theme,
theme,
),
render_choice_line(
tos_text,
@ -186,7 +177,7 @@ impl Screen for TermsCheckerScreen {
focused: self.focus == Focus::Tos,
enabled: self.eula,
},
context.theme,
theme,
),
render_choice_line(
pp_text,
@ -196,7 +187,7 @@ impl Screen for TermsCheckerScreen {
focused: self.focus == Focus::Pp,
enabled: self.eula,
},
context.theme,
theme,
),
Line::from(""),
Line::from("¹ Necessary– required to run the program"),
@ -217,19 +208,19 @@ impl Screen for TermsCheckerScreen {
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
context.theme.status.success
theme.status.success
} else if size.width >= 60 {
context.theme.status.warning
theme.status.warning
} else {
context.theme.status.error
theme.status.error
};
let height_style = if size.height > 19 {
context.theme.status.success
theme.status.success
} else if size.height >= 13 {
context.theme.status.warning
theme.status.warning
} else {
context.theme.status.error
theme.status.error
};
let warning_text = Text::from(vec![
@ -271,13 +262,14 @@ impl Screen for TermsCheckerScreen {
true,
false,
true,
context.theme,
theme,
);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
@ -293,21 +285,21 @@ impl Screen for TermsCheckerScreen {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
KeyCode::Up | KeyCode::Left => {
self.focus.prev(&possible_states);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.focus.next(&possible_states);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
@ -322,77 +314,75 @@ impl Screen for TermsCheckerScreen {
.iter()
.find(|document| document.kind() == terms_type)
.cloned();
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
Box::pin(async move {
if let Some(document) = document {
let screen: FileViewer =
FileViewer::new(terms_type.to_string(), document.content());
Box::new(screen) as Box<dyn Screen>
} else {
let screen: FileViewer = FileViewer::new(
"Error".to_string(),
"The fetched legal document is unavailable.",
);
Box::new(screen) as Box<dyn Screen>
}
});
InteractionResult::OpenFutureScreen { screen: fut }
let screen = match document {
Some(document) => {
FileViewer::new(terms_type.to_string(), document.content())
}
None => FileViewer::new(
"Error".to_string(),
"The fetched legal document is unavailable.",
),
};
IotaInteraction::Push {
screen: Box::new(screen),
presentation: ratatool::ScreenPresentation::Replace,
}
} else {
InteractionResult::Unhandled
IotaInteraction::Unhandled
}
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_link(TermsType::EULA));
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Tos => {
let _ = open::that(get_link(TermsType::TOS));
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Pp => {
let _ = open::that(get_link(TermsType::PP));
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
},
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
Focus::Eula => {
self.eula = !self.eula;
self.tos = false;
self.pp = false;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Tos if self.eula => {
self.tos = !self.tos;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Pp if self.eula => {
self.pp = !self.pp;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Cancel => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
Focus::Continue if self.eula => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptEULA);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
Focus::ContinueAll if self.eula && self.tos && self.pp => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptAll);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
},
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}
}

View file

@ -1,24 +1,24 @@
use crate::screens::terms_checker::UserChoice;
use crate::theme::ResolvedTheme;
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{
md_viewer::FileViewer,
screens::{HitMap, Screen, UiEvent},
screens::{
AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen, UiEvent,
},
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::KeyCode;
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
use iota_terms::{Doc, TermsType, get_newest_link};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use tokio::sync::oneshot;
#[derive(Debug, Clone)]
@ -115,16 +115,8 @@ impl TermsUpdaterScreen {
}
}
}
impl Screen for TermsUpdaterScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
impl IotaScreen for TermsUpdaterScreen {
fn render_legacy(&self, f: &mut Frame, size: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
let checkbox = |label, selected, focused, enabled| {
render_choice_line(
label,
@ -134,7 +126,7 @@ impl Screen for TermsUpdaterScreen {
focused,
enabled,
},
context.theme,
theme,
)
};
let mut needed_height = 5;
@ -536,19 +528,19 @@ impl Screen for TermsUpdaterScreen {
};
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
context.theme.status.success
theme.status.success
} else if size.width >= 60 {
context.theme.status.warning
theme.status.warning
} else {
context.theme.status.error
theme.status.error
};
let height_style = if size.height > 20 {
context.theme.status.success
theme.status.success
} else if size.height >= (header_lines as u16 + 10) {
context.theme.status.warning
theme.status.warning
} else {
context.theme.status.error
theme.status.error
};
let warning_text = Text::from(vec![
@ -592,13 +584,27 @@ impl Screen for TermsUpdaterScreen {
self.update_needed,
downgrade_scenario,
self.pp_needed || self.tos_needed,
context.theme,
theme,
);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
fn handle_event(&mut self, event: &UiEvent) -> IotaInteraction {
let event = event.clone();
let event = match event {
UiEvent::Message(IotaMessage::Event(AppEvent::TermsLoaded { kind, result })) => {
let screen = match result {
Ok(content) => FileViewer::new(kind.to_string(), &content),
Err(error) => FileViewer::new("Error".to_string(), &error),
};
return IotaInteraction::Push {
screen: Box::new(screen),
presentation: ratatool::ScreenPresentation::Replace,
};
}
event => event,
};
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
return IotaInteraction::Unhandled;
};
let mut possible_states = Vec::new();
@ -626,42 +632,42 @@ impl Screen for TermsUpdaterScreen {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
return InteractionResult::CloseScreen;
return IotaInteraction::Pop;
}
KeyCode::Enter | KeyCode::Char(' ') => match self.focus {
Focus::Eula => {
self.eula = !self.eula;
self.tos = !self.tos_needed;
self.pp = !self.pp_needed;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Tos if self.eula => {
self.tos = !self.tos;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Pp if self.eula => {
self.pp = !self.pp;
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Cancel => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::Deny);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
Focus::Continue if self.eula => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptEULA);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
Focus::ContinueAll if self.eula && self.tos && self.pp => {
if let Some(sender) = self.sender.take() {
let _ = sender.send(UserChoice::AcceptAll);
}
InteractionResult::CloseScreen
IotaInteraction::Pop
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
},
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
@ -672,47 +678,37 @@ impl Screen for TermsUpdaterScreen {
};
if let Some(terms_type) = terms_type {
let fut = Box::pin(async move {
if let Some(content) = get_terms(terms_type.clone()).await {
Box::new(FileViewer::new(terms_type.to_string(), &content))
as Box<dyn Screen>
} else {
Box::new(FileViewer::new(
"Error".to_string(),
"Could not connect to the legal endpoint to fetch the document. Please check your internet connection.",
)) as Box<dyn Screen>
}
return IotaInteraction::Effect(IotaEffect::LoadNewestTerms {
kind: terms_type,
});
return InteractionResult::OpenFutureScreen { screen: fut };
} else {
InteractionResult::Unhandled
IotaInteraction::Unhandled
}
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_newest_link(TermsType::EULA));
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Tos => {
let _ = open::that(get_newest_link(TermsType::TOS));
InteractionResult::Handled
IotaInteraction::Changed
}
Focus::Pp => {
let _ = open::that(get_newest_link(TermsType::PP));
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
},
KeyCode::Up | KeyCode::Left => {
self.focus.prev(&possible_states);
InteractionResult::Handled
IotaInteraction::Changed
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.focus.next(&possible_states);
InteractionResult::Handled
IotaInteraction::Changed
}
_ => InteractionResult::Unhandled,
_ => IotaInteraction::Unhandled,
}
}
}

View file

@ -1,5 +1,5 @@
use super::model::{UserAction, UserActionGroup};
use crate::controls::menu::{MenuItem, MenuState};
use ratatool::{MenuItem, MenuState};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserActionMenu {

View file

@ -1,7 +1,4 @@
use crate::controls::{
menu::{MenuItem, MenuState},
text_input::TextInput,
};
use ratatool::{MenuItem, MenuState, TextInputState};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddUserMethod {
@ -26,8 +23,8 @@ pub struct AddUserFlow {
pub phase: AddUserPhase,
pub methods: MenuState<AddUserMethod>,
pub invitation_authorities: MenuState<iota_ipc::InvitationAuthority>,
pub username: TextInput,
pub import_path: TextInput,
pub username: TextInputState,
pub import_path: TextInputState,
pub credential: Option<iota_ipc::SecretString>,
pub preview: Option<iota_ipc::TuCredentialPreview>,
pub error: Option<String>,
@ -77,8 +74,8 @@ impl AddUserFlow {
disabled_reason: Some("Local invitations are not implemented yet".into()),
},
]),
username: TextInput::new("Username"),
import_path: TextInput::new("TU path"),
username: TextInputState::default(),
import_path: TextInputState::default(),
credential: None,
preview: None,
error: None,

View file

@ -1,5 +1,5 @@
use super::UserEntry;
use crate::controls::text_input::TextInput;
use ratatool::TextInputState;
use std::{
fs::{self, OpenOptions},
io::{self, Write},
@ -9,7 +9,7 @@ use std::{
#[derive(Clone, Debug)]
pub struct CredentialExportState {
pub user: UserEntry,
pub destination: TextInput,
pub destination: TextInputState,
pub error: Option<String>,
}
@ -17,7 +17,7 @@ impl CredentialExportState {
pub fn new(user: UserEntry) -> Self {
Self {
user,
destination: TextInput::new("Destination"),
destination: TextInputState::default(),
error: None,
}
}

View file

@ -13,11 +13,11 @@ pub fn empty_message(supported: bool) -> &'static str {
pub fn render(
frame: &mut ratatui::Frame,
area: ratatui::layout::Rect,
context: &crate::render_context::RenderContext<'_>,
theme: &crate::theme::ResolvedTheme,
supported: bool,
) {
frame.render_widget(
ratatui::widgets::Paragraph::new(empty_message(supported)).style(context.theme.text.muted),
ratatui::widgets::Paragraph::new(empty_message(supported)).style(theme.text.muted),
area,
);
}

File diff suppressed because it is too large Load diff