Skip to main content

kimun_notes/components/
indexing.rs

1use std::time::Duration;
2
3use ratatui::Frame;
4use ratatui::layout::Alignment;
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::Style;
7use ratatui::text::Text;
8use ratatui::widgets::{Block, Borders, Clear, Paragraph};
9use throbber_widgets_tui::{Throbber, ThrobberState};
10
11use crate::components::events::{AppEvent, AppTx};
12use crate::settings::themes::Theme;
13
14pub enum IndexingProgressState {
15    Running {
16        work: tokio::task::JoinHandle<()>,
17        ticker: tokio::task::JoinHandle<()>,
18    },
19    Done(Duration),
20    Failed(String),
21}
22
23impl Drop for IndexingProgressState {
24    fn drop(&mut self) {
25        if let Self::Running { work, ticker } = self {
26            work.abort();
27            ticker.abort();
28        }
29    }
30}
31
32pub fn spawn_running(work: tokio::task::JoinHandle<()>, tx: &AppTx) -> IndexingProgressState {
33    let tx2 = tx.clone();
34    let ticker = tokio::spawn(async move {
35        loop {
36            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
37            if tx2.send(AppEvent::Redraw).is_err() {
38                break;
39            }
40        }
41    });
42    IndexingProgressState::Running { work, ticker }
43}
44
45pub fn fixed_centered_rect(width: u16, height: u16, r: Rect) -> Rect {
46    let x = r.x + (r.width.saturating_sub(width)) / 2;
47    let y = r.y + (r.height.saturating_sub(height)) / 2;
48    Rect {
49        x,
50        y,
51        width: width.min(r.width),
52        height: height.min(r.height),
53    }
54}
55
56/// Render a centered indexing progress dialog over the current frame.
57///
58/// - `running_label`: text shown next to the throbber spinner while running.
59///   Both the throbber and the label are centered in the dialog box.
60/// - Done/Failed states render centered status text and a `[ OK ]` hint.
61pub fn render_indexing_overlay(
62    f: &mut Frame,
63    state: &IndexingProgressState,
64    throbber_state: &mut ThrobberState,
65    theme: &Theme,
66    running_label: &str,
67) {
68    let area = fixed_centered_rect(44, 5, f.area());
69    f.render_widget(Clear, area);
70    let block = Block::default()
71        .title("Indexing")
72        .borders(Borders::ALL)
73        .border_style(Style::default().fg(theme.accent.to_ratatui()))
74        .style(theme.base_style());
75    let inner = block.inner(area);
76    f.render_widget(block, area);
77
78    match state {
79        IndexingProgressState::Running { .. } => {
80            throbber_state.calc_next();
81            // +2 for the spinner char and the space throbber_widgets_tui inserts before the label
82            let content_width = (running_label.chars().count() as u16).saturating_add(2);
83            let vert = Layout::default()
84                .direction(Direction::Vertical)
85                .constraints([
86                    Constraint::Min(0),
87                    Constraint::Length(1),
88                    Constraint::Min(0),
89                ])
90                .split(inner);
91            let horiz = Layout::default()
92                .direction(Direction::Horizontal)
93                .constraints([
94                    Constraint::Min(0),
95                    Constraint::Length(content_width),
96                    Constraint::Min(0),
97                ])
98                .split(vert[1]);
99            let throbber = Throbber::default().label(running_label).style(
100                Style::default()
101                    .fg(theme.fg.to_ratatui())
102                    .bg(theme.bg.to_ratatui()),
103            );
104            f.render_stateful_widget(throbber, horiz[1], throbber_state);
105        }
106        IndexingProgressState::Done(dur) => {
107            f.render_widget(
108                Paragraph::new(Text::raw(format!(
109                    "✓  Done in {}s\n\n[ OK ]",
110                    dur.as_secs()
111                )))
112                .alignment(Alignment::Center)
113                .style(theme.base_style()),
114                inner,
115            );
116        }
117        IndexingProgressState::Failed(msg) => {
118            f.render_widget(
119                Paragraph::new(Text::raw(format!("✗  {}\n\n[ OK ]", msg)))
120                    .alignment(Alignment::Center)
121                    .style(theme.base_style()),
122                inner,
123            );
124        }
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::sync::{
132        Arc,
133        atomic::{AtomicBool, Ordering},
134    };
135
136    #[tokio::test]
137    async fn drop_aborts_running_tasks() {
138        let completed = Arc::new(AtomicBool::new(false));
139        let completed2 = completed.clone();
140
141        let work = tokio::spawn(async move {
142            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
143            completed2.store(true, Ordering::SeqCst);
144        });
145        let ticker = tokio::spawn(async {
146            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
147        });
148
149        let state = IndexingProgressState::Running { work, ticker };
150        drop(state);
151
152        // Yield several times: abort() is cooperative, the task needs at least one
153        // poll after cancellation is posted before it is marked finished.
154        for _ in 0..10 {
155            tokio::task::yield_now().await;
156        }
157
158        assert!(
159            !completed.load(Ordering::SeqCst),
160            "work task should be aborted, not completed"
161        );
162    }
163}