use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use sigye_core::{AnimationSpeed, AnimationStyle, BackgroundStyle, ColorTheme, TimeFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SettingsField {
#[default]
Font,
Color,
TimeFormat,
ShowSeconds,
Animation,
Speed,
Background,
ColonBlink,
PomodoroWork,
PomodoroBreak,
PomodoroLongBreak,
PomodoroSound,
DesktopNotifications,
TimerDuration,
}
impl SettingsField {
pub fn next(self) -> Self {
match self {
Self::Font => Self::Color,
Self::Color => Self::TimeFormat,
Self::TimeFormat => Self::ShowSeconds,
Self::ShowSeconds => Self::Animation,
Self::Animation => Self::Speed,
Self::Speed => Self::Background,
Self::Background => Self::ColonBlink,
Self::ColonBlink => Self::PomodoroWork,
Self::PomodoroWork => Self::PomodoroBreak,
Self::PomodoroBreak => Self::PomodoroLongBreak,
Self::PomodoroLongBreak => Self::PomodoroSound,
Self::PomodoroSound => Self::DesktopNotifications,
Self::DesktopNotifications => Self::TimerDuration,
Self::TimerDuration => Self::Font,
}
}
pub fn prev(self) -> Self {
match self {
Self::Font => Self::TimerDuration,
Self::TimerDuration => Self::DesktopNotifications,
Self::DesktopNotifications => Self::PomodoroSound,
Self::PomodoroSound => Self::PomodoroLongBreak,
Self::Color => Self::Font,
Self::TimeFormat => Self::Color,
Self::ShowSeconds => Self::TimeFormat,
Self::Animation => Self::ShowSeconds,
Self::Speed => Self::Animation,
Self::Background => Self::Speed,
Self::ColonBlink => Self::Background,
Self::PomodoroWork => Self::ColonBlink,
Self::PomodoroBreak => Self::PomodoroWork,
Self::PomodoroLongBreak => Self::PomodoroBreak,
}
}
}
enum RowKind {
Header(&'static str),
Field(SettingsField),
Spacer,
}
#[derive(Debug)]
pub struct SettingsDialog {
pub visible: bool,
pub selected_field: SettingsField,
scroll_offset: u16,
pub font_index: usize,
pub available_fonts: Vec<String>,
pub color_theme: ColorTheme,
pub time_format: TimeFormat,
pub animation_style: AnimationStyle,
pub animation_speed: AnimationSpeed,
pub background_style: BackgroundStyle,
pub colon_blink: bool,
pub show_seconds: bool,
pub pomodoro_work_mins: u32,
pub pomodoro_break_mins: u32,
pub pomodoro_long_break_mins: u32,
pub pomodoro_sound: bool,
pub desktop_notifications: bool,
pub timer_duration_mins: u32,
original_font_index: usize,
original_color_theme: ColorTheme,
original_time_format: TimeFormat,
original_animation_style: AnimationStyle,
original_animation_speed: AnimationSpeed,
original_background_style: BackgroundStyle,
original_colon_blink: bool,
original_show_seconds: bool,
original_pomodoro_work_mins: u32,
original_pomodoro_break_mins: u32,
original_pomodoro_long_break_mins: u32,
original_pomodoro_sound: bool,
original_desktop_notifications: bool,
original_timer_duration_mins: u32,
}
impl SettingsDialog {
pub fn new(available_fonts: Vec<String>) -> Self {
Self {
visible: false,
selected_field: SettingsField::default(),
scroll_offset: 0,
font_index: 0,
available_fonts,
color_theme: ColorTheme::default(),
time_format: TimeFormat::default(),
animation_style: AnimationStyle::default(),
animation_speed: AnimationSpeed::default(),
background_style: BackgroundStyle::default(),
colon_blink: false,
show_seconds: true,
pomodoro_work_mins: 25,
pomodoro_break_mins: 5,
pomodoro_long_break_mins: 15,
pomodoro_sound: true,
desktop_notifications: true,
timer_duration_mins: 5,
original_font_index: 0,
original_color_theme: ColorTheme::default(),
original_time_format: TimeFormat::default(),
original_animation_style: AnimationStyle::default(),
original_animation_speed: AnimationSpeed::default(),
original_background_style: BackgroundStyle::default(),
original_colon_blink: false,
original_show_seconds: true,
original_pomodoro_work_mins: 25,
original_pomodoro_break_mins: 5,
original_pomodoro_long_break_mins: 15,
original_pomodoro_sound: true,
original_desktop_notifications: true,
original_timer_duration_mins: 5,
}
}
#[allow(clippy::too_many_arguments)]
pub fn open(
&mut self,
font_name: &str,
color_theme: ColorTheme,
time_format: TimeFormat,
animation_style: AnimationStyle,
animation_speed: AnimationSpeed,
colon_blink: bool,
show_seconds: bool,
background_style: BackgroundStyle,
pomodoro_work_mins: u32,
pomodoro_break_mins: u32,
pomodoro_long_break_mins: u32,
pomodoro_sound: bool,
desktop_notifications: bool,
timer_duration_mins: u32,
) {
self.visible = true;
self.selected_field = SettingsField::default();
self.scroll_offset = 0;
self.color_theme = color_theme;
self.time_format = time_format;
self.animation_style = animation_style;
self.animation_speed = animation_speed;
self.background_style = background_style;
self.colon_blink = colon_blink;
self.show_seconds = show_seconds;
self.pomodoro_work_mins = pomodoro_work_mins;
self.pomodoro_break_mins = pomodoro_break_mins;
self.pomodoro_long_break_mins = pomodoro_long_break_mins;
self.pomodoro_sound = pomodoro_sound;
self.desktop_notifications = desktop_notifications;
self.timer_duration_mins = timer_duration_mins;
self.font_index = self
.available_fonts
.iter()
.position(|f| f == font_name)
.unwrap_or(0);
self.original_font_index = self.font_index;
self.original_color_theme = color_theme;
self.original_time_format = time_format;
self.original_animation_style = animation_style;
self.original_animation_speed = animation_speed;
self.original_background_style = background_style;
self.original_colon_blink = colon_blink;
self.original_show_seconds = show_seconds;
self.original_pomodoro_work_mins = pomodoro_work_mins;
self.original_pomodoro_break_mins = pomodoro_break_mins;
self.original_pomodoro_long_break_mins = pomodoro_long_break_mins;
self.original_pomodoro_sound = pomodoro_sound;
self.original_desktop_notifications = desktop_notifications;
self.original_timer_duration_mins = timer_duration_mins;
}
pub fn close(&mut self) {
self.visible = false;
}
pub fn original_font(&self) -> &str {
self.available_fonts
.get(self.original_font_index)
.map(String::as_str)
.unwrap_or("Standard")
}
pub fn original_color_theme(&self) -> ColorTheme {
self.original_color_theme
}
pub fn original_time_format(&self) -> TimeFormat {
self.original_time_format
}
pub fn original_animation_style(&self) -> AnimationStyle {
self.original_animation_style
}
pub fn original_animation_speed(&self) -> AnimationSpeed {
self.original_animation_speed
}
pub fn original_colon_blink(&self) -> bool {
self.original_colon_blink
}
pub fn original_show_seconds(&self) -> bool {
self.original_show_seconds
}
pub fn original_background_style(&self) -> BackgroundStyle {
self.original_background_style
}
pub fn original_pomodoro_work_mins(&self) -> u32 {
self.original_pomodoro_work_mins
}
pub fn original_pomodoro_break_mins(&self) -> u32 {
self.original_pomodoro_break_mins
}
pub fn original_pomodoro_long_break_mins(&self) -> u32 {
self.original_pomodoro_long_break_mins
}
pub fn original_pomodoro_sound(&self) -> bool {
self.original_pomodoro_sound
}
pub fn original_desktop_notifications(&self) -> bool {
self.original_desktop_notifications
}
pub fn original_timer_duration_mins(&self) -> u32 {
self.original_timer_duration_mins
}
pub fn next_field(&mut self) {
self.selected_field = self.selected_field.next();
}
pub fn prev_field(&mut self) {
self.selected_field = self.selected_field.prev();
}
fn section_layout() -> Vec<RowKind> {
vec![
RowKind::Header("Clock"),
RowKind::Field(SettingsField::Font),
RowKind::Field(SettingsField::Color),
RowKind::Field(SettingsField::TimeFormat),
RowKind::Field(SettingsField::ShowSeconds),
RowKind::Field(SettingsField::ColonBlink),
RowKind::Spacer,
RowKind::Header("Animation"),
RowKind::Field(SettingsField::Animation),
RowKind::Field(SettingsField::Speed),
RowKind::Field(SettingsField::Background),
RowKind::Spacer,
RowKind::Header("Pomodoro"),
RowKind::Field(SettingsField::PomodoroWork),
RowKind::Field(SettingsField::PomodoroBreak),
RowKind::Field(SettingsField::PomodoroLongBreak),
RowKind::Field(SettingsField::PomodoroSound),
RowKind::Field(SettingsField::DesktopNotifications),
RowKind::Spacer,
RowKind::Header("Timer"),
RowKind::Field(SettingsField::TimerDuration),
]
}
fn selected_field_row_index(&self) -> usize {
Self::section_layout()
.iter()
.position(|row| matches!(row, RowKind::Field(f) if *f == self.selected_field))
.unwrap_or(0)
}
fn ensure_visible(&mut self, visible_rows: u16) {
let row_idx = self.selected_field_row_index() as u16;
if row_idx < self.scroll_offset {
self.scroll_offset = row_idx;
}
if row_idx >= self.scroll_offset + visible_rows {
self.scroll_offset = row_idx.saturating_sub(visible_rows - 1);
}
}
pub fn next_value(&mut self) {
match self.selected_field {
SettingsField::Font => {
if !self.available_fonts.is_empty() {
self.font_index = (self.font_index + 1) % self.available_fonts.len();
}
}
SettingsField::Color => {
self.color_theme = self.color_theme.next();
}
SettingsField::TimeFormat => {
self.time_format = self.time_format.toggle();
}
SettingsField::ShowSeconds => {
self.show_seconds = !self.show_seconds;
}
SettingsField::Animation => {
self.animation_style = self.animation_style.next();
}
SettingsField::Speed => {
self.animation_speed = self.animation_speed.next();
}
SettingsField::Background => {
self.background_style = self.background_style.next();
}
SettingsField::ColonBlink => {
self.colon_blink = !self.colon_blink;
}
SettingsField::PomodoroWork => {
self.pomodoro_work_mins = match self.pomodoro_work_mins {
15 => 20,
20 => 25,
25 => 30,
30 => 45,
45 => 50,
50 => 60,
_ => 15,
};
}
SettingsField::PomodoroBreak => {
self.pomodoro_break_mins = match self.pomodoro_break_mins {
3 => 5,
5 => 10,
10 => 15,
_ => 3,
};
}
SettingsField::PomodoroLongBreak => {
self.pomodoro_long_break_mins = match self.pomodoro_long_break_mins {
10 => 15,
15 => 20,
20 => 30,
_ => 10,
};
}
SettingsField::PomodoroSound => {
self.pomodoro_sound = !self.pomodoro_sound;
}
SettingsField::DesktopNotifications => {
self.desktop_notifications = !self.desktop_notifications;
}
SettingsField::TimerDuration => {
self.timer_duration_mins = (self.timer_duration_mins + 1).min(99);
}
}
}
pub fn prev_value(&mut self) {
match self.selected_field {
SettingsField::Font => {
if !self.available_fonts.is_empty() {
self.font_index = if self.font_index == 0 {
self.available_fonts.len() - 1
} else {
self.font_index - 1
};
}
}
SettingsField::Color => {
self.color_theme = self.color_theme.prev();
}
SettingsField::TimeFormat => {
self.time_format = self.time_format.toggle();
}
SettingsField::ShowSeconds => {
self.show_seconds = !self.show_seconds;
}
SettingsField::Animation => {
self.animation_style = self.animation_style.prev();
}
SettingsField::Speed => {
self.animation_speed = self.animation_speed.prev();
}
SettingsField::Background => {
self.background_style = self.background_style.prev();
}
SettingsField::ColonBlink => {
self.colon_blink = !self.colon_blink;
}
SettingsField::PomodoroWork => {
self.pomodoro_work_mins = match self.pomodoro_work_mins {
15 => 60,
20 => 15,
25 => 20,
30 => 25,
45 => 30,
50 => 45,
60 => 50,
_ => 25,
};
}
SettingsField::PomodoroBreak => {
self.pomodoro_break_mins = match self.pomodoro_break_mins {
3 => 15,
5 => 3,
10 => 5,
15 => 10,
_ => 5,
};
}
SettingsField::PomodoroLongBreak => {
self.pomodoro_long_break_mins = match self.pomodoro_long_break_mins {
10 => 30,
15 => 10,
20 => 15,
30 => 20,
_ => 15,
};
}
SettingsField::PomodoroSound => {
self.pomodoro_sound = !self.pomodoro_sound;
}
SettingsField::DesktopNotifications => {
self.desktop_notifications = !self.desktop_notifications;
}
SettingsField::TimerDuration => {
self.timer_duration_mins = (self.timer_duration_mins.saturating_sub(1)).max(1);
}
}
}
pub fn selected_font(&self) -> &str {
self.available_fonts
.get(self.font_index)
.map(String::as_str)
.unwrap_or("Standard")
}
pub fn render(&mut self, frame: &mut Frame, area: Rect, accent_color: Color) {
if !self.visible {
return;
}
let layout = Self::section_layout();
let total_content_rows = layout.len() as u16;
let dialog_width = 50.min(area.width.saturating_sub(4));
let dialog_height = (total_content_rows + 5).min(area.height.saturating_sub(2));
let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Settings ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_style(Style::default().fg(accent_color))
.style(Style::default().fg(Color::White).bg(Color::Black));
let inner_area = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), ])
.split(inner_area);
let content_area = Rect::new(
chunks[1].x + 2,
chunks[1].y,
chunks[1].width.saturating_sub(4),
chunks[1].height,
);
let visible_rows = content_area.height;
self.ensure_visible(visible_rows);
let can_scroll_up = self.scroll_offset > 0;
let can_scroll_down = total_content_rows > self.scroll_offset + visible_rows;
for (row_idx, row) in layout.iter().enumerate() {
let row_idx = row_idx as u16;
if row_idx < self.scroll_offset {
continue;
}
let visible_y = row_idx - self.scroll_offset;
if visible_y >= visible_rows {
break;
}
let row_area = Rect::new(
content_area.x,
content_area.y + visible_y,
content_area.width,
1,
);
match row {
RowKind::Header(name) => {
let header = self.render_section_header(name, accent_color);
frame.render_widget(
Paragraph::new(header).alignment(Alignment::Center),
row_area,
);
}
RowKind::Field(field) => {
let line = self.render_field_for(*field, accent_color);
frame
.render_widget(Paragraph::new(line).alignment(Alignment::Center), row_area);
}
RowKind::Spacer => {} }
}
if can_scroll_up {
let indicator = Line::from(Span::styled(" ▲ ", Style::default().fg(accent_color)));
let indicator_area = Rect::new(
content_area.x + content_area.width.saturating_sub(6),
content_area.y,
6,
1,
);
frame.render_widget(
Paragraph::new(indicator).alignment(Alignment::Right),
indicator_area,
);
}
if can_scroll_down {
let indicator = Line::from(Span::styled(" ▼ ", Style::default().fg(accent_color)));
let indicator_area = Rect::new(
content_area.x + content_area.width.saturating_sub(6),
content_area.y + visible_rows.saturating_sub(1),
6,
1,
);
frame.render_widget(
Paragraph::new(indicator).alignment(Alignment::Right),
indicator_area,
);
}
let help = Line::from(vec![
Span::styled("↑↓", Style::default().fg(accent_color).bold()),
Span::styled(" nav ", Style::default().fg(Color::Gray)),
Span::styled("←→", Style::default().fg(accent_color).bold()),
Span::styled(" change ", Style::default().fg(Color::Gray)),
Span::styled("Enter", Style::default().fg(accent_color).bold()),
Span::styled(" save ", Style::default().fg(Color::Gray)),
Span::styled("Esc", Style::default().fg(accent_color).bold()),
Span::styled(" cancel", Style::default().fg(Color::Gray)),
]);
frame.render_widget(Paragraph::new(help).alignment(Alignment::Center), chunks[3]);
}
fn render_section_header(&self, name: &str, accent_color: Color) -> Line<'static> {
Line::from(Span::styled(
format!("── {name} ──"),
Style::default().fg(accent_color).bold(),
))
}
fn render_field_for(&self, field: SettingsField, accent_color: Color) -> Line<'static> {
let selected = self.selected_field == field;
match field {
SettingsField::Font => {
self.render_field("Font", self.selected_font(), selected, accent_color)
}
SettingsField::Color => self.render_field(
"Color",
self.color_theme.display_name(),
selected,
accent_color,
),
SettingsField::TimeFormat => {
let name = match self.time_format {
TimeFormat::TwentyFourHour => "24-hour",
TimeFormat::TwelveHour => "12-hour",
};
self.render_field("Format", name, selected, accent_color)
}
SettingsField::ShowSeconds => {
let v = if self.show_seconds { "On" } else { "Off" };
self.render_field("Seconds", v, selected, accent_color)
}
SettingsField::ColonBlink => {
let v = if self.colon_blink { "On" } else { "Off" };
self.render_field("Colon Blink", v, selected, accent_color)
}
SettingsField::Animation => self.render_field(
"Animation",
self.animation_style.display_name(),
selected,
accent_color,
),
SettingsField::Speed => self.render_field_with_style(
"Speed",
self.animation_speed.display_name(),
selected,
accent_color,
self.animation_style != AnimationStyle::None,
),
SettingsField::Background => self.render_field(
"Background",
self.background_style.display_name(),
selected,
accent_color,
),
SettingsField::PomodoroWork => {
let v = format!("{} min", self.pomodoro_work_mins);
self.render_field("Work", &v, selected, accent_color)
}
SettingsField::PomodoroBreak => {
let v = format!("{} min", self.pomodoro_break_mins);
self.render_field("Break", &v, selected, accent_color)
}
SettingsField::PomodoroLongBreak => {
let v = format!("{} min", self.pomodoro_long_break_mins);
self.render_field("Long Break", &v, selected, accent_color)
}
SettingsField::PomodoroSound => {
let v = if self.pomodoro_sound { "On" } else { "Off" };
self.render_field("Sound", v, selected, accent_color)
}
SettingsField::DesktopNotifications => {
let v = if self.desktop_notifications {
"On"
} else {
"Off"
};
self.render_field("Notifications", v, selected, accent_color)
}
SettingsField::TimerDuration => {
let v = format!("{} min", self.timer_duration_mins);
self.render_field("Duration", &v, selected, accent_color)
}
}
}
fn render_field(
&self,
label: &str,
value: &str,
selected: bool,
accent_color: Color,
) -> Line<'static> {
if selected {
let arrow_style = Style::default().fg(accent_color).bold();
let value_style = Style::default().fg(accent_color).bold();
let label_style = Style::default().fg(accent_color);
Line::from(vec![
Span::styled(String::from("► "), arrow_style),
Span::styled(format!("{label}: "), label_style),
Span::styled(String::from("◀ "), arrow_style),
Span::styled(value.to_string(), value_style),
Span::styled(String::from(" ▶"), arrow_style),
])
} else {
let label_style = Style::default().fg(Color::Gray);
let value_style = Style::default().fg(Color::White);
Line::from(vec![
Span::styled(String::from(" "), Style::default()),
Span::styled(format!("{label}: "), label_style),
Span::styled(value.to_string(), value_style),
])
}
}
fn render_field_with_style(
&self,
label: &str,
value: &str,
selected: bool,
accent_color: Color,
enabled: bool,
) -> Line<'static> {
if !enabled {
let gray = Style::default().fg(Color::DarkGray);
return Line::from(vec![
Span::styled(String::from(" "), Style::default()),
Span::styled(format!("{label}: "), gray),
Span::styled(value.to_string(), gray),
]);
}
self.render_field(label, value, selected, accent_color)
}
}