[Add] Proper User managment

This commit is contained in:
Alex Emmet 2026-09-02 22:42:25 +02:00
commit b38b68ad96
No known key found for this signature in database
38 changed files with 4331 additions and 1065 deletions

View file

@ -11,6 +11,36 @@ use ratatui::{
widgets::Paragraph,
};
#[derive(Debug, Clone, Copy)]
pub struct HeaderItem {
pub label: &'static str,
pub intent: ButtonIntent,
pub action: AppAction,
}
pub const HEADER_ITEMS: &[HeaderItem] = &[
HeaderItem {
label: "Overview",
intent: ButtonIntent::Primary,
action: AppAction::OpenOverview,
},
HeaderItem {
label: "Users",
intent: ButtonIntent::Neutral,
action: AppAction::OpenUsers,
},
HeaderItem {
label: "Settings",
intent: ButtonIntent::Neutral,
action: AppAction::OpenSettings,
},
HeaderItem {
label: "Quit",
intent: ButtonIntent::Destructive,
action: AppAction::Quit,
},
];
fn connection_badge(
state: &IpcConnectionState,
theme: &ResolvedTheme,
@ -59,22 +89,14 @@ pub fn render_header(
let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[0]);
let cells2 = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[1]);
let constraints = std::iter::once(Constraint::Min(28))
.chain(
HEADER_ITEMS
.iter()
.map(|item| Constraint::Length(if item.label == "Quit" { 8 } else { 12 })),
)
.collect::<Vec<_>>();
let cells = Layout::horizontal(constraints.clone()).split(rows[0]);
let (ipc_label, ipc_style) = connection_badge(connection, theme);
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
@ -99,46 +121,15 @@ pub fn render_header(
);
hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [
(
cells[1],
cells2[1],
"Overview",
ButtonIntent::Primary,
AppAction::OpenOverview,
),
(
cells[2],
cells2[2],
"Users",
ButtonIntent::Neutral,
AppAction::OpenUsers,
),
(
cells[3],
cells2[3],
"Settings",
ButtonIntent::Neutral,
AppAction::OpenSettings,
),
(
cells[4],
cells2[4],
"Quit",
ButtonIntent::Destructive,
AppAction::Quit,
),
]
.into_iter()
.enumerate()
{
for (index, item) in HEADER_ITEMS.iter().enumerate() {
let top = cells[index + 1];
let button_area = Rect {
x: top.x,
y: top.y,
width: top.width,
height: area.height,
};
let style = match (intent, focused_action == Some(index)) {
let style = match (item.intent, focused_action == Some(index)) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
@ -148,9 +139,9 @@ pub fn render_header(
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
};
let display_label = if focused_action == Some(index) {
format!("› {label}")
format!("› {}", item.label)
} else {
label.to_owned()
item.label.to_owned()
};
frame.render_widget(
Paragraph::new(vec![
@ -159,6 +150,6 @@ pub fn render_header(
]),
button_area,
);
hits.register(button_area, action);
hits.register(button_area, item.action);
}
}

View file

@ -0,0 +1,119 @@
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);
}
}

View file

@ -4,7 +4,9 @@ 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;

View file

@ -0,0 +1,99 @@
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
}
}