[Fix] Connection Management
This commit is contained in:
parent
9e9e3597da
commit
3f2ac18333
122 changed files with 19970 additions and 5263 deletions
|
|
@ -1,7 +0,0 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ControlAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Select,
|
||||
Activate,
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
pub struct CheckboxItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: HashSet<T>,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxGroupError {
|
||||
Empty,
|
||||
DuplicateValue,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxChange<T> {
|
||||
Selected(T),
|
||||
Deselected(T),
|
||||
IgnoredDisabled(T),
|
||||
NoItem,
|
||||
}
|
||||
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: impl IntoIterator<Item = T>,
|
||||
) -> Result<Self, CheckboxGroupError> {
|
||||
let mut values = HashSet::new();
|
||||
if items.iter().any(|item| !values.insert(item.value.clone())) {
|
||||
return Err(CheckboxGroupError::DuplicateValue);
|
||||
}
|
||||
let selected = selected
|
||||
.into_iter()
|
||||
.filter(|value| values.contains(value))
|
||||
.collect();
|
||||
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[CheckboxItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &HashSet<T> {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
|
||||
self.items.get(self.focused_index)
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
for step in 1..=self.items.len() {
|
||||
let current = self.focused_index as isize;
|
||||
let delta = if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let raw = current + delta;
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
|
||||
let Some(item) = self.items.get(self.focused_index) else {
|
||||
return CheckboxChange::NoItem;
|
||||
};
|
||||
let value = item.value.clone();
|
||||
if !item.enabled {
|
||||
return CheckboxChange::IgnoredDisabled(value);
|
||||
}
|
||||
if self.selected.remove(&value) {
|
||||
CheckboxChange::Deselected(value)
|
||||
} else {
|
||||
self.selected.insert(value.clone());
|
||||
CheckboxChange::Selected(value)
|
||||
}
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
|
||||
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
|
||||
item.enabled = enabled;
|
||||
}
|
||||
}
|
||||
pub fn set_selected(&mut self, value: T, selected: bool) {
|
||||
if selected {
|
||||
self.selected.insert(value);
|
||||
} else {
|
||||
self.selected.remove(&value);
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: self.selected.contains(value),
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ use crate::ipc_client::{DaemonStatus, IpcConnectionState};
|
|||
use crate::theme::ResolvedTheme;
|
||||
use crate::{
|
||||
controls::button::ButtonIntent,
|
||||
screens::screens::{AppAction, HitMap},
|
||||
screens::screens::{AppAction, HitMap, IotaMessage},
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
|
|
@ -119,7 +119,7 @@ pub fn render_header(
|
|||
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
|
||||
brand_area,
|
||||
);
|
||||
hits.register(brand_area, AppAction::OpenMain);
|
||||
hits.register(brand_area, IotaMessage::Action(AppAction::OpenMain));
|
||||
|
||||
for (index, item) in HEADER_ITEMS.iter().enumerate() {
|
||||
let top = cells[index + 1];
|
||||
|
|
@ -150,6 +150,6 @@ pub fn render_header(
|
|||
]),
|
||||
button_area,
|
||||
);
|
||||
hits.register(button_area, item.action);
|
||||
hits.register(button_area, IotaMessage::Action(item.action));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
use crossterm::event::KeyCode;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MenuItem<T> {
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub value: T,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MenuState<T> {
|
||||
items: Vec<MenuItem<T>>,
|
||||
selected: Option<usize>,
|
||||
}
|
||||
|
||||
impl<T> MenuState<T> {
|
||||
pub fn new(items: Vec<MenuItem<T>>) -> Self {
|
||||
let selected = items.iter().position(|item| item.enabled);
|
||||
Self { items, selected }
|
||||
}
|
||||
|
||||
pub fn items(&self) -> &[MenuItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected_index(&self) -> Option<usize> {
|
||||
self.selected
|
||||
}
|
||||
pub fn selected_item(&self) -> Option<&MenuItem<T>> {
|
||||
self.selected.and_then(|index| self.items.get(index))
|
||||
}
|
||||
|
||||
pub fn move_next(&mut self) {
|
||||
self.move_by(1);
|
||||
}
|
||||
pub fn move_previous(&mut self) {
|
||||
self.move_by(-1);
|
||||
}
|
||||
pub fn first(&mut self) {
|
||||
self.selected = self.items.iter().position(|item| item.enabled);
|
||||
}
|
||||
pub fn last(&mut self) {
|
||||
self.selected = self.items.iter().rposition(|item| item.enabled);
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyCode) -> bool {
|
||||
match key {
|
||||
KeyCode::Up => self.move_previous(),
|
||||
KeyCode::Down => self.move_next(),
|
||||
KeyCode::Home => self.first(),
|
||||
KeyCode::End => self.last(),
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn move_by(&mut self, delta: isize) {
|
||||
let Some(current) = self.selected else {
|
||||
return;
|
||||
};
|
||||
for offset in 1..=self.items.len() {
|
||||
let next = (current as isize + delta * offset as isize)
|
||||
.rem_euclid(self.items.len() as isize) as usize;
|
||||
if self.items[next].enabled {
|
||||
self.selected = Some(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn navigation_skips_disabled_items() {
|
||||
let mut menu = MenuState::new(vec![
|
||||
MenuItem {
|
||||
label: "A".into(),
|
||||
description: None,
|
||||
value: 1,
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
},
|
||||
MenuItem {
|
||||
label: "B".into(),
|
||||
description: None,
|
||||
value: 2,
|
||||
enabled: false,
|
||||
disabled_reason: Some("Unavailable".into()),
|
||||
},
|
||||
MenuItem {
|
||||
label: "C".into(),
|
||||
description: None,
|
||||
value: 3,
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
},
|
||||
]);
|
||||
menu.move_next();
|
||||
assert_eq!(menu.selected_index(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_enabled_set_is_safe() {
|
||||
let mut menu = MenuState::new(vec![MenuItem {
|
||||
label: "A".into(),
|
||||
description: None,
|
||||
value: (),
|
||||
enabled: false,
|
||||
disabled_reason: None,
|
||||
}]);
|
||||
menu.move_next();
|
||||
menu.last();
|
||||
assert_eq!(menu.selected_index(), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,4 @@
|
|||
pub mod action;
|
||||
pub mod button;
|
||||
pub mod checkbox_group;
|
||||
pub mod choice;
|
||||
pub mod dialog;
|
||||
pub mod header;
|
||||
pub mod menu;
|
||||
pub mod navigation;
|
||||
pub mod panel;
|
||||
pub mod radio_group;
|
||||
pub mod scroll;
|
||||
pub mod text_input;
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DisabledFocusPolicy {
|
||||
Include,
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
|
||||
pub struct RadioItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct RadioGroup<T: Clone + Eq> {
|
||||
items: Vec<RadioItem<T>>,
|
||||
selected: T,
|
||||
default: T,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
disabled_selection_policy: DisabledSelectionPolicy,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioGroupError {
|
||||
Empty,
|
||||
DefaultMissing,
|
||||
DefaultDisabled,
|
||||
NoEnabledItems,
|
||||
SelectedItemDisabled,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioChange<T> {
|
||||
Changed { previous: T, selected: T },
|
||||
Unchanged(T),
|
||||
IgnoredDisabled(T),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisabledSelectionPolicy {
|
||||
UseConfiguredDefault,
|
||||
UseFirstEnabled,
|
||||
ReturnError,
|
||||
}
|
||||
impl<T: Clone + Eq> RadioGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<RadioItem<T>>,
|
||||
observed: Option<T>,
|
||||
default: T,
|
||||
) -> Result<Self, RadioGroupError> {
|
||||
if items.is_empty() {
|
||||
return Err(RadioGroupError::Empty);
|
||||
}
|
||||
let default_item = items
|
||||
.iter()
|
||||
.find(|item| item.value == default)
|
||||
.ok_or(RadioGroupError::DefaultMissing)?;
|
||||
if !default_item.enabled {
|
||||
return Err(RadioGroupError::DefaultDisabled);
|
||||
}
|
||||
let focused_index = items
|
||||
.iter()
|
||||
.position(|item| item.enabled)
|
||||
.ok_or(RadioGroupError::NoEnabledItems)?;
|
||||
let selected = observed
|
||||
.filter(|value| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.enabled && item.value == *value)
|
||||
})
|
||||
.unwrap_or_else(|| default.clone());
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
default,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[RadioItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &T {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> &RadioItem<T> {
|
||||
&self.items[self.focused_index]
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
for step in 1..=self.items.len() {
|
||||
let raw = self.focused_index as isize
|
||||
+ if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn select_focused(&mut self) -> RadioChange<T> {
|
||||
let item = self.focused_item();
|
||||
let enabled = item.enabled;
|
||||
let value = item.value.clone();
|
||||
if !enabled {
|
||||
return RadioChange::IgnoredDisabled(value);
|
||||
}
|
||||
if value == self.selected {
|
||||
RadioChange::Unchanged(self.selected.clone())
|
||||
} else {
|
||||
let previous = std::mem::replace(&mut self.selected, value);
|
||||
RadioChange::Changed {
|
||||
previous,
|
||||
selected: self.selected.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: &self.selected == value,
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
|
||||
self.disabled_selection_policy = policy;
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
|
||||
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.items[index].enabled == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if !enabled
|
||||
&& self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(other, item)| other == index || !item.enabled)
|
||||
{
|
||||
return Err(RadioGroupError::NoEnabledItems);
|
||||
}
|
||||
if !enabled && self.selected == *value {
|
||||
let replacement = match self.disabled_selection_policy {
|
||||
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.enabled && item.value == self.default)
|
||||
.map(|item| item.value.clone()),
|
||||
DisabledSelectionPolicy::UseConfiguredDefault => None,
|
||||
DisabledSelectionPolicy::UseFirstEnabled => self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(other, item)| *other != index && item.enabled)
|
||||
.map(|(_, item)| item.value.clone()),
|
||||
DisabledSelectionPolicy::ReturnError => {
|
||||
return Err(RadioGroupError::SelectedItemDisabled);
|
||||
}
|
||||
};
|
||||
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
|
||||
}
|
||||
self.items[index].enabled = enabled;
|
||||
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
|
||||
{
|
||||
self.focus_next();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn default(&self) -> &T {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
};
|
||||
|
||||
/// Reusable viewport policy for long, vertically stacked terminal content.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ScrollOptions {
|
||||
pub show_scrollbar: bool,
|
||||
pub render_partial_components: bool,
|
||||
}
|
||||
impl Default for ScrollOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_scrollbar: true,
|
||||
render_partial_components: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ScrollField {
|
||||
pub offset: u16,
|
||||
pub options: ScrollOptions,
|
||||
}
|
||||
impl ScrollField {
|
||||
pub fn up(&mut self, amount: u16) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) {
|
||||
self.offset = (self.offset.saturating_add(amount))
|
||||
.min(content_height.saturating_sub(viewport_height));
|
||||
}
|
||||
pub fn render(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
content: Paragraph<'_>,
|
||||
content_height: u16,
|
||||
) {
|
||||
frame.render_widget(content.scroll((self.offset, 0)), area);
|
||||
if self.options.show_scrollbar && content_height > area.height {
|
||||
let mut state =
|
||||
ScrollbarState::new(content_height as usize).position(self.offset as usize);
|
||||
frame.render_stateful_widget(
|
||||
Scrollbar::new(ScrollbarOrientation::VerticalRight),
|
||||
area,
|
||||
&mut state,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
use crossterm::event::KeyCode;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TextInput {
|
||||
value: String,
|
||||
cursor: usize,
|
||||
label: Option<String>,
|
||||
placeholder: Option<String>,
|
||||
validation: Option<String>,
|
||||
secret: bool,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn new(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: Some(label.into()),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
pub fn value(&self) -> &str {
|
||||
&self.value
|
||||
}
|
||||
pub fn display_value(&self) -> String {
|
||||
if self.secret {
|
||||
"•".repeat(self.value.chars().count())
|
||||
} else {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
pub fn set_placeholder(&mut self, value: impl Into<String>) {
|
||||
self.placeholder = Some(value.into());
|
||||
}
|
||||
pub fn placeholder(&self) -> Option<&str> {
|
||||
self.placeholder.as_deref()
|
||||
}
|
||||
pub fn label(&self) -> Option<&str> {
|
||||
self.label.as_deref()
|
||||
}
|
||||
pub fn validation(&self) -> Option<&str> {
|
||||
self.validation.as_deref()
|
||||
}
|
||||
pub fn set_validation(&mut self, value: Option<String>) {
|
||||
self.validation = value;
|
||||
}
|
||||
pub fn set_secret(&mut self, secret: bool) {
|
||||
self.secret = secret;
|
||||
}
|
||||
pub fn handle_key(&mut self, key: KeyCode) -> bool {
|
||||
match key {
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor > 0 {
|
||||
let start = self.value[..self.cursor]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0);
|
||||
self.value.drain(start..self.cursor);
|
||||
self.cursor = start;
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor < self.value.len() {
|
||||
let end = self.value[self.cursor..]
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map(|(index, _)| self.cursor + index)
|
||||
.unwrap_or(self.value.len());
|
||||
self.value.drain(self.cursor..end);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if self.cursor > 0 {
|
||||
self.cursor = self.value[..self.cursor]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map(|(index, _)| index)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor < self.value.len() {
|
||||
self.cursor = self.value[self.cursor..]
|
||||
.char_indices()
|
||||
.nth(1)
|
||||
.map(|(index, _)| self.cursor + index)
|
||||
.unwrap_or(self.value.len());
|
||||
}
|
||||
}
|
||||
KeyCode::Home => self.cursor = 0,
|
||||
KeyCode::End => self.cursor = self.value.len(),
|
||||
KeyCode::Char(character) => {
|
||||
self.value.insert(self.cursor, character);
|
||||
self.cursor += character.len_utf8();
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,11 @@ use std::{
|
|||
};
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::theme::ResolvedTheme;
|
||||
use crate::{
|
||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||
interaction_result::InteractionResult,
|
||||
ipc_client::IpcClient,
|
||||
render_context::RenderContext,
|
||||
screens::screens::IotaInteraction,
|
||||
util::borders::draw_block_joins,
|
||||
};
|
||||
|
||||
|
|
@ -300,18 +300,13 @@ impl Element for ConsoleCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
let inner = crate::controls::panel::render_panel(
|
||||
f,
|
||||
r,
|
||||
&self.title,
|
||||
self.focused,
|
||||
context.theme,
|
||||
);
|
||||
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) {
|
||||
if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
let inner =
|
||||
crate::controls::panel::render_panel(f, r, &self.title, self.focused, theme);
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
|
||||
.style(context.theme.console.text),
|
||||
Paragraph::new(Line::from(self.render_cursor_spans(theme)))
|
||||
.style(theme.console.text),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
|
|
@ -319,15 +314,15 @@ impl Element for ConsoleCard {
|
|||
let block = Block::default()
|
||||
.borders(self.borders)
|
||||
.title(self.title.clone())
|
||||
.title_style(context.theme.console.title)
|
||||
.title_style(theme.console.title)
|
||||
.border_style(if self.focused {
|
||||
context.theme.console.focused_border
|
||||
theme.console.focused_border
|
||||
} else {
|
||||
context.theme.console.border
|
||||
theme.console.border
|
||||
})
|
||||
.style(context.theme.console.text);
|
||||
.style(theme.console.text);
|
||||
|
||||
let spans = self.render_cursor_spans(context.theme);
|
||||
let spans = self.render_cursor_spans(theme);
|
||||
let par = Paragraph::new(Line::from(spans))
|
||||
.block(block)
|
||||
.scroll((0, 0));
|
||||
|
|
@ -338,9 +333,9 @@ impl Element for ConsoleCard {
|
|||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
theme.borders.normal
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -389,7 +384,7 @@ impl InteractableElement for ConsoleCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||
fn interact(&mut self, key: KeyEvent) -> IotaInteraction {
|
||||
// Check if a previously failed command should be restored.
|
||||
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
|
||||
self.content = restored;
|
||||
|
|
@ -401,26 +396,26 @@ impl InteractableElement for ConsoleCard {
|
|||
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
||||
self.dispatch_command(command);
|
||||
}
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if self.content.is_empty() {
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
|
||||
let command = self.content.clone();
|
||||
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
|
||||
self.message = Some(error);
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
if command.trim_start_matches('/').trim() == "help" {
|
||||
self.message = Some(format!(
|
||||
"Commands: {}",
|
||||
iota_ipc::text_commands::COMMANDS.join(", ")
|
||||
));
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
if self.history.last() != Some(&command) {
|
||||
self.history.push(command.clone());
|
||||
|
|
@ -434,12 +429,12 @@ impl InteractableElement for ConsoleCard {
|
|||
} else {
|
||||
self.dispatch_command(command);
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.message = None;
|
||||
self.delete_at_cursor();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
self.message = None;
|
||||
|
|
@ -454,41 +449,41 @@ impl InteractableElement for ConsoleCard {
|
|||
.unwrap_or(self.content.len());
|
||||
self.content.replace_range(start..end, "");
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.move_cursor_left();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.move_cursor_right();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.content.chars().count();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Up => {
|
||||
self.history_previous();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Down => {
|
||||
self.history_next();
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Tab if !self.content.is_empty() => {
|
||||
if self.complete() {
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
} else {
|
||||
self.message = Some("No command completion.".into());
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
}
|
||||
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
|
||||
KeyCode::Tab | KeyCode::BackTab => IotaInteraction::Unhandled,
|
||||
_ => {
|
||||
if !key
|
||||
.modifiers
|
||||
|
|
@ -497,10 +492,10 @@ impl InteractableElement for ConsoleCard {
|
|||
if let Some(c) = key.code.as_char() {
|
||||
self.insert_at_cursor(c);
|
||||
self.message = None;
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
}
|
||||
InteractionResult::Unhandled
|
||||
IotaInteraction::Unhandled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,15 @@ use std::any::Any;
|
|||
use crossterm::event::KeyEvent;
|
||||
use ratatui::{Frame, layout::Rect, widgets::Borders};
|
||||
|
||||
use crate::{
|
||||
interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen,
|
||||
};
|
||||
use crate::screens::screens::{IotaInteraction, IotaScreen};
|
||||
use crate::theme::ResolvedTheme;
|
||||
|
||||
#[allow(unused)]
|
||||
pub trait Element: Send + Sync + Any {
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>);
|
||||
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme);
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
|
|
@ -33,7 +32,7 @@ pub trait InfoElement: Send + Sync + Any {
|
|||
fn as_element(&self) -> &dyn Element;
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||
|
||||
fn get_info_screen(&self) -> Box<dyn Screen>;
|
||||
fn get_info_screen(&self) -> Box<dyn IotaScreen>;
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
|
|
@ -43,7 +42,7 @@ pub trait InteractableElement: Send + Sync + Any {
|
|||
fn as_element(&self) -> &dyn Element;
|
||||
fn as_element_mut(&mut self) -> &mut dyn Element;
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult;
|
||||
fn interact(&mut self, key: KeyEvent) -> IotaInteraction;
|
||||
|
||||
fn can_focus(&self) -> bool;
|
||||
fn is_focused(&self) -> bool;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use std::{any::Any, sync::Arc};
|
||||
use std::any::Any;
|
||||
|
||||
use crossterm::event::KeyEvent;
|
||||
use iota_state::ClientState;
|
||||
|
|
@ -11,11 +11,10 @@ use ratatui::{
|
|||
},
|
||||
};
|
||||
|
||||
use crate::theme::ResolvedTheme;
|
||||
use crate::{
|
||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
ui::UI,
|
||||
screens::screens::IotaInteraction,
|
||||
util::borders::draw_block_joins,
|
||||
};
|
||||
|
||||
|
|
@ -67,7 +66,6 @@ impl GRAPHS {
|
|||
|
||||
#[allow(unused)]
|
||||
pub struct GraphCard {
|
||||
ui: Arc<UI>,
|
||||
state: ClientState,
|
||||
graph_type: GRAPHS,
|
||||
|
||||
|
|
@ -82,9 +80,8 @@ pub struct GraphCard {
|
|||
}
|
||||
|
||||
impl GraphCard {
|
||||
pub fn new(ui: Arc<UI>, state: ClientState, graph_type: GRAPHS, title: String) -> Self {
|
||||
pub fn new(state: ClientState, graph_type: GRAPHS, title: String) -> Self {
|
||||
Self {
|
||||
ui,
|
||||
state,
|
||||
graph_type,
|
||||
focused: false,
|
||||
|
|
@ -113,7 +110,7 @@ impl Element for GraphCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||
fn render(&self, f: &mut Frame, r: Rect, theme: &ResolvedTheme) {
|
||||
if self.open {
|
||||
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
|
||||
if graph.is_empty() {
|
||||
|
|
@ -121,13 +118,13 @@ impl Element for GraphCard {
|
|||
.title(format!(" {} ", self.title))
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
context.theme.graphs.focused_border
|
||||
theme.graphs.focused_border
|
||||
} else {
|
||||
context.theme.graphs.border
|
||||
theme.graphs.border
|
||||
});
|
||||
f.render_widget(
|
||||
ratatui::widgets::Paragraph::new("No metric samples yet.")
|
||||
.style(context.theme.text.muted)
|
||||
.style(theme.text.muted)
|
||||
.block(block),
|
||||
r,
|
||||
);
|
||||
|
|
@ -149,7 +146,7 @@ impl Element for GraphCard {
|
|||
GRAPHS::Ping => (max_y * 1.2).max(10.0),
|
||||
};
|
||||
|
||||
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
|
||||
let surface = matches!(theme.chrome, crate::theme::ChromeMode::Surfaces);
|
||||
let title = format!(
|
||||
"{}: {}{} {}min/{}max",
|
||||
self.title,
|
||||
|
|
@ -159,7 +156,7 @@ impl Element for GraphCard {
|
|||
max_y as i64
|
||||
);
|
||||
let plot_area = if surface {
|
||||
crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme)
|
||||
crate::controls::panel::render_panel(f, r, &title, self.focused, theme)
|
||||
} else {
|
||||
r
|
||||
};
|
||||
|
|
@ -178,9 +175,9 @@ impl Element for GraphCard {
|
|||
})
|
||||
.borders(if surface { Borders::NONE } else { self.borders })
|
||||
.border_style(if self.focused {
|
||||
context.theme.graphs.focused_border
|
||||
theme.graphs.focused_border
|
||||
} else {
|
||||
context.theme.graphs.border
|
||||
theme.graphs.border
|
||||
});
|
||||
|
||||
let canvas = Canvas::default()
|
||||
|
|
@ -194,7 +191,7 @@ impl Element for GraphCard {
|
|||
y1: 0.0,
|
||||
x2: *x,
|
||||
y2: *y,
|
||||
color: self.graph_type.get_color(context.theme),
|
||||
color: self.graph_type.get_color(theme),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -204,22 +201,22 @@ impl Element for GraphCard {
|
|||
.title("")
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
context.theme.graphs.focused_border
|
||||
theme.graphs.focused_border
|
||||
} else {
|
||||
context.theme.graphs.border
|
||||
theme.graphs.border
|
||||
});
|
||||
f.render_widget(block, r);
|
||||
}
|
||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
draw_block_joins(
|
||||
f,
|
||||
r,
|
||||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
theme.borders.normal
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -269,8 +266,8 @@ impl InteractableElement for GraphCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn interact(&mut self, _key: KeyEvent) -> InteractionResult {
|
||||
InteractionResult::Handled
|
||||
fn interact(&mut self, _key: KeyEvent) -> IotaInteraction {
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
|
||||
fn can_focus(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||
use crate::util::borders::draw_block_joins;
|
||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||
use crate::{screens::screens::IotaInteraction, theme::ResolvedTheme};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use iota_state::{ClientState, UiLogEntry};
|
||||
use ratatui::{
|
||||
|
|
@ -270,7 +270,7 @@ impl LogCard {
|
|||
}
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
pub fn scroll_older(&mut self) {
|
||||
let max_offset = self
|
||||
.last_total_lines
|
||||
.load(Ordering::Relaxed)
|
||||
|
|
@ -278,7 +278,7 @@ impl LogCard {
|
|||
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
pub fn scroll_newer(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
|
|
@ -318,25 +318,19 @@ impl Element for LogCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||
fn render(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
||||
let entries = self.get_logs();
|
||||
|
||||
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
crate::controls::panel::render_panel(
|
||||
f,
|
||||
area,
|
||||
&self.build_title(),
|
||||
self.focused,
|
||||
context.theme,
|
||||
)
|
||||
let inner_area = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
crate::controls::panel::render_panel(f, area, &self.build_title(), self.focused, theme)
|
||||
} else {
|
||||
let block = Block::default()
|
||||
.title(self.build_title())
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
context.theme.logs.focused_border
|
||||
theme.logs.focused_border
|
||||
} else {
|
||||
context.theme.logs.border
|
||||
theme.logs.border
|
||||
});
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
|
@ -350,9 +344,9 @@ impl Element for LogCard {
|
|||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
theme.borders.normal
|
||||
},
|
||||
);
|
||||
return;
|
||||
|
|
@ -376,24 +370,21 @@ impl Element for LogCard {
|
|||
let mut spans = Vec::new();
|
||||
|
||||
let (prefix, rest) = Self::split_line_prefix(line);
|
||||
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
let prefix = if matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
""
|
||||
} else {
|
||||
prefix
|
||||
};
|
||||
|
||||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
prefix.to_string(),
|
||||
source.style(context.theme),
|
||||
));
|
||||
spans.push(Span::styled(prefix.to_string(), source.style(theme)));
|
||||
}
|
||||
|
||||
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
||||
let text_style = if *is_error {
|
||||
context.theme.logs.error
|
||||
theme.logs.error
|
||||
} else {
|
||||
context.theme.logs.text
|
||||
theme.logs.text
|
||||
};
|
||||
|
||||
if !content.is_empty() {
|
||||
|
|
@ -401,10 +392,7 @@ impl Element for LogCard {
|
|||
}
|
||||
|
||||
if !timestamp.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
timestamp.to_string(),
|
||||
context.theme.logs.timestamp,
|
||||
));
|
||||
spans.push(Span::styled(timestamp.to_string(), theme.logs.timestamp));
|
||||
}
|
||||
|
||||
Line::from(spans)
|
||||
|
|
@ -421,16 +409,16 @@ impl Element for LogCard {
|
|||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||
}
|
||||
|
||||
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
if !matches!(theme.chrome, crate::theme::ChromeMode::Surfaces) {
|
||||
draw_block_joins(
|
||||
f,
|
||||
area,
|
||||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
theme.borders.normal
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -480,7 +468,7 @@ impl InteractableElement for LogCard {
|
|||
&mut *self
|
||||
}
|
||||
|
||||
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
|
||||
fn interact(&mut self, key: KeyEvent) -> IotaInteraction {
|
||||
if self.filtering {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
|
|
@ -501,7 +489,7 @@ impl InteractableElement for LogCard {
|
|||
_ => {}
|
||||
}
|
||||
self.scroll_offset = 0;
|
||||
return InteractionResult::Handled;
|
||||
return IotaInteraction::Changed;
|
||||
}
|
||||
let entries = self.get_logs();
|
||||
let width = self.last_width.load(Ordering::Relaxed).max(1);
|
||||
|
|
@ -517,51 +505,51 @@ impl InteractableElement for LogCard {
|
|||
self.filtering = true;
|
||||
self.filter.clear();
|
||||
self.scroll_offset = 0;
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => {
|
||||
self.selected = !self.selected;
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Char('J') => {
|
||||
let (can_up, _) = self.get_title_hints();
|
||||
if can_up {
|
||||
self.scroll_up();
|
||||
self.scroll_older();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Char('k') | KeyCode::Char('K') => {
|
||||
let (_, can_down) = self.get_title_hints();
|
||||
if can_down {
|
||||
self.scroll_down();
|
||||
self.scroll_newer();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Up if self.selected => {
|
||||
let (can_up, _) = self.get_title_hints();
|
||||
if can_up {
|
||||
self.scroll_up();
|
||||
self.scroll_older();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Down if self.selected => {
|
||||
let (_, can_down) = self.get_title_hints();
|
||||
if can_down {
|
||||
self.scroll_down();
|
||||
self.scroll_newer();
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
if total_lines > visible_height {
|
||||
self.scroll_offset = total_lines - visible_height;
|
||||
}
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.scroll_offset = 0;
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
_ => IotaInteraction::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ use ratatui::{
|
|||
};
|
||||
|
||||
use crate::{
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::screens::{HitMap, Screen, UiEvent},
|
||||
screens::screens::{HitMap, IotaInteraction, IotaScreen, UiEvent},
|
||||
theme::ResolvedTheme,
|
||||
};
|
||||
|
||||
|
|
@ -139,19 +137,11 @@ impl HelpOverlay {
|
|||
}
|
||||
}
|
||||
|
||||
impl Screen for HelpOverlay {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
|
||||
let area = crate::layout::fit::centered_rect(
|
||||
impl IotaScreen for HelpOverlay {
|
||||
fn render_legacy(&self, f: &mut Frame, rect: Rect, theme: &ResolvedTheme, _hits: &mut HitMap) {
|
||||
let area = ratatool::layout::centered_rect(
|
||||
rect,
|
||||
crate::layout::fit::RequiredSize {
|
||||
ratatool::layout::RequiredSize {
|
||||
width: 52,
|
||||
height: 40,
|
||||
},
|
||||
|
|
@ -161,44 +151,43 @@ impl Screen for HelpOverlay {
|
|||
let block = Block::default()
|
||||
.title(" Keyboard Shortcuts (?) ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(context.theme.borders.focused)
|
||||
.style(context.theme.surfaces.overlay);
|
||||
.border_style(theme.borders.focused)
|
||||
.style(theme.surfaces.overlay);
|
||||
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let lines = self.build_lines(context.theme);
|
||||
let lines = self.build_lines(theme);
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.scroll((self.scroll as u16, 0))
|
||||
.style(context.theme.text.normal);
|
||||
.style(theme.text.normal);
|
||||
f.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
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::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => IotaInteraction::Pop,
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.scroll = self.scroll.saturating_add(1);
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.scroll = self.scroll.saturating_sub(1);
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
self.scroll = self.scroll.saturating_add(10);
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
self.scroll = self.scroll.saturating_sub(10);
|
||||
InteractionResult::Handled
|
||||
IotaInteraction::Changed
|
||||
}
|
||||
_ => InteractionResult::Unhandled,
|
||||
_ => IotaInteraction::Unhandled,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -215,5 +204,3 @@ impl Screen for HelpOverlay {
|
|||
]
|
||||
}
|
||||
}
|
||||
|
||||
use std::any::Any;
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
use crate::{screens::screens::UiEvent, ui::UI};
|
||||
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let cancellation = ui.cancellation_token();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let worker_cancellation = cancellation.clone();
|
||||
let worker = tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
while !worker_cancellation.is_cancelled() {
|
||||
if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
|
||||
tx.send(read().map_err(|e| e.to_string())?)
|
||||
.map_err(|_| "input session closed".to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
loop {
|
||||
if ui.is_shutdown() {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
event = rx.recv() => match event {
|
||||
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
||||
Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
|
||||
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
|
||||
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
|
||||
Some(_) => {},
|
||||
None => break,
|
||||
},
|
||||
_ = cancellation.cancelled() => break,
|
||||
}
|
||||
}
|
||||
let result = match worker.await {
|
||||
Ok(result) => result,
|
||||
Err(error) if error.is_cancelled() => Ok(()),
|
||||
Err(error) => Err(format!("input worker failed: {error}")),
|
||||
};
|
||||
if result.is_err() {
|
||||
ui.request_shutdown();
|
||||
}
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
||||
if matches!(
|
||||
key.code,
|
||||
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
|
||||
) && key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
ui.request_shutdown();
|
||||
} else {
|
||||
ui.handle_event(UiEvent::Key(key)).await;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
use std::fmt::{Debug, Formatter};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::screens::screens::{Screen, UiEvent};
|
||||
|
||||
#[allow(unused)]
|
||||
pub enum InteractionResult {
|
||||
CloseScreen,
|
||||
OpenScreen {
|
||||
screen: Box<dyn Screen>,
|
||||
},
|
||||
OpenFutureScreen {
|
||||
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
|
||||
},
|
||||
AppTask {
|
||||
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
|
||||
},
|
||||
Handled,
|
||||
Unhandled,
|
||||
}
|
||||
|
||||
impl Debug for InteractionResult {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
|
||||
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
|
||||
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
|
||||
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
|
||||
InteractionResult::Handled => write!(f, "Handled"),
|
||||
InteractionResult::Unhandled => write!(f, "Unhandled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for InteractionResult {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(
|
||||
InteractionResult::OpenScreen { screen: _ },
|
||||
InteractionResult::OpenScreen { screen: _ },
|
||||
) => true,
|
||||
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
|
||||
true
|
||||
}
|
||||
(
|
||||
InteractionResult::OpenFutureScreen { screen: _ },
|
||||
InteractionResult::OpenFutureScreen { screen: _ },
|
||||
) => true,
|
||||
(InteractionResult::CloseScreen, InteractionResult::CloseScreen) => true,
|
||||
(InteractionResult::Handled, InteractionResult::Handled) => true,
|
||||
(InteractionResult::Unhandled, InteractionResult::Unhandled) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,7 @@ pub struct IpcClient {
|
|||
pending: Mutex<HashMap<u64, PendingRequest>>,
|
||||
connection_state: watch::Sender<IpcConnectionState>,
|
||||
daemon_status: watch::Sender<DaemonStatus>,
|
||||
ui_updates: watch::Sender<u64>,
|
||||
path: PathBuf,
|
||||
reconnector_started: AtomicBool,
|
||||
cancellation: CancellationToken,
|
||||
|
|
@ -110,6 +111,7 @@ impl IpcClient {
|
|||
};
|
||||
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
|
||||
let (daemon_status_tx, _) = watch::channel(initial_status);
|
||||
let (ui_updates_tx, _) = watch::channel(0);
|
||||
let client = Arc::new(Self {
|
||||
state: ClientState::new(),
|
||||
writer: Mutex::new(Some(ActiveWriter {
|
||||
|
|
@ -122,6 +124,7 @@ impl IpcClient {
|
|||
pending: Mutex::new(HashMap::new()),
|
||||
connection_state: conn_state_tx,
|
||||
daemon_status: daemon_status_tx,
|
||||
ui_updates: ui_updates_tx,
|
||||
path: path.clone(),
|
||||
reconnector_started: AtomicBool::new(false),
|
||||
cancellation: CancellationToken::new(),
|
||||
|
|
@ -415,6 +418,10 @@ impl IpcClient {
|
|||
self.daemon_status.subscribe()
|
||||
}
|
||||
|
||||
pub fn ui_updates(&self) -> watch::Receiver<u64> {
|
||||
self.ui_updates.subscribe()
|
||||
}
|
||||
|
||||
/// Stop the IPC reader/reconnector and release the socket writer. This
|
||||
/// is deliberately bounded so UI shutdown cannot hang on a peer.
|
||||
pub async fn shutdown(&self) {
|
||||
|
|
@ -903,5 +910,8 @@ impl IpcClient {
|
|||
});
|
||||
}
|
||||
}
|
||||
self.ui_updates.send_modify(|generation| {
|
||||
*generation = generation.wrapping_add(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
use ratatui::layout::Rect;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RequiredSize {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FitLevel {
|
||||
Preferred,
|
||||
Compact,
|
||||
Fallback,
|
||||
}
|
||||
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
|
||||
if area.width >= preferred.width && area.height >= preferred.height {
|
||||
FitLevel::Preferred
|
||||
} else if area.width >= compact.width && area.height >= compact.height {
|
||||
FitLevel::Compact
|
||||
} else {
|
||||
FitLevel::Fallback
|
||||
}
|
||||
}
|
||||
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
|
||||
let width = area.width.min(maximum.width);
|
||||
let height = area.height.min(maximum.height);
|
||||
Rect {
|
||||
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
||||
y: area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(height) / 2),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
|
||||
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
|
||||
Some(Rect {
|
||||
x: area.x,
|
||||
y: area.y.checked_add(top)?,
|
||||
width: area.width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
|
||||
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
|
||||
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
|
||||
Some(Rect {
|
||||
x: area.x.checked_add(horizontal)?,
|
||||
y: area.y.checked_add(vertical)?,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
pub mod fit;
|
||||
pub mod text_measure;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
use unicode_width::UnicodeWidthStr;
|
||||
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 0;
|
||||
}
|
||||
text.split('\n')
|
||||
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
|
||||
.sum::<usize>()
|
||||
.min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
|
@ -24,12 +24,7 @@ pub mod util {
|
|||
pub mod app_state;
|
||||
pub mod controls;
|
||||
pub mod help_overlay;
|
||||
pub mod input_handler;
|
||||
pub mod interaction_result;
|
||||
pub mod ipc_client;
|
||||
pub mod layout;
|
||||
pub mod notification;
|
||||
pub mod render_context;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub use ui::TuiSession;
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
use crate::theme::ResolvedTheme;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationKind {
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Notification {
|
||||
pub message: String,
|
||||
pub kind: NotificationKind,
|
||||
pub created_at: Instant,
|
||||
pub duration: Duration,
|
||||
}
|
||||
|
||||
impl Notification {
|
||||
pub fn success(message: impl Into<String>) -> Self {
|
||||
Self::new(message, NotificationKind::Success, Duration::from_secs(3))
|
||||
}
|
||||
|
||||
pub fn warning(message: impl Into<String>) -> Self {
|
||||
Self::new(message, NotificationKind::Warning, Duration::from_secs(4))
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self::new(message, NotificationKind::Error, Duration::from_secs(5))
|
||||
}
|
||||
|
||||
pub fn info(message: impl Into<String>) -> Self {
|
||||
Self::new(message, NotificationKind::Info, Duration::from_secs(3))
|
||||
}
|
||||
|
||||
fn new(message: impl Into<String>, kind: NotificationKind, duration: Duration) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
kind,
|
||||
created_at: Instant::now(),
|
||||
duration,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed() >= self.duration
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> Duration {
|
||||
self.duration.saturating_sub(self.created_at.elapsed())
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> f64 {
|
||||
let elapsed = self.created_at.elapsed().as_secs_f64();
|
||||
let total = self.duration.as_secs_f64();
|
||||
(elapsed / total).min(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_notification(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
notification: &Notification,
|
||||
theme: &ResolvedTheme,
|
||||
) {
|
||||
let (prefix, style) = match notification.kind {
|
||||
NotificationKind::Success => ("✓ ", theme.status.success),
|
||||
NotificationKind::Warning => ("⚠ ", theme.status.warning),
|
||||
NotificationKind::Error => ("✗ ", theme.status.error),
|
||||
NotificationKind::Info => ("ℹ ", theme.status.info),
|
||||
};
|
||||
|
||||
let remaining = notification.remaining().as_secs();
|
||||
let progress = notification.progress();
|
||||
|
||||
let mut spans = vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(¬ification.message, theme.text.normal),
|
||||
];
|
||||
|
||||
if remaining > 0 {
|
||||
let bar_width = 10;
|
||||
let filled = ((1.0 - progress) * bar_width as f64) as usize;
|
||||
let empty = bar_width - filled;
|
||||
let bar: String = "█".repeat(filled) + &"░".repeat(empty);
|
||||
spans.push(Span::styled(
|
||||
format!(" [{bar}] {remaining}s"),
|
||||
theme.text.muted,
|
||||
));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
pub fn render_notification_area(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
notifications: &[Notification],
|
||||
theme: &ResolvedTheme,
|
||||
) {
|
||||
if notifications.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let visible_height = area.height as usize;
|
||||
let start = notifications.len().saturating_sub(visible_height);
|
||||
let visible = ¬ifications[start..];
|
||||
|
||||
for (i, notification) in visible.iter().enumerate() {
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: area.y + i as u16,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
render_notification(frame, row, notification, theme);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
|
||||
/// Immutable state shared by every component during one render pass.
|
||||
pub struct RenderContext<'a> {
|
||||
pub theme: &'a ResolvedTheme,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -7,6 +7,112 @@ pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig};
|
|||
pub use model::*;
|
||||
pub use name::ThemeName;
|
||||
|
||||
pub fn ratatool_policy(policy: TerminalPolicy) -> ratatool::CapabilityPolicy {
|
||||
match policy {
|
||||
TerminalPolicy::Auto => ratatool::CapabilityPolicy::Auto,
|
||||
TerminalPolicy::Always => ratatool::CapabilityPolicy::Force,
|
||||
TerminalPolicy::Never => ratatool::CapabilityPolicy::Disable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminal_options(
|
||||
color: TerminalPolicy,
|
||||
unicode: TerminalPolicy,
|
||||
) -> ratatool::TerminalOptions {
|
||||
ratatool::TerminalOptions {
|
||||
color: ratatool_policy(color),
|
||||
unicode: ratatool_policy(unicode),
|
||||
overrides: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn registry() -> ratatool::ThemeRegistry {
|
||||
let mut registry = ratatool::ThemeRegistry::new();
|
||||
let plain = presets::resolve(ThemeName::Monospace);
|
||||
let styled = presets::resolve(ThemeName::Binary);
|
||||
let ansi = presets::resolve(ThemeName::Ansi);
|
||||
for name in ThemeName::ALL {
|
||||
let selected = presets::resolve(name);
|
||||
let color = if matches!(name, ThemeName::Surface) {
|
||||
&ansi
|
||||
} else {
|
||||
&selected
|
||||
};
|
||||
registry.register(ratatool::ThemeFamily::new(
|
||||
name.ratatool_id(),
|
||||
generic_variant(&selected),
|
||||
generic_variant(color),
|
||||
generic_variant(&styled),
|
||||
generic_variant(&plain),
|
||||
));
|
||||
}
|
||||
registry
|
||||
}
|
||||
|
||||
fn generic_variant(theme: &ResolvedTheme) -> ratatool::ThemeVariant {
|
||||
ratatool::ThemeVariant {
|
||||
text: ratatool::TextTheme {
|
||||
normal: theme.text.normal,
|
||||
muted: theme.text.muted,
|
||||
emphasis: theme.text.heading,
|
||||
disabled: theme.borders.disabled,
|
||||
heading: theme.text.heading,
|
||||
link: theme.text.link,
|
||||
code: theme.text.code,
|
||||
},
|
||||
borders: ratatool::BorderTheme {
|
||||
normal: theme.borders.normal,
|
||||
focused: theme.borders.focused,
|
||||
disabled: theme.borders.disabled,
|
||||
},
|
||||
surfaces: ratatool::SurfaceTheme {
|
||||
background: theme.surfaces.canvas,
|
||||
panel: theme.surfaces.panel,
|
||||
panel_focused: theme.surfaces.panel_focused,
|
||||
panel_header: theme.surfaces.panel_alternate,
|
||||
raised: theme.surfaces.panel_alternate,
|
||||
toolbar: theme.surfaces.toolbar,
|
||||
footer: theme.surfaces.footer,
|
||||
overlay: theme.surfaces.overlay,
|
||||
},
|
||||
buttons: ratatool::ButtonTheme {
|
||||
primary: theme.buttons.primary,
|
||||
primary_focused: theme.buttons.primary_focused,
|
||||
secondary: theme.buttons.neutral,
|
||||
secondary_focused: theme.buttons.neutral_focused,
|
||||
cancel: theme.buttons.cancel,
|
||||
cancel_focused: theme.buttons.cancel_focused,
|
||||
destructive: theme.buttons.destructive,
|
||||
destructive_focused: theme.buttons.destructive,
|
||||
disabled: theme.buttons.disabled,
|
||||
},
|
||||
status: ratatool::StatusTheme {
|
||||
info: theme.status.info,
|
||||
success: theme.status.success,
|
||||
warning: theme.status.warning,
|
||||
error: theme.status.error,
|
||||
},
|
||||
input: ratatool::InputTheme {
|
||||
normal: theme.text.normal,
|
||||
focused: theme.borders.focused,
|
||||
placeholder: theme.text.muted,
|
||||
selection: theme.surfaces.panel_selected,
|
||||
},
|
||||
selection: ratatool::SelectionTheme {
|
||||
normal: theme.text.normal,
|
||||
selected: theme.surfaces.panel_selected,
|
||||
focused: theme.borders.focused,
|
||||
disabled: theme.borders.disabled,
|
||||
},
|
||||
metrics: ratatool::ThemeMetrics {
|
||||
panel_presentation: match theme.chrome {
|
||||
ChromeMode::Bordered => ratatool::PanelPresentation::Bordered,
|
||||
ChromeMode::Surfaces => ratatool::PanelPresentation::Surface,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||
presets::resolve(name)
|
||||
}
|
||||
|
|
@ -34,22 +140,6 @@ pub fn resolve_with_capabilities(
|
|||
theme
|
||||
}
|
||||
|
||||
/// Resolve a theme against the terminal's color depth. Surface uses RGB
|
||||
/// colors, so a portable ANSI preset is selected when truecolor is absent.
|
||||
pub fn resolve_with_terminal_profile(
|
||||
name: ThemeName,
|
||||
color_enabled: bool,
|
||||
unicode_enabled: bool,
|
||||
truecolor_enabled: bool,
|
||||
) -> ResolvedTheme {
|
||||
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
|
||||
ThemeName::Ansi
|
||||
} else {
|
||||
name
|
||||
};
|
||||
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -75,9 +165,18 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn surface_uses_ansi_fallback_without_truecolor() {
|
||||
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
|
||||
assert_eq!(theme.name, ThemeName::Ansi);
|
||||
assert_eq!(theme.surfaces.panel.bg, None);
|
||||
fn terminal_profile_changes_preserve_surface_identity() {
|
||||
let registry = registry();
|
||||
let mut state = ratatool::ThemeState::new(
|
||||
ThemeName::Surface.ratatool_id(),
|
||||
ratatool::TerminalProfile::truecolor_unicode(),
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.set_profile(ratatool::TerminalProfile::ansi16_unicode(), ®istry)
|
||||
.unwrap();
|
||||
assert_eq!(state.selected(), &ThemeName::Surface.ratatool_id());
|
||||
assert_eq!(state.profile().color_depth, ratatool::ColorDepth::Ansi16);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
iota-cli/src/theme/model.rs
Normal file → Executable file
0
iota-cli/src/theme/model.rs
Normal file → Executable file
|
|
@ -17,6 +17,10 @@ impl ThemeName {
|
|||
pub fn supported_names() -> &'static str {
|
||||
"monospace, binary, ansi, surface"
|
||||
}
|
||||
|
||||
pub fn ratatool_id(self) -> ratatool::ThemeId {
|
||||
ratatool::ThemeId::new(self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThemeName {
|
||||
|
|
|
|||
1645
iota-cli/src/ui.rs
1645
iota-cli/src/ui.rs
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue