Skip to main content

kimun_notes/components/dialogs/
workspace_switcher.rs

1use 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)>, // (name, is_current)
18    list_state: ListState,
19    /// Dismiss-on-outside-press and hint-chip clicks (see [`ModalShell`]).
20    shell: ModalShell,
21    /// Where the rows were drawn in the last render.
22    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    /// Click a row to select it, click the selected row to switch; the wheel
74    /// moves the selection; a press outside the popup cancels. Modal: every
75    /// mouse event is consumed.
76    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);