Files
herdr/src/app/input/settings.rs
T

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);
}
}