Skip to content

Commit bc4ae63

Browse files
committed
✨ Add theme selector modal with live preview and search filtering
Introduce a dedicated theme selection experience in Iris Studio that lets users browse all available themes with live preview. The modal displays themes organized by variant (dark/light), supports filtering by name or author, and applies themes in real-time as the user navigates. Key additions: - Theme selector modal with two-panel layout (list + preview) - Live preview showing color swatches and gradients - Keyboard navigation with j/k and arrow keys - Search/filter support with automatic selection update - Dark themes sorted before light themes for better organization Also reorganizes theme tests into dedicated test modules for improved maintainability.
1 parent f72b66b commit bc4ae63

File tree

11 files changed

+827
-269
lines changed

11 files changed

+827
-269
lines changed

src/studio/handlers/modals/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod preset_selector;
1010
mod ref_selector;
1111
mod search;
1212
mod settings;
13+
mod theme_selector;
1314

1415
use crossterm::event::KeyEvent;
1516

@@ -32,6 +33,7 @@ pub fn handle_modal_key(state: &mut StudioState, key: KeyEvent) -> Vec<SideEffec
3233
Some(Modal::PresetSelector { .. }) => preset_selector::handle(state, key),
3334
Some(Modal::EmojiSelector { .. }) => emoji_selector::handle(state, key),
3435
Some(Modal::Settings(_)) => settings::handle(state, key),
36+
Some(Modal::ThemeSelector { .. }) => theme_selector::handle(state, key),
3537
None => vec![],
3638
}
3739
}

src/studio/handlers/modals/settings.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use crossterm::event::{KeyCode, KeyEvent};
44

55
use crate::studio::events::SideEffect;
6-
use crate::studio::state::{Modal, StudioState};
6+
use crate::studio::state::{Modal, SettingsField, StudioState};
77

