Skip to main content

graphrag_cli/ui/components/
results_viewer.rs

1//! Results viewer component with scrolling support and markdown rendering
2
3use crate::{action::Action, theme::Theme};
4use ratatui::{
5    layout::{Margin, Rect},
6    text::{Line, Text},
7    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
8    Frame,
9};
10
11/// Results viewer with vim-style scrolling and markdown rendering
12pub struct ResultsViewer {
13    /// Rendered content lines (markdown-parsed)
14    content_lines: Vec<Line<'static>>,
15    /// Vertical scroll position
16    scroll_offset: usize,
17    /// Scrollbar state
18    scrollbar_state: ScrollbarState,
19    /// Is this widget focused?
20    focused: bool,
21    /// Theme
22    theme: Theme,
23}
24
25impl ResultsViewer {
26    pub fn new() -> Self {
27        let welcome = crate::ui::markdown::parse_markdown(
28            "# Welcome to GraphRAG CLI\n\
29             \n\
30             To get started:\n\
31             \n\
32             - Load a config: `/config path/to/config.json5`\n\
33             - Load a document: `/load path/to/document.txt`\n\
34             - Ask questions in natural language!\n\
35             \n\
36             Press **?** for help  |  Use `/mode explain` for richer output",
37        );
38        let mut rv = Self {
39            content_lines: welcome,
40            scroll_offset: 0,
41            scrollbar_state: ScrollbarState::default(),
42            focused: false,
43            theme: Theme::default(),
44        };
45        rv.update_scrollbar();
46        rv
47    }
48
49    /// Set content from plain string lines (runs through markdown parser)
50    pub fn set_content(&mut self, lines: Vec<String>) {
51        let combined = lines.join("\n");
52        self.content_lines = crate::ui::markdown::parse_markdown(&combined);
53        self.scroll_offset = 0;
54        self.update_scrollbar();
55    }
56
57    /// Set content from pre-built `Line` values (bypasses markdown parser)
58    pub fn set_lines(&mut self, lines: Vec<Line<'static>>) {
59        self.content_lines = lines;
60        self.scroll_offset = 0;
61        self.update_scrollbar();
62    }
63
64    /// Append plain lines to existing content
65    #[allow(dead_code)]
66    pub fn append_content(&mut self, lines: Vec<String>) {
67        let combined = lines.join("\n");
68        let mut new_lines = crate::ui::markdown::parse_markdown(&combined);
69        self.content_lines.append(&mut new_lines);
70        self.update_scrollbar();
71    }
72
73    /// Clear content
74    #[allow(dead_code)]
75    pub fn clear(&mut self) {
76        self.content_lines.clear();
77        self.scroll_offset = 0;
78        self.update_scrollbar();
79    }
80
81    pub fn scroll_up(&mut self) {
82        self.scroll_offset = self.scroll_offset.saturating_sub(1);
83        self.update_scrollbar();
84    }
85
86    pub fn scroll_down(&mut self) {
87        if self.scroll_offset < self.content_lines.len().saturating_sub(1) {
88            self.scroll_offset += 1;
89        }
90        self.update_scrollbar();
91    }
92
93    pub fn scroll_page_up(&mut self, page_size: usize) {
94        self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
95        self.update_scrollbar();
96    }
97
98    pub fn scroll_page_down(&mut self, page_size: usize) {
99        let max_scroll = self.content_lines.len().saturating_sub(1);
100        self.scroll_offset = (self.scroll_offset + page_size).min(max_scroll);
101        self.update_scrollbar();
102    }
103
104    pub fn scroll_to_top(&mut self) {
105        self.scroll_offset = 0;
106        self.update_scrollbar();
107    }
108
109    pub fn scroll_to_bottom(&mut self) {
110        self.scroll_offset = self.content_lines.len().saturating_sub(1);
111        self.update_scrollbar();
112    }
113
114    fn update_scrollbar(&mut self) {
115        self.scrollbar_state = self
116            .scrollbar_state
117            .content_length(self.content_lines.len())
118            .position(self.scroll_offset);
119    }
120
121    pub fn set_focused(&mut self, focused: bool) {
122        self.focused = focused;
123    }
124}
125
126impl super::Component for ResultsViewer {
127    fn handle_action(&mut self, action: &Action) -> Option<Action> {
128        match action {
129            Action::ScrollUp => {
130                if self.focused {
131                    self.scroll_up();
132                }
133                None
134            },
135            Action::ScrollDown => {
136                if self.focused {
137                    self.scroll_down();
138                }
139                None
140            },
141            Action::ScrollPageUp => {
142                if self.focused {
143                    self.scroll_page_up(10);
144                }
145                None
146            },
147            Action::ScrollPageDown => {
148                if self.focused {
149                    self.scroll_page_down(10);
150                }
151                None
152            },
153            Action::ScrollToTop => {
154                if self.focused {
155                    self.scroll_to_top();
156                }
157                None
158            },
159            Action::ScrollToBottom => {
160                if self.focused {
161                    self.scroll_to_bottom();
162                }
163                None
164            },
165            Action::FocusResultsViewer => {
166                self.set_focused(true);
167                None
168            },
169            Action::QuerySuccess(result) => {
170                self.set_content(vec![
171                    "## Query Result".to_string(),
172                    String::new(),
173                    result.clone(),
174                ]);
175                None
176            },
177            Action::QueryExplainedSuccess(payload) => {
178                use ratatui::{
179                    style::{Color, Style},
180                    text::Span,
181                };
182                let conf_color = confidence_color(payload.confidence);
183                let conf_bar = confidence_bar(payload.confidence, 10);
184                let header_line = Line::from(vec![
185                    Span::styled("Query Result  ".to_owned(), self.theme.title()),
186                    Span::styled(
187                        format!(
188                            "[EXPLAIN | {:.0}% {}]",
189                            payload.confidence * 100.0,
190                            conf_bar
191                        ),
192                        Style::default().fg(conf_color),
193                    ),
194                ]);
195                let mut lines: Vec<Line<'static>> = vec![
196                    header_line,
197                    Line::from(Span::styled(
198                        "━".repeat(50),
199                        Style::default().fg(Color::DarkGray),
200                    )),
201                    Line::from(""),
202                ];
203                lines.extend(crate::ui::markdown::parse_markdown(&payload.answer));
204                self.set_lines(lines);
205                None
206            },
207            Action::QueryError(error) => {
208                self.set_content(vec![
209                    "## Query Error".to_string(),
210                    String::new(),
211                    format!("> {}", error),
212                ]);
213                None
214            },
215            _ => None,
216        }
217    }
218
219    fn render(&mut self, f: &mut Frame, area: Rect) {
220        let border_style = if self.focused {
221            self.theme.border_focused()
222        } else {
223            self.theme.border()
224        };
225
226        let title = if self.focused {
227            " Results Viewer [ACTIVE] (j/k or ↑↓ to scroll | Ctrl+N next panel) "
228        } else {
229            " Results Viewer (Ctrl+2 or Ctrl+N to focus) "
230        };
231
232        let block = Block::default()
233            .title(title)
234            .borders(Borders::ALL)
235            .border_style(border_style);
236
237        let visible: Vec<Line> = self
238            .content_lines
239            .iter()
240            .skip(self.scroll_offset)
241            .cloned()
242            .collect();
243
244        let paragraph = Paragraph::new(Text::from(visible))
245            .block(block)
246            .wrap(Wrap { trim: false })
247            .style(self.theme.text());
248
249        f.render_widget(paragraph, area);
250
251        if self.content_lines.len() > area.height as usize {
252            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
253                .begin_symbol(Some("↑"))
254                .end_symbol(Some("↓"));
255
256            let scrollbar_area = area.inner(Margin {
257                vertical: 1,
258                horizontal: 0,
259            });
260
261            f.render_stateful_widget(scrollbar, scrollbar_area, &mut self.scrollbar_state);
262        }
263    }
264}
265
266impl Default for ResultsViewer {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272fn confidence_color(score: f32) -> ratatui::style::Color {
273    use ratatui::style::Color;
274    if score < 0.3 {
275        Color::Red
276    } else if score < 0.7 {
277        Color::Yellow
278    } else {
279        Color::Green
280    }
281}
282
283fn confidence_bar(score: f32, width: usize) -> String {
284    let filled = (score * width as f32).round() as usize;
285    let empty = width.saturating_sub(filled);
286    format!("[{}{}]", "█".repeat(filled), "░".repeat(empty))
287}