Skip to content

Commit 8e21b6d

Browse files
committed
Add in-app theme selector with Shift+T keybinding
Users can now switch themes directly from the TUI without editing config files: - Press Shift+T to open theme selector modal - Navigate with j/k or arrow keys - Apply theme with Enter (changes take effect immediately) - Cancel with Esc The selector shows all 5 available themes with visual indicators for the currently selected and active themes. Theme preference is automatically saved to config. Bump version to 0.6.0
1 parent 77dca15 commit 8e21b6d

5 files changed

Lines changed: 179 additions & 2 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tui-kanban"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
edition = "2021"
55

66
[dependencies]

src/app.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub struct App {
1717
pub focused_field: TaskField,
1818
pub disable_saving: bool, // For testing
1919
pub theme: Theme,
20+
pub selected_theme_index: usize, // for theme selector view
2021
}
2122

2223
// which field is focused in task detail view
@@ -42,6 +43,7 @@ pub enum InputMode {
4243
AddingColumn,
4344
RenamingColumn,
4445
ConfirmingDelete,
46+
SelectingTheme,
4547
}
4648

4749
impl App {
@@ -74,6 +76,7 @@ impl App {
7476
focused_field: TaskField::Title,
7577
disable_saving: false,
7678
theme,
79+
selected_theme_index: 0,
7780
}
7881
}
7982

@@ -115,6 +118,7 @@ impl App {
115118
focused_field: TaskField::Title,
116119
disable_saving: true,
117120
theme: Theme::default(),
121+
selected_theme_index: 0,
118122
}
119123
}
120124

@@ -467,7 +471,8 @@ impl App {
467471
| InputMode::ViewingTask
468472
| InputMode::ViewingHelp
469473
| InputMode::ProjectList
470-
| InputMode::ConfirmingDelete => {}
474+
| InputMode::ConfirmingDelete
475+
| InputMode::SelectingTheme => {}
471476
}
472477
self.cancel_input();
473478
}
@@ -588,6 +593,54 @@ impl App {
588593
let _ = storage::save_config(&config);
589594
}
590595

596+
// theme management
597+
pub fn open_theme_selector(&mut self) {
598+
self.input_mode = InputMode::SelectingTheme;
599+
// Find current theme index
600+
let config = storage::load_config();
601+
let current_theme_name = config.theme.as_deref().unwrap_or("high-contrast");
602+
let theme_names = Theme::all_theme_names();
603+
self.selected_theme_index = theme_names
604+
.iter()
605+
.position(|&name| name == current_theme_name)
606+
.unwrap_or(0);
607+
}
608+
609+
pub fn move_theme_up(&mut self) {
610+
let theme_count = Theme::all_theme_names().len();
611+
if self.selected_theme_index == 0 {
612+
self.selected_theme_index = theme_count - 1;
613+
} else {
614+
self.selected_theme_index -= 1;
615+
}
616+
}
617+
618+
pub fn move_theme_down(&mut self) {
619+
let theme_count = Theme::all_theme_names().len();
620+
if self.selected_theme_index >= theme_count - 1 {
621+
self.selected_theme_index = 0;
622+
} else {
623+
self.selected_theme_index += 1;
624+
}
625+
}
626+
627+
pub fn apply_theme(&mut self) {
628+
let theme_names = Theme::all_theme_names();
629+
let theme_name = theme_names[self.selected_theme_index];
630+
631+
// Update app theme
632+
self.theme = Theme::from_name(theme_name).unwrap_or_default();
633+
634+
// Save to config
635+
let mut config = storage::load_config();
636+
config.theme = Some(theme_name.to_string());
637+
let _ = storage::save_config(&config);
638+
}
639+
640+
pub fn close_theme_selector(&mut self) {
641+
self.input_mode = InputMode::Normal;
642+
}
643+
591644
// show help view
592645
pub fn show_help(&mut self) {
593646
self.input_mode = InputMode::ViewingHelp;

src/main.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ fn run_app<B: ratatui::backend::Backend>(
6060
continue;
6161
}
6262

63+
// Handle Shift+T globally to open theme selector
64+
if key.code == KeyCode::Char('T') && key.modifiers.contains(KeyModifiers::SHIFT) {
65+
if app.input_mode == InputMode::Normal {
66+
app.open_theme_selector();
67+
}
68+
continue;
69+
}
70+
6371
match app.input_mode {
6472
InputMode::Normal => handle_normal_mode(app, key.code),
6573
InputMode::AddingTask
@@ -73,6 +81,7 @@ fn run_app<B: ratatui::backend::Backend>(
7381
InputMode::ProjectList => handle_project_list_mode(app, key.code),
7482
InputMode::AddingProject => handle_adding_project_mode(app, key.code),
7583
InputMode::ConfirmingDelete => handle_confirming_delete_mode(app, key.code),
84+
InputMode::SelectingTheme => handle_theme_selector_mode(app, key.code),
7685
}
7786
}
7887

@@ -268,3 +277,17 @@ fn handle_confirming_delete_mode(app: &mut App, key: KeyCode) {
268277
_ => {}
269278
}
270279
}
280+
281+
// handle keys when selecting theme
282+
fn handle_theme_selector_mode(app: &mut App, key: KeyCode) {
283+
match key {
284+
KeyCode::Char('j') | KeyCode::Down => app.move_theme_down(),
285+
KeyCode::Char('k') | KeyCode::Up => app.move_theme_up(),
286+
KeyCode::Enter => {
287+
app.apply_theme();
288+
app.close_theme_selector();
289+
}
290+
KeyCode::Esc => app.close_theme_selector(),
291+
_ => {}
292+
}
293+
}