88
/// Handle key events in settings modal
99
pub fn handle(state: &mut StudioState, key: KeyEvent) -> Vec<SideEffect> {
@@ -80,6 +80,26 @@ fn handle_navigation_mode(state: &mut StudioState, key: KeyEvent) -> Vec<SideEff
8080
vec![]
8181
}
8282
KeyCode::Enter | KeyCode::Char(' ') => {
83+
// Check if Theme field - open theme selector modal instead
84+
if let Some(Modal::Settings(settings)) = &state.modal
85+
&& settings.current_field() == SettingsField::Theme
86+
{
87+
// Get themes and current selection index
88+
let themes = settings.available_themes.clone();
89+
let selected = themes
90+
.iter()
91+
.position(|t| t.id == settings.theme)
92+
.unwrap_or(0);
93+
// Open theme selector modal
94+
state.modal = Some(Modal::ThemeSelector {
95+
input: String::new(),
96+
themes,
97+
selected,
98+
scroll: 0,
99+
});
100+
state.mark_dirty();
101+
return vec![];
102+
}
83103
if let Some(Modal::Settings(settings)) = &mut state.modal {
84104
settings.start_editing();
85105
}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
//! Theme selector modal key handler
2+
3+
use crossterm::event::{KeyCode, KeyEvent};
4+
5+
use crate::studio::events::SideEffect;
6+
use crate::studio::state::{Modal, Notification, StudioState};
7+
use crate::theme;
8+
9+
/// Visible items in the list (modal height - header - footer - variant separators)
10+
const VISIBLE_ITEMS: usize = 16;
11+
12+
/// Handle key events in theme selector modal
13+
pub fn handle(state: &mut StudioState, key: KeyEvent) -> Vec<SideEffect> {
14+
// Verify we're in the right modal
15+
let Some(Modal::ThemeSelector {
16+
themes, selected, ..
17+
}) = &state.modal
18+
else {
19+
return vec![];
20+
};
21+
let themes = themes.clone();
22+
let selected = *selected;
23+
24+
match key.code {
25+
KeyCode::Esc => {
26+
state.close_modal();
27+
vec![]
28+
}
29+
KeyCode::Enter => {
30+
// Apply selected theme
31+
if let Some(theme_info) = themes.get(selected) {
32+
// Load the theme
33+
if theme::load_theme_by_name(&theme_info.id).is_ok() {
34+
// Theme has been applied (done above)
35+
state.notify(Notification::success(format!(
36+
"Theme: {}",
37+
theme_info.display_name
38+
)));
39+
} else {
40+
state.notify(Notification::error(format!(
41+
"Failed to load theme: {}",
42+
theme_info.id
43+
)));
44+
}
45+
}
46+
state.close_modal();
47+
vec![]
48+
}
49+
KeyCode::Up | KeyCode::Char('k') => {
50+
if let Some(Modal::ThemeSelector {
51+
selected,
52+
scroll,
53+
themes,
54+
input,
55+
}) = &mut state.modal
56+
{
57+
// Get filtered indices
58+
let filtered_indices: Vec<usize> = themes
59+
.iter()
60+
.enumerate()
61+
.filter(|(_, t)| {
62+
input.is_empty()
63+
|| t.display_name
64+
.to_lowercase()
65+
.contains(&input.to_lowercase())
66+
|| t.author.to_lowercase().contains(&input.to_lowercase())
67+
})
68+
.map(|(i, _)| i)
69+
.collect();
70+
71+
// Find current position in filtered list
72+
if let Some(pos) = filtered_indices.iter().position(|&i| i == *selected)
73+
&& pos > 0
74+
{
75+
*selected = filtered_indices[pos - 1];
76+
// Scroll up if selection goes above visible area
77+
if pos - 1 < *scroll {
78+
*scroll = pos - 1;
79+
}
80+
// Apply live preview
81+
if let Some(theme_info) = themes.get(*selected) {
82+
let _ = theme::load_theme_by_name(&theme_info.id);
83+
}
84+
}
85+
}
86+
state.mark_dirty();
87+
vec![]
88+
}
89+
KeyCode::Down | KeyCode::Char('j') => {
90+
if let Some(Modal::ThemeSelector {
91+
selected,
92+
scroll,
93+
themes,
94+
input,
95+
}) = &mut state.modal
96+
{
97+
// Get filtered indices
98+
let filtered_indices: Vec<usize> = themes
99+
.iter()
100+
.enumerate()
101+
.filter(|(_, t)| {
102+
input.is_empty()
103+
|| t.display_name
104+
.to_lowercase()
105+
.contains(&input.to_lowercase())
106+
|| t.author.to_lowercase().contains(&input.to_lowercase())
107+
})
108+
.map(|(i, _)| i)
109+
.collect();
110+
111+
// Find current position in filtered list
112+
if let Some(pos) = filtered_indices.iter().position(|&i| i == *selected)
113+
&& pos + 1 < filtered_indices.len()
114+
{
115+
*selected = filtered_indices[pos + 1];
116+
// Scroll down if selection goes below visible area
117+
if pos + 1 >= *scroll + VISIBLE_ITEMS {
118+
*scroll = pos + 1 - VISIBLE_ITEMS + 1;
119+
}
120+
// Apply live preview
121+
if let Some(theme_info) = themes.get(*selected) {
122+
let _ = theme::load_theme_by_name(&theme_info.id);
123+
}
124+
}
125+
}
126+
state.mark_dirty();
127+
vec![]
128+
}
129+
KeyCode::Char(c) => {
130+
if let Some(Modal::ThemeSelector {
131+
input,
132+
selected,
133+
scroll,
134+
themes,
135+
}) = &mut state.modal
136+
{
137+
input.push(c);
138+
*scroll = 0;
139+
// Select first matching theme
140+
let first_match = themes
141+
.iter()
142+
.enumerate()
143+
.find(|(_, t)| {
144+
input.is_empty()
145+
|| t.display_name
146+
.to_lowercase()
147+
.contains(&input.to_lowercase())
148+
|| t.author.to_lowercase().contains(&input.to_lowercase())
149+
})
150+
.map(|(i, _)| i);
151+
*selected = first_match.unwrap_or(0);
152+
}
153+
state.mark_dirty();
154+
vec![]
155+
}
156+
KeyCode::Backspace => {
157+
if let Some(Modal::ThemeSelector {
158+
input,
159+
selected,
160+
scroll,
161+
themes,
162+
}) = &mut state.modal
163+
{
164+
input.pop();
165+
*scroll = 0;
166+
// Select first matching theme
167+
let first_match = themes
168+
.iter()
169+
.enumerate()
170+
.find(|(_, t)| {
171+
input.is_empty()
172+
|| t.display_name
173+
.to_lowercase()
174+
.contains(&input.to_lowercase())
175+
|| t.author.to_lowercase().contains(&input.to_lowercase())
176+
})
177+
.map(|(i, _)| i);
178+
*selected = first_match.unwrap_or(0);
179+
}
180+
state.mark_dirty();
181+
vec![]
182+
}
183+
_ => vec![],
184+
}
185+
}

src/studio/render/modals/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ mod preset_selector;
1111
mod ref_selector;
1212
mod search;
1313
mod settings;
14+
mod theme_selector;
1415

1516
use ratatui::Frame;
1617
use ratatui::layout::Rect;
@@ -61,6 +62,11 @@ fn modal_size(modal: &Modal, area: Rect) -> (u16, u16) {
6162
Modal::EmojiSelector { .. } => (55.min(max_width), 26.min(max_height)),
6263
// Settings modal - full width for fields, compact preview strip
6364
Modal::Settings(_) => (70.min(max_width), 24.min(max_height)),
65+
// Theme selector modal - spacious for preview and list
66+
Modal::ThemeSelector { themes, .. } => {
67+
let list_height = (themes.len() as u16 + 8).min(28);
68+
(75.min(max_width), list_height.min(max_height))
69+
}
6470
}
6571
}
6672

@@ -112,5 +118,11 @@ pub fn render_modal(state: &StudioState, frame: &mut Frame, last_render: Instant
112118
scroll,
113119
} => emoji_selector::render(frame, modal_area, input, emojis, *selected, *scroll),
114120
Modal::Settings(settings_state) => settings::render(frame, modal_area, settings_state),
121+
Modal::ThemeSelector {
122+
input,
123+
themes,
124+
selected,
125+
scroll,
126+
} => theme_selector::render(frame, modal_area, input, themes, *selected, *scroll),
115127
}
116128
}

0 commit comments

Comments
 (0)