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