[WIP] Daemon & CLI
This commit is contained in:
parent
56aad3a023
commit
8b158108bb
100 changed files with 6519 additions and 1596 deletions
7
iota-cli/src/controls/action.rs
Normal file
7
iota-cli/src/controls/action.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ControlAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Select,
|
||||
Activate,
|
||||
}
|
||||
72
iota-cli/src/controls/button.rs
Normal file
72
iota-cli/src/controls/button.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Rect},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonIntent {
|
||||
Primary,
|
||||
Neutral,
|
||||
Cancel,
|
||||
Destructive,
|
||||
}
|
||||
pub struct ActionButton<'a> {
|
||||
pub label: &'a str,
|
||||
pub intent: ButtonIntent,
|
||||
pub focused: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub fn render_button(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
button: ActionButton<'_>,
|
||||
theme: &ResolvedTheme,
|
||||
) {
|
||||
let style = if !button.enabled {
|
||||
theme.buttons.disabled
|
||||
} else {
|
||||
match (button.intent, button.focused) {
|
||||
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
|
||||
(ButtonIntent::Primary, false) => theme.buttons.primary,
|
||||
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
|
||||
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
|
||||
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
|
||||
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
|
||||
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
|
||||
}
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(button.label, style))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option<Vec<u16>> {
|
||||
let required = minimums
|
||||
.iter()
|
||||
.try_fold(0u16, |total, width| total.checked_add(*width))?;
|
||||
if required > available {
|
||||
return None;
|
||||
}
|
||||
if minimums.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let extra = available - required;
|
||||
let count = minimums.len() as u16;
|
||||
Some(
|
||||
minimums
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn button_minimum_width(label: &str) -> u16 {
|
||||
UnicodeWidthStr::width(label)
|
||||
.saturating_add(2)
|
||||
.min(u16::MAX as usize) as u16
|
||||
}
|
||||
135
iota-cli/src/controls/checkbox_group.rs
Normal file
135
iota-cli/src/controls/checkbox_group.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
42
iota-cli/src/controls/choice.rs
Normal file
42
iota-cli/src/controls/choice.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
use ratatui::text::{Line, Span};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChoiceKind {
|
||||
Checkbox,
|
||||
Radio,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChoiceVisualState {
|
||||
pub selected: bool,
|
||||
pub focused: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub fn render_choice_line<'a>(
|
||||
label: &'a str,
|
||||
kind: ChoiceKind,
|
||||
state: ChoiceVisualState,
|
||||
theme: &'a ResolvedTheme,
|
||||
) -> Line<'a> {
|
||||
let item = match (state.selected, state.focused, state.enabled) {
|
||||
(_, true, false) => &theme.choices.focused_disabled,
|
||||
(true, false, false) => &theme.choices.selected_disabled,
|
||||
(false, false, false) => &theme.choices.disabled,
|
||||
(true, true, true) => &theme.choices.focused_selected,
|
||||
(true, false, true) => &theme.choices.selected,
|
||||
(false, true, true) => &theme.choices.focused,
|
||||
(false, false, true) => &theme.choices.normal,
|
||||
};
|
||||
let marker = match (kind, state.selected) {
|
||||
(ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected,
|
||||
(ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected,
|
||||
(ChoiceKind::Radio, false) => theme.markers.radio_unselected,
|
||||
(ChoiceKind::Radio, true) => theme.markers.radio_selected,
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(item.prefix, item.label),
|
||||
Span::styled(marker, item.marker),
|
||||
Span::raw(" "),
|
||||
Span::styled(label, item.label),
|
||||
Span::styled(item.suffix, item.label),
|
||||
])
|
||||
}
|
||||
6
iota-cli/src/controls/mod.rs
Normal file
6
iota-cli/src/controls/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod action;
|
||||
pub mod button;
|
||||
pub mod checkbox_group;
|
||||
pub mod choice;
|
||||
pub mod navigation;
|
||||
pub mod radio_group;
|
||||
6
iota-cli/src/controls/navigation.rs
Normal file
6
iota-cli/src/controls/navigation.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DisabledFocusPolicy {
|
||||
Include,
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
194
iota-cli/src/controls/radio_group.rs
Normal file
194
iota-cli/src/controls/radio_group.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue