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 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)>, // (name, is_current)
17    list_state: ListState,
18    /// Outer popup rect from the last render; a press outside cancels.
19    popup_rect: Rect,
20    /// Where the rows were drawn in the last render.
21    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    /// Click a row to select it, click the selected row to switch; the wheel
73    /// moves the selection; a press outside the popup cancels. Modal: every
74    /// mouse event is consumed.
75    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}