mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
375 lines
14 KiB
Rust
375 lines
14 KiB
Rust
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
|
|
use ratatui::layout::Rect;
|
|
|
|
use crate::{
|
|
app::{
|
|
state::{AppState, SettingsSection, THEME_NAMES},
|
|
App, Mode,
|
|
},
|
|
config::ToastDelivery,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(super) enum SettingsAction {
|
|
SaveTheme(String),
|
|
SaveSound(bool),
|
|
SaveToastDelivery(ToastDelivery),
|
|
}
|
|
|
|
impl App {
|
|
pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
|
|
if let Some(action) = update_settings_state(&mut self.state, key) {
|
|
match action {
|
|
SettingsAction::SaveTheme(name) => self.save_theme(&name),
|
|
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
|
|
SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn normalize_theme_name(name: &str) -> String {
|
|
name.to_lowercase().replace([' ', '_'], "-")
|
|
}
|
|
|
|
fn current_theme_index(theme_name: &str) -> usize {
|
|
let normalized = normalize_theme_name(theme_name);
|
|
THEME_NAMES
|
|
.iter()
|
|
.position(|name| normalize_theme_name(name) == normalized)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
fn toast_delivery_index(delivery: ToastDelivery) -> usize {
|
|
match delivery {
|
|
ToastDelivery::Off => 0,
|
|
ToastDelivery::Herdr => 1,
|
|
ToastDelivery::Terminal => 2,
|
|
}
|
|
}
|
|
|
|
fn toast_delivery_for_index(idx: usize) -> ToastDelivery {
|
|
match idx {
|
|
0 => ToastDelivery::Off,
|
|
1 => ToastDelivery::Herdr,
|
|
_ => ToastDelivery::Terminal,
|
|
}
|
|
}
|
|
|
|
fn preview_selected_theme(state: &mut AppState) {
|
|
use crate::app::state::Palette;
|
|
|
|
let name = THEME_NAMES[state.settings.list.selected];
|
|
if let Some(palette) = Palette::from_name(name) {
|
|
state.palette = palette;
|
|
state.theme_name = name.to_string();
|
|
}
|
|
}
|
|
|
|
fn cancel_settings(state: &mut AppState) {
|
|
if let Some(palette) = state.settings.original_palette.take() {
|
|
state.palette = palette;
|
|
}
|
|
if let Some(theme_name) = state.settings.original_theme.take() {
|
|
state.theme_name = theme_name;
|
|
}
|
|
super::modal::leave_modal(state);
|
|
}
|
|
|
|
fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
|
|
match state.settings.section {
|
|
SettingsSection::Theme => {
|
|
let theme_name = state.theme_name.clone();
|
|
state.settings.original_palette = None;
|
|
state.settings.original_theme = None;
|
|
super::modal::leave_modal(state);
|
|
Some(SettingsAction::SaveTheme(theme_name))
|
|
}
|
|
_ => {
|
|
super::modal::leave_modal(state);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
|
|
match state.settings.section {
|
|
SettingsSection::Theme => match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
let previous = state.settings.list.selected;
|
|
state.settings.list.move_prev();
|
|
if state.settings.list.selected != previous {
|
|
preview_selected_theme(state);
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
let previous = state.settings.list.selected;
|
|
state.settings.list.move_next(THEME_NAMES.len());
|
|
if state.settings.list.selected != previous {
|
|
preview_selected_theme(state);
|
|
}
|
|
}
|
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
|
state.settings.section = SettingsSection::Sound;
|
|
state.settings.list.selected = usize::from(!state.sound_enabled());
|
|
}
|
|
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
|
|
Some(super::modal::ModalAction::Apply) => return apply_settings(state),
|
|
Some(super::modal::ModalAction::Close) => cancel_settings(state),
|
|
_ => {}
|
|
},
|
|
},
|
|
SettingsSection::Sound => match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
|
|
state.settings.list.selected = 1 - state.settings.list.selected.min(1);
|
|
}
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
let enabled = state.settings.list.selected == 0;
|
|
return Some(SettingsAction::SaveSound(enabled));
|
|
}
|
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
|
state.settings.section = SettingsSection::Toast;
|
|
state.settings.list.selected = toast_delivery_index(state.toast_delivery());
|
|
}
|
|
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
|
state.settings.section = SettingsSection::Theme;
|
|
state.settings.list.selected = current_theme_index(&state.theme_name);
|
|
}
|
|
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
|
|
Some(super::modal::ModalAction::Close) => cancel_settings(state),
|
|
_ => {}
|
|
},
|
|
},
|
|
SettingsSection::Toast => match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(),
|
|
KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3),
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
let delivery = toast_delivery_for_index(state.settings.list.selected);
|
|
return Some(SettingsAction::SaveToastDelivery(delivery));
|
|
}
|
|
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
|
|
state.settings.section = SettingsSection::Sound;
|
|
state.settings.list.selected = usize::from(!state.sound_enabled());
|
|
}
|
|
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
|
|
state.settings.section = SettingsSection::Theme;
|
|
state.settings.list.selected = current_theme_index(&state.theme_name);
|
|
}
|
|
_ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
|
|
Some(super::modal::ModalAction::Close) => cancel_settings(state),
|
|
_ => {}
|
|
},
|
|
},
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) fn open_settings(state: &mut AppState) {
|
|
state.settings.original_palette = Some(state.palette.clone());
|
|
state.settings.original_theme = Some(state.theme_name.clone());
|
|
state.settings.section = SettingsSection::Theme;
|
|
state.settings.list.selected = current_theme_index(&state.theme_name);
|
|
state.mode = Mode::Settings;
|
|
}
|
|
|
|
impl AppState {
|
|
fn settings_popup_rect(&self) -> Rect {
|
|
crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default()
|
|
}
|
|
|
|
fn settings_inner_rect(&self) -> Rect {
|
|
let popup = self.settings_popup_rect();
|
|
Rect::new(
|
|
popup.x + 1,
|
|
popup.y + 1,
|
|
popup.width.saturating_sub(2),
|
|
popup.height.saturating_sub(2),
|
|
)
|
|
}
|
|
|
|
fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> {
|
|
let inner = self.settings_inner_rect();
|
|
let tab_y = inner.y + 1;
|
|
if row != tab_y {
|
|
return None;
|
|
}
|
|
let mut x = inner.x;
|
|
for section in SettingsSection::ALL {
|
|
let width = section.label().len() as u16 + 2;
|
|
if col >= x && col < x + width {
|
|
return Some(*section);
|
|
}
|
|
x += width + 1;
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn settings_content_rect(&self) -> Rect {
|
|
let inner = self.settings_inner_rect();
|
|
crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
|
|
}
|
|
|
|
fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
|
|
let area = self.settings_content_rect();
|
|
if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width
|
|
{
|
|
return None;
|
|
}
|
|
|
|
match self.settings.section {
|
|
SettingsSection::Theme => {
|
|
let max_visible = area.height as usize;
|
|
let scroll = if self.settings.list.selected >= max_visible {
|
|
self.settings.list.selected - max_visible + 1
|
|
} else {
|
|
0
|
|
};
|
|
let idx = scroll + (row - area.y) as usize;
|
|
(idx < THEME_NAMES.len()).then_some(idx)
|
|
}
|
|
SettingsSection::Sound => {
|
|
let list_y = area.y + 3;
|
|
if row >= list_y && row < list_y + 2 {
|
|
Some((row - list_y) as usize)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
SettingsSection::Toast => {
|
|
let list_y = area.y + 3;
|
|
if row >= list_y && row < list_y + 6 {
|
|
Some(((row - list_y) / 2) as usize)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
|
|
match mouse.kind {
|
|
MouseEventKind::Down(MouseButton::Left) => {
|
|
if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) {
|
|
self.settings.section = section;
|
|
self.settings.list.select(match section {
|
|
SettingsSection::Theme => current_theme_index(&self.theme_name),
|
|
SettingsSection::Sound => usize::from(!self.sound_enabled()),
|
|
SettingsSection::Toast => toast_delivery_index(self.toast_delivery()),
|
|
});
|
|
return None;
|
|
}
|
|
if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) {
|
|
self.settings.list.select(idx);
|
|
return match self.settings.section {
|
|
SettingsSection::Theme => {
|
|
preview_selected_theme(self);
|
|
None
|
|
}
|
|
SettingsSection::Sound => {
|
|
let enabled = idx == 0;
|
|
Some(SettingsAction::SaveSound(enabled))
|
|
}
|
|
SettingsSection::Toast => {
|
|
let delivery = toast_delivery_for_index(idx);
|
|
Some(SettingsAction::SaveToastDelivery(delivery))
|
|
}
|
|
};
|
|
}
|
|
|
|
let inner = self.settings_inner_rect();
|
|
let (apply, close) = crate::ui::settings_button_rects(inner);
|
|
match super::modal::modal_action_from_buttons(
|
|
mouse.column,
|
|
mouse.row,
|
|
&[
|
|
(apply, super::modal::ModalAction::Apply),
|
|
(close, super::modal::ModalAction::Close),
|
|
],
|
|
) {
|
|
Some(super::modal::ModalAction::Apply) => apply_settings(self),
|
|
Some(super::modal::ModalAction::Close) => {
|
|
cancel_settings(self);
|
|
None
|
|
}
|
|
_ => {
|
|
cancel_settings(self);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
|
|
|
|
use super::super::{app_for_mouse_test, mouse, state_with_workspaces};
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn settings_cancel_restores_previewed_theme_from_other_sections() {
|
|
let mut state = state_with_workspaces(&["test"]);
|
|
let original_palette = state.palette.clone();
|
|
let original_theme = state.theme_name.clone();
|
|
|
|
open_settings(&mut state);
|
|
update_settings_state(
|
|
&mut state,
|
|
KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
|
|
);
|
|
assert_ne!(state.theme_name, original_theme);
|
|
|
|
update_settings_state(
|
|
&mut state,
|
|
KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
|
|
);
|
|
assert_eq!(
|
|
state.settings.section,
|
|
crate::app::state::SettingsSection::Sound
|
|
);
|
|
|
|
update_settings_state(
|
|
&mut state,
|
|
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
|
|
);
|
|
|
|
assert_eq!(state.mode, Mode::Terminal);
|
|
assert_eq!(state.theme_name, original_theme);
|
|
assert_eq!(state.palette.accent, original_palette.accent);
|
|
assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_sound_toggle_returns_save_action() {
|
|
let mut state = state_with_workspaces(&["test"]);
|
|
open_settings(&mut state);
|
|
state.settings.section = crate::app::state::SettingsSection::Sound;
|
|
state.settings.list.selected = 0;
|
|
|
|
let action = update_settings_state(
|
|
&mut state,
|
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
|
|
);
|
|
|
|
assert_eq!(action, Some(SettingsAction::SaveSound(true)));
|
|
assert!(!state.sound.enabled);
|
|
assert_eq!(state.mode, Mode::Settings);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_hover_does_not_change_selection() {
|
|
let mut app = app_for_mouse_test();
|
|
open_settings(&mut app.state);
|
|
app.state.settings.list.select(0);
|
|
|
|
let area = app.state.settings_content_rect();
|
|
app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
|
|
|
|
assert_eq!(app.state.settings.list.selected, 0);
|
|
}
|
|
}
|