Skip to content

Commit 8ecd632

Browse files
Merge pull request #25 from gitcoder89431/20-implement-settings-modal-ui-component
feat: enhance settings modal with navigation and selection functionality
2 parents 95e6c8b + 1b5dfa5 commit 8ecd632

File tree

3 files changed

+237
-30
lines changed

3 files changed

+237
-30
lines changed

src/events.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ pub enum AppEvent {
1616
OpenSettings,
1717
/// User requested to close settings modal
1818
CloseSettings,
19+
/// Navigate up in settings modal
20+
NavigateUp,
21+
/// Navigate down in settings modal
22+
NavigateDown,
23+
/// Select current item in settings modal
24+
Select,
1925
/// Settings action to be applied
2026
SettingsAction(crate::settings::SettingsAction),
2127
/// Terminal was resized to new dimensions
@@ -49,8 +55,11 @@ impl EventHandler {
4955
match key_event.code {
5056
KeyCode::Char('q') => Ok(AppEvent::Quit),
5157
KeyCode::Esc => Ok(AppEvent::CloseSettings),
52-
KeyCode::Char(',') => Ok(AppEvent::OpenSettings),
58+
KeyCode::Char('s') | KeyCode::Char('S') => Ok(AppEvent::OpenSettings),
5359
KeyCode::Char('t') | KeyCode::Char('T') => Ok(AppEvent::ToggleTheme),
60+
KeyCode::Up => Ok(AppEvent::NavigateUp),
61+
KeyCode::Down => Ok(AppEvent::NavigateDown),
62+
KeyCode::Enter => Ok(AppEvent::Select),
5463
KeyCode::Char('c')
5564
if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
5665
{
@@ -70,7 +79,7 @@ impl EventHandler {
7079

7180
impl Default for EventHandler {
7281
fn default() -> Self {
73-
Self::new(Duration::from_millis(50))
82+
Self::new(Duration::from_millis(100)) // 100ms timeout for better efficiency
7483
}
7584
}
7685

src/settings.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
//! Provides clean separation of concerns and prepares for future feature expansion.
55
66
use crate::theme::{Theme, ThemeVariant};
7+
use ratatui::{
8+
Frame,
9+
layout::{Alignment, Constraint, Direction, Layout, Rect},
10+
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
11+
};
712

813
/// Core settings structure with extensible design
914
#[derive(Debug, Clone)]
@@ -17,6 +22,56 @@ pub struct Settings {
1722
// pub advanced: AdvancedConfig,
1823
}
1924

25+
/// Settings modal state for UI navigation
26+
#[derive(Debug, Clone)]
27+
pub struct SettingsModalState {
28+
/// Currently selected theme index in the modal
29+
pub selected_theme_index: usize,
30+
/// Available theme variants for selection
31+
pub available_themes: Vec<ThemeVariant>,
32+
}
33+
34+
impl SettingsModalState {
35+
/// Create a new modal state with current theme selected
36+
pub fn new(current_theme: ThemeVariant) -> Self {
37+
let available_themes = vec![ThemeVariant::EverforestDark, ThemeVariant::EverforestLight];
38+
let selected_theme_index = available_themes
39+
.iter()
40+
.position(|&t| t == current_theme)
41+
.unwrap_or(0);
42+
43+
Self {
44+
selected_theme_index,
45+
available_themes,
46+
}
47+
}
48+
49+
/// Navigate up in the theme selection
50+
pub fn navigate_up(&mut self) {
51+
if self.selected_theme_index > 0 {
52+
self.selected_theme_index -= 1;
53+
} else {
54+
// Wrap to bottom
55+
self.selected_theme_index = self.available_themes.len() - 1;
56+
}
57+
}
58+
59+
/// Navigate down in the theme selection
60+
pub fn navigate_down(&mut self) {
61+
if self.selected_theme_index < self.available_themes.len() - 1 {
62+
self.selected_theme_index += 1;
63+
} else {
64+
// Wrap to top
65+
self.selected_theme_index = 0;
66+
}
67+
}
68+
69+
/// Get the currently selected theme variant
70+
pub fn selected_theme(&self) -> ThemeVariant {
71+
self.available_themes[self.selected_theme_index]
72+
}
73+
}
74+
2075
impl Settings {
2176
/// Create new settings instance with sensible defaults
2277
pub fn new() -> Self {
@@ -189,6 +244,99 @@ impl SettingsManager {
189244
// pub fn auto_save(&self) -> Result<(), SettingsError>
190245
}
191246

247+
/// Render the settings modal as a centered popup
248+
pub fn render_settings_modal(
249+
f: &mut Frame,
250+
area: Rect,
251+
modal_state: &SettingsModalState,
252+
theme: &Theme,
253+
) {
254+
// Create a centered modal area
255+
let modal_area = centered_rect(60, 40, area);
256+
257+
// Clear the background (overlay effect)
258+
f.render_widget(Clear, area);
259+
260+
// Create the modal layout
261+
let modal_layout = Layout::default()
262+
.direction(Direction::Vertical)
263+
.constraints([
264+
Constraint::Length(3), // Title section
265+
Constraint::Min(4), // Theme selection
266+
Constraint::Length(2), // Help text
267+
])
268+
.split(modal_area);
269+
270+
// Modal border and title
271+
let modal_block = Block::default()
272+
.title(" Settings ")
273+
.borders(Borders::ALL)
274+
.border_style(theme.border_style());
275+
276+
f.render_widget(modal_block, modal_area);
277+
278+
// Theme selection section
279+
let theme_section = Paragraph::new("Theme Selection")
280+
.style(theme.text_style())
281+
.alignment(Alignment::Left);
282+
f.render_widget(theme_section, modal_layout[0]);
283+
284+
// Theme options with radio buttons
285+
let themes = [
286+
("Everforest Dark", ThemeVariant::EverforestDark),
287+
("Everforest Light", ThemeVariant::EverforestLight),
288+
];
289+
290+
let items: Vec<ListItem> = themes
291+
.iter()
292+
.enumerate()
293+
.map(|(i, (name, _variant))| {
294+
let indicator = if i == modal_state.selected_theme_index {
295+
"●" // Filled circle for selected
296+
} else {
297+
"○" // Empty circle for unselected
298+
};
299+
let style = if i == modal_state.selected_theme_index {
300+
theme.highlight_style()
301+
} else {
302+
theme.text_style()
303+
};
304+
ListItem::new(format!(" {} {}", indicator, name)).style(style)
305+
})
306+
.collect();
307+
308+
let theme_list = List::new(items).style(theme.text_style());
309+
310+
f.render_widget(theme_list, modal_layout[1]);
311+
312+
// Help text at bottom
313+
let help_text = Paragraph::new("ESC: Close ↑↓: Navigate")
314+
.style(theme.secondary_style())
315+
.alignment(Alignment::Center);
316+
f.render_widget(help_text, modal_layout[2]);
317+
}
318+
319+
/// Calculate centered rectangle for modal positioning
320+
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
321+
let popup_layout = Layout::default()
322+
.direction(Direction::Vertical)
323+
.constraints([
324+
Constraint::Percentage((100 - percent_y) / 2),
325+
Constraint::Percentage(percent_y),
326+
Constraint::Percentage((100 - percent_y) / 2),
327+
])
328+
.split(r);
329+
330+
Layout::default()
331+
.direction(Direction::Horizontal)
332+
.constraints([
333+
Constraint::Percentage((100 - percent_x) / 2),
334+
Constraint::Percentage(percent_x),
335+
Constraint::Percentage((100 - percent_x) / 2),
336+
])
337+
.split(popup_layout[1])[1]
338+
}
339+
192340
impl Default for SettingsManager {
193341
fn default() -> Self {
194342
Self::new()

src/ui/app.rs

Lines changed: 78 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use crate::{
44
events::{AppEvent, AppState, EventHandler},
55
layout::AppLayout,
6-
settings::{Settings, SettingsAction, SettingsManager},
6+
settings::{Settings, SettingsAction, SettingsManager, SettingsModalState},
77
theme::{Element, Theme},
88
};
99
use crossterm::{
@@ -17,8 +17,7 @@ use ratatui::{
1717
layout::Alignment,
1818
widgets::{Block, Borders, Paragraph, Wrap},
1919
};
20-
use std::{io, time::Duration};
21-
use tokio::time;
20+
use std::io;
2221

2322
/// Main application state and manager
2423
pub struct App {
@@ -34,6 +33,8 @@ pub struct App {
3433
event_handler: EventHandler,
3534
/// Settings manager for configuration
3635
settings: SettingsManager,
36+
/// Settings modal state for navigation
37+
modal_state: Option<SettingsModalState>,
3738
/// Last known terminal size for resize detection
3839
last_size: Option<(u16, u16)>,
3940
}
@@ -48,6 +49,7 @@ impl App {
4849
layout: AppLayout::new().expect("Failed to create layout"),
4950
event_handler: EventHandler::default(),
5051
settings: SettingsManager::new(),
52+
modal_state: None,
5153
last_size: None,
5254
}
5355
}
@@ -69,48 +71,53 @@ impl App {
6971
self.previous_state = self.state.clone();
7072
}
7173
self.state = AppState::Settings;
74+
75+
// Initialize modal state with current theme
76+
self.modal_state = Some(SettingsModalState::new(self.settings.get().theme_variant));
7277
}
7378

7479
/// Exit settings modal and return to previous state
7580
pub fn exit_settings(&mut self) {
7681
self.state = self.previous_state.clone();
82+
self.modal_state = None;
7783
}
7884

7985
/// Main application run loop with proper async event handling
8086
pub async fn run<B: Backend>(
8187
&mut self,
8288
terminal: &mut Terminal<B>,
8389
) -> Result<(), Box<dyn std::error::Error>> {
84-
let mut interval = time::interval(Duration::from_millis(16)); // ~60 FPS
90+
// Initial render
91+
terminal.draw(|f| self.draw(f))?;
8592

8693
loop {
87-
// Handle the render/update cycle
88-
tokio::select! {
89-
_ = interval.tick() => {
90-
// Render the UI
91-
terminal.draw(|f| self.draw(f))?;
92-
93-
// Check if we should quit
94-
if self.should_quit() {
95-
break;
96-
}
97-
}
98-
99-
// Handle input events
100-
event_result = {
101-
let event_handler = self.event_handler.clone();
102-
tokio::task::spawn_blocking(move || event_handler.next_event())
103-
} => {
104-
match event_result? {
105-
Ok(event) => {
106-
self.handle_event(event);
107-
}
108-
Err(e) => {
109-
self.state = AppState::Error(format!("Input error: {}", e));
94+
// Handle input events - this will block until an event occurs
95+
let event_result = {
96+
let event_handler = self.event_handler.clone();
97+
tokio::task::spawn_blocking(move || event_handler.next_event())
98+
}.await;
99+
100+
match event_result? {
101+
Ok(event) => {
102+
// Only handle events that aren't None
103+
if event != AppEvent::None {
104+
self.handle_event(event);
105+
106+
// Only redraw after handling a real event
107+
terminal.draw(|f| self.draw(f))?;
108+
109+
// Check if we should quit after handling the event
110+
if self.should_quit() {
110111
break;
111112
}
112113
}
113114
}
115+
Err(e) => {
116+
self.state = AppState::Error(format!("Input error: {}", e));
117+
// Redraw to show error state
118+
terminal.draw(|f| self.draw(f))?;
119+
break;
120+
}
114121
}
115122
}
116123

@@ -135,6 +142,42 @@ impl App {
135142
self.state = AppState::Quitting;
136143
}
137144
}
145+
AppEvent::NavigateUp => {
146+
// Only handle navigation in settings modal
147+
if matches!(self.state, AppState::Settings) {
148+
if let Some(ref mut modal_state) = self.modal_state {
149+
modal_state.navigate_up();
150+
// Apply live theme preview
151+
let selected_theme = modal_state.selected_theme();
152+
self.theme.set_variant(selected_theme);
153+
}
154+
}
155+
}
156+
AppEvent::NavigateDown => {
157+
// Only handle navigation in settings modal
158+
if matches!(self.state, AppState::Settings) {
159+
if let Some(ref mut modal_state) = self.modal_state {
160+
modal_state.navigate_down();
161+
// Apply live theme preview
162+
let selected_theme = modal_state.selected_theme();
163+
self.theme.set_variant(selected_theme);
164+
}
165+
}
166+
}
167+
AppEvent::Select => {
168+
// Only handle selection in settings modal
169+
if matches!(self.state, AppState::Settings) {
170+
if let Some(ref modal_state) = self.modal_state {
171+
let selected_theme = modal_state.selected_theme();
172+
let action = SettingsAction::ChangeTheme(selected_theme);
173+
if let Err(e) = self.handle_settings_action(action) {
174+
self.state = AppState::Error(format!("Settings error: {}", e));
175+
}
176+
// Close modal after selection
177+
self.exit_settings();
178+
}
179+
}
180+
}
138181
AppEvent::SettingsAction(action) => {
139182
// Handle settings actions and apply theme changes immediately
140183
if let Err(e) = self.handle_settings_action(action) {
@@ -180,6 +223,13 @@ impl App {
180223
self.render_header(frame, layout_rects.header);
181224
self.render_main_content(frame, layout_rects.body);
182225
self.render_footer(frame, layout_rects.footer);
226+
227+
// Render modal overlay if in settings state
228+
if matches!(self.state, AppState::Settings) {
229+
if let Some(ref modal_state) = self.modal_state {
230+
crate::settings::render_settings_modal(frame, size, modal_state, &self.theme);
231+
}
232+
}
183233
}
184234

185235
/// Render the header section
@@ -320,7 +370,7 @@ impl App {
320370

321371
let footer_text = match self.state {
322372
AppState::Main => format!(
323-
"ESC/q: Quit | T: Toggle Theme | ,: Settings | Current: [{}] | Production v0.1.0",
373+
"ESC/q: Quit | T: Toggle Theme | S: Settings | Current: [{}] | Production v0.1.0",
324374
current_theme
325375
),
326376
AppState::Settings => format!(

0 commit comments

Comments
 (0)