src/theme.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ impl Theme {
5656
}
5757
}
5858

59+
pub fn all_theme_names() -> Vec<&'static str> {
60+
vec![
61+
"high-contrast",
62+
"classic",
63+
"solarized-dark",
64+
"gruvbox",
65+
"nord",
66+
]
67+
}
68+
5969
fn high_contrast() -> Self {
6070
Self {
6171
primary: Color::Cyan,

src/ui.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ pub fn draw(f: &mut Frame, app: &mut App) {
3131
draw_delete_confirmation(f, app);
3232
return;
3333
}
34+
InputMode::SelectingTheme => {
35+
draw_theme_selector(f, app);
36+
return;
37+
}
3438
_ => {}
3539
}
3640

@@ -924,3 +928,90 @@ fn draw_delete_confirmation(f: &mut Frame, app: &mut App) {
924928

925929
f.render_widget(para, dialog_area);
926930
}
931+
932+
// draw theme selector
933+
fn draw_theme_selector(f: &mut Frame, app: &mut App) {
934+
let area = f.area();
935+
936+
let title = " Select Theme (j/k: navigate | Enter: apply | Esc: cancel) ";
937+
938+
let block = Block::default()
939+
.borders(Borders::ALL)
940+
.border_style(Style::default().fg(app.theme.primary))
941+
.title(title);
942+
943+
let inner = block.inner(area);
944+
f.render_widget(block, area);
945+
946+
// Build theme list
947+
let mut lines = vec![
948+
Line::from(Span::styled(
949+
"Choose a theme:",
950+
Style::default()
951+
.fg(app.theme.primary)
952+
.add_modifier(Modifier::BOLD),
953+
)),
954+
Line::from(""),
955+
];
956+
957+
// Load config to check for current theme
958+
let config = crate::storage::load_config();
959+
let current_theme_name = config.theme.as_deref().unwrap_or("high-contrast");
960+
961+
let theme_names = crate::theme::Theme::all_theme_names();
962+
963+
for (i, theme_name) in theme_names.iter().enumerate() {
964+
let is_selected = i == app.selected_theme_index;
965+
let is_current = *theme_name == current_theme_name;
966+
967+
let mut spans = vec![];
968+
969+
// Selection indicator
970+
if is_selected {
971+
spans.push(Span::styled(
972+
"> ",
973+
Style::default()
974+
.fg(app.theme.accent)
975+
.add_modifier(Modifier::BOLD),
976+
));
977+
} else {
978+
spans.push(Span::raw(" "));
979+
}
980+
981+
// Current theme indicator
982+
if is_current {
983+
spans.push(Span::styled(
984+
"★ ",
985+
Style::default()
986+
.fg(app.theme.success)
987+
.add_modifier(Modifier::BOLD),
988+
));
989+
} else {
990+
spans.push(Span::raw(" "));
991+
}
992+
993+
// Theme name
994+
let style = if is_selected {
995+
Style::default()
996+
.fg(app.theme.text_primary)
997+
.add_modifier(Modifier::BOLD)
998+
} else {
999+
Style::default().fg(app.theme.text_primary)
1000+
};
1001+
1002+
spans.push(Span::styled(*theme_name, style));
1003+
1004+
// Current indicator text
1005+
if is_current {
1006+
spans.push(Span::styled(
1007+
" (active)",
1008+
Style::default().fg(app.theme.text_secondary),
1009+
));
1010+
}
1011+
1012+
lines.push(Line::from(spans));
1013+
}
1014+
1015+
let list_para = Paragraph::new(lines);
1016+
f.render_widget(list_para, inner);
1017+
}

0 commit comments

Comments
 (0)