kimun_notes/components/dialogs/
workspace_switcher.rs1use ratatui::Frame;
2use ratatui::crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
3use ratatui::layout::{Constraint, Direction, Layout, Rect};
4use ratatui::style::{Modifier, Style};
5use ratatui::widgets::{List, ListItem, ListState, Paragraph};
6
7use super::ModalShell;
8use crate::components::clickable::list_index_at;
9use crate::components::event_state::EventState;
10use crate::components::events::{AppEvent, AppTx};
11use crate::components::hint_row::HintRow;
12use crate::components::panel::{ModalSpec, modal_chrome};
13use crate::settings::AppSettings;
14use crate::settings::themes::Theme;
15
16pub struct WorkspaceSwitcherModal {
17 workspaces: Vec<(String, bool)>, list_state: ListState,
19 shell: ModalShell,
21 list_rect: Rect,
23 hints: HintRow,
24}
25
26impl WorkspaceSwitcherModal {
27 pub fn new(settings: &AppSettings) -> Self {
28 let mut workspaces: Vec<(String, bool)> = Vec::new();
29 if let Some(ref wc) = settings.workspace_config {
30 let current = &wc.global.current_workspace;
31 let mut names: Vec<&String> = wc.workspaces.keys().collect();
32 names.sort();
33 for name in names {
34 workspaces.push((name.clone(), name == current));
35 }
36 }
37 let mut list_state = ListState::default();
38 if !workspaces.is_empty() {
39 let current_idx = workspaces
40 .iter()
41 .position(|(_, is_cur)| *is_cur)
42 .unwrap_or(0);
43 list_state.select(Some(current_idx));
44 }
45 Self {
46 workspaces,
47 list_state,
48 shell: ModalShell::default(),
49 list_rect: Rect::default(),
50 hints: HintRow::new(&[
51 (KeyCode::Enter, "Enter", "Switch"),
52 (KeyCode::Esc, "Esc", "Cancel"),
53 ]),
54 }
55 }
56
57 #[cfg(test)]
58 pub(crate) fn with_workspaces(workspaces: Vec<(String, bool)>) -> Self {
59 let mut list_state = ListState::default();
60 list_state.select(workspaces.iter().position(|(_, cur)| *cur));
61 Self {
62 workspaces,
63 list_state,
64 shell: ModalShell::default(),
65 list_rect: Rect::default(),
66 hints: HintRow::new(&[
67 (KeyCode::Enter, "Enter", "Switch"),
68 (KeyCode::Esc, "Esc", "Cancel"),
69 ]),
70 }
71 }
72
73 pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
77 if let Some(key) = self.shell.pointer_key(m, &[&self.hints]) {
78 return self.handle_key(key, tx);
79 }
80 match m.kind {
81 MouseEventKind::ScrollUp => {
82 self.handle_key(KeyEvent::from(KeyCode::Up), tx);
83 }
84 MouseEventKind::ScrollDown => {
85 self.handle_key(KeyEvent::from(KeyCode::Down), tx);
86 }
87 _ => {
88 let len = self.workspaces.len();
89 if let Some(idx) = list_index_at(m, self.list_rect, self.list_state.offset(), len) {
90 if self.list_state.selected() == Some(idx) {
91 return self.handle_key(KeyEvent::from(KeyCode::Enter), tx);
92 }
93 self.list_state.select(Some(idx));
94 }
95 }
96 }
97 EventState::Consumed
98 }
99
100 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
101 match key.code {
102 KeyCode::Up => {
103 if !self.workspaces.is_empty() {
104 let cur = self.list_state.selected().unwrap_or(0);
105 let next = if cur == 0 {
106 self.workspaces.len() - 1
107 } else {
108 cur - 1
109 };
110 self.list_state.select(Some(next));
111 }
112 EventState::Consumed
113 }
114 KeyCode::Down => {
115 if !self.workspaces.is_empty() {
116 let cur = self.list_state.selected().unwrap_or(0);
117 let next = (cur + 1) % self.workspaces.len();
118 self.list_state.select(Some(next));
119 }
120 EventState::Consumed
121 }
122 KeyCode::Enter => {
123 if let Some(idx) = self.list_state.selected()
124 && let Some((name, is_current)) = self.workspaces.get(idx)
125 && !is_current
126 {
127 tx.send(AppEvent::WorkspaceSwitched(name.clone())).ok();
128 }
129 tx.send(AppEvent::CloseOverlay).ok();
130 EventState::Consumed
131 }
132 KeyCode::Esc => {
133 tx.send(AppEvent::CloseOverlay).ok();
134 EventState::Consumed
135 }
136 _ => EventState::NotConsumed,
137 }
138 }
139
140 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
141 let fg = theme.fg.to_ratatui();
142 let gray = theme.gray.to_ratatui();
143 let bg = theme.bg_panel.to_ratatui();
144
145 let height = (self.workspaces.len() as u16 + 5).min(rect.height.saturating_sub(4));
146 let width = 50u16.min(rect.width.saturating_sub(4));
147 let popup = super::fixed_centered_rect(width, height, rect);
148 self.shell.set(popup);
149
150 let inner = modal_chrome(
151 f,
152 popup,
153 theme,
154 ModalSpec {
155 title: Some(" Switch Workspace "),
156 border: Some(Style::default().fg(theme.focus_border.to_ratatui())),
157 ..Default::default()
158 },
159 );
160
161 if self.workspaces.is_empty() {
162 f.render_widget(
163 Paragraph::new(" No workspaces configured.\n Use Preferences to create one.")
164 .style(Style::default().fg(gray).bg(bg)),
165 inner,
166 );
167 return;
168 }
169
170 let rows = Layout::default()
171 .direction(Direction::Vertical)
172 .constraints([Constraint::Min(0), Constraint::Length(1)])
173 .split(inner);
174
175 let items: Vec<ListItem> = self
176 .workspaces
177 .iter()
178 .map(|(name, is_current)| {
179 let marker = if *is_current { "\u{25CF} " } else { " " };
180 let style = if *is_current {
181 Style::default()
182 .fg(theme.accent.to_ratatui())
183 .bg(bg)
184 .add_modifier(Modifier::BOLD)
185 } else {
186 Style::default().fg(fg).bg(bg)
187 };
188 ListItem::new(format!("{}{}", marker, name)).style(style)
189 })
190 .collect();
191
192 let list = List::new(items)
193 .style(Style::default().bg(bg))
194 .highlight_style(Style::default().bg(theme.selection_bg.to_ratatui()));
195
196 self.list_rect = rows[0];
197 f.render_stateful_widget(list, rows[0], &mut self.list_state);
198
199 self.hints
200 .render(f, rows[1], Style::default().fg(gray).bg(bg), theme);
201 }
202}
203
204impl_dialog!(WorkspaceSwitcherModal);