[WIP] Daemon & CLI

This commit is contained in:
Alex-Emmet 2026-07-23 23:13:02 +02:00
commit 8b158108bb
100 changed files with 6519 additions and 1596 deletions

View file

@ -0,0 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlAction {
FocusNext,
FocusPrevious,
Select,
Activate,
}

View 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
}

View 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),
}
}
}

View 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),
])
}

View 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;

View file

@ -0,0 +1,6 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DisabledFocusPolicy {
Include,
#[default]
Skip,
}

View 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
}
}