git_iris/studio/components/
code_view.rs

1//! Code view component for Iris Studio
2//!
3//! Displays file content with line numbers and syntax highlighting.
4
5use ratatui::Frame;
6use ratatui::layout::Rect;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{
10    Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
11};
12use std::fs;
13use std::path::{Path, PathBuf};
14use unicode_width::UnicodeWidthStr;
15
16use super::syntax::SyntaxHighlighter;
17use crate::studio::theme;
18use crate::studio::utils::expand_tabs;
19
20// ═══════════════════════════════════════════════════════════════════════════════
21// Code View State
22// ═══════════════════════════════════════════════════════════════════════════════
23
24/// State for the code view widget
25#[derive(Debug, Clone, Default)]
26pub struct CodeViewState {
27    /// Path to the currently loaded file
28    current_file: Option<PathBuf>,
29    /// File content as lines
30    lines: Vec<String>,
31    /// Scroll offset (line)
32    scroll_offset: usize,
33    /// Currently selected/highlighted line (1-indexed, 0 = none)
34    selected_line: usize,
35    /// Selection range for multi-line selection (start, end) 1-indexed
36    selection: Option<(usize, usize)>,
37}
38
39impl CodeViewState {
40    /// Create new code view state
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Load file content from path
46    pub fn load_file(&mut self, path: &Path) -> std::io::Result<()> {
47        let content = fs::read_to_string(path)?;
48        self.lines = content.lines().map(String::from).collect();
49        self.current_file = Some(path.to_path_buf());
50        self.scroll_offset = 0;
51        self.selected_line = 1;
52        self.selection = None;
53        Ok(())
54    }
55
56    /// Get current file path
57    pub fn current_file(&self) -> Option<&Path> {
58        self.current_file.as_deref()
59    }
60
61    /// Get all lines
62    pub fn lines(&self) -> &[String] {
63        &self.lines
64    }
65
66    /// Get line count
67    pub fn line_count(&self) -> usize {
68        self.lines.len()
69    }
70
71    /// Get scroll offset
72    pub fn scroll_offset(&self) -> usize {
73        self.scroll_offset
74    }
75
76    /// Get selected line (1-indexed)
77    pub fn selected_line(&self) -> usize {
78        self.selected_line
79    }
80
81    /// Set selected line (1-indexed)
82    pub fn set_selected_line(&mut self, line: usize) {
83        if line > 0 && line <= self.lines.len() {
84            self.selected_line = line;
85        }
86    }
87
88    /// Get selection range
89    pub fn selection(&self) -> Option<(usize, usize)> {
90        self.selection
91    }
92
93    /// Set selection range (start, end) 1-indexed
94    pub fn set_selection(&mut self, start: usize, end: usize) {
95        if start > 0 && end >= start && end <= self.lines.len() {
96            self.selection = Some((start, end));
97        }
98    }
99
100    /// Clear selection
101    pub fn clear_selection(&mut self) {
102        self.selection = None;
103    }
104
105    /// Scroll up by amount
106    pub fn scroll_up(&mut self, amount: usize) {
107        self.scroll_offset = self.scroll_offset.saturating_sub(amount);
108    }
109
110    /// Scroll down by amount
111    pub fn scroll_down(&mut self, amount: usize) {
112        let max_offset = self.lines.len().saturating_sub(1);
113        self.scroll_offset = (self.scroll_offset + amount).min(max_offset);
114    }
115
116    /// Scroll to make a specific line visible (1-indexed)
117    pub fn scroll_to_line(&mut self, line: usize, visible_height: usize) {
118        if line == 0 || self.lines.is_empty() {
119            return;
120        }
121        let line_idx = line.saturating_sub(1);
122
123        // If line is above visible area, scroll up
124        if line_idx < self.scroll_offset {
125            self.scroll_offset = line_idx;
126        }
127        // If line is below visible area, scroll down
128        else if line_idx >= self.scroll_offset + visible_height {
129            self.scroll_offset = line_idx.saturating_sub(visible_height.saturating_sub(1));
130        }
131    }
132
133    /// Move selection up
134    pub fn move_up(&mut self, amount: usize, visible_height: usize) {
135        if self.selected_line > 1 {
136            self.selected_line = self.selected_line.saturating_sub(amount).max(1);
137            self.scroll_to_line(self.selected_line, visible_height);
138        }
139    }
140
141    /// Move selection down
142    pub fn move_down(&mut self, amount: usize, visible_height: usize) {
143        if self.selected_line < self.lines.len() {
144            self.selected_line = (self.selected_line + amount).min(self.lines.len());
145            self.scroll_to_line(self.selected_line, visible_height);
146        }
147    }
148
149    /// Go to first line
150    pub fn goto_first(&mut self) {
151        self.selected_line = 1;
152        self.scroll_offset = 0;
153    }
154
155    /// Go to last line
156    pub fn goto_last(&mut self, visible_height: usize) {
157        self.selected_line = self.lines.len().max(1);
158        self.scroll_to_line(self.selected_line, visible_height);
159    }
160
161    /// Check if file is loaded
162    pub fn is_loaded(&self) -> bool {
163        self.current_file.is_some()
164    }
165
166    /// Select a line by visible row (for mouse clicks)
167    /// Returns true if selection changed
168    pub fn select_by_row(&mut self, row: usize) -> bool {
169        let target_line = self.scroll_offset + row + 1; // Convert to 1-indexed
170        if target_line <= self.lines.len() && target_line != self.selected_line {
171            self.selected_line = target_line;
172            true
173        } else {
174            false
175        }
176    }
177}
178
179// ═══════════════════════════════════════════════════════════════════════════════
180// Rendering
181// ═══════════════════════════════════════════════════════════════════════════════
182
183/// Render the code view widget
184pub fn render_code_view(
185    frame: &mut Frame,
186    area: Rect,
187    state: &CodeViewState,
188    title: &str,
189    focused: bool,
190) {
191    let block = Block::default()
192        .title(format!(" {} ", title))
193        .borders(Borders::ALL)
194        .border_style(if focused {
195            theme::focused_border()
196        } else {
197            theme::unfocused_border()
198        });
199
200    let inner = block.inner(area);
201    frame.render_widget(block, area);
202
203    if inner.height == 0 || inner.width == 0 {
204        return;
205    }
206
207    // Show placeholder if no file loaded
208    if !state.is_loaded() {
209        let placeholder = Paragraph::new("Select a file from the tree")
210            .style(Style::default().fg(theme::text_dim_color()));
211        frame.render_widget(placeholder, inner);
212        return;
213    }
214
215    let visible_height = inner.height as usize;
216    let lines = state.lines();
217    let scroll_offset = state.scroll_offset();
218    let line_num_width = lines.len().to_string().len().max(3);
219
220    // Create syntax highlighter based on file extension
221    let highlighter = state.current_file().map(SyntaxHighlighter::for_path);
222
223    let display_lines: Vec<Line> = lines
224        .iter()
225        .enumerate()
226        .skip(scroll_offset)
227        .take(visible_height)
228        .map(|(idx, content)| {
229            render_code_line(
230                idx + 1, // 1-indexed line number
231                content,
232                line_num_width,
233                inner.width as usize,
234                state.selected_line,
235                state.selection(),
236                highlighter.as_ref(),
237            )
238        })
239        .collect();
240
241    let paragraph = Paragraph::new(display_lines);
242    frame.render_widget(paragraph, inner);
243
244    // Render scrollbar if needed
245    if lines.len() > visible_height {
246        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
247            .begin_symbol(None)
248            .end_symbol(None);
249
250        let mut scrollbar_state = ScrollbarState::new(lines.len()).position(scroll_offset);
251
252        frame.render_stateful_widget(
253            scrollbar,
254            area.inner(ratatui::layout::Margin {
255                vertical: 1,
256                horizontal: 0,
257            }),
258            &mut scrollbar_state,
259        );
260    }
261}
262
263/// Render a single code line with line number and optional syntax highlighting
264fn render_code_line(
265    line_num: usize,
266    content: &str,
267    line_num_width: usize,
268    max_width: usize,
269    selected_line: usize,
270    selection: Option<(usize, usize)>,
271    highlighter: Option<&SyntaxHighlighter>,
272) -> Line<'static> {
273    // Expand tabs and strip control characters to prevent visual corruption
274    let content = expand_tabs(content, 4);
275
276    let is_selected = line_num == selected_line;
277    let is_in_selection =
278        selection.is_some_and(|(start, end)| line_num >= start && line_num <= end);
279
280    // Line number style - use semantic style, with highlight when selected
281    let line_num_style = if is_selected {
282        theme::line_number().add_modifier(Modifier::BOLD)
283    } else {
284        theme::line_number()
285    };
286
287    // Selection indicator
288    let indicator = if is_selected { ">" } else { " " };
289    let indicator_style = if is_selected {
290        Style::default()
291            .fg(theme::accent_primary())
292            .add_modifier(Modifier::BOLD)
293    } else {
294        Style::default()
295    };
296
297    // Build the line prefix (indicator + line number + separator)
298    let mut spans = vec![
299        Span::styled(indicator.to_string(), indicator_style),
300        Span::styled(
301            format!("{:>width$}", line_num, width = line_num_width),
302            line_num_style,
303        ),
304        Span::styled(" │ ", Style::default().fg(theme::text_muted_color())),
305    ];
306
307    // Calculate available width for content
308    let available_width = max_width.saturating_sub(line_num_width + 4); // 4 = "> " + " │ "
309
310    // Add syntax-highlighted content
311    if let Some(hl) = highlighter {
312        let styled_spans = hl.highlight_line(&content);
313        let mut display_width = 0;
314
315        for (style, text) in styled_spans {
316            if display_width >= available_width {
317                break;
318            }
319
320            let remaining = available_width - display_width;
321            // Truncate by display width, not char count
322            let mut truncated = String::new();
323            let mut width = 0;
324            for c in text.chars() {
325                let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
326                if width + c_width > remaining {
327                    break;
328                }
329                truncated.push(c);
330                width += c_width;
331            }
332            display_width += width;
333
334            // Apply selection/highlight overlay
335            let final_style = if is_in_selection {
336                style.bg(theme::bg_selection_color())
337            } else if is_selected {
338                // Keep syntax colors but ensure visibility
339                style
340            } else {
341                style
342            };
343
344            spans.push(Span::styled(truncated, final_style));
345        }
346
347        // Add truncation indicator if needed
348        if content.width() > available_width {
349            spans.push(Span::styled(
350                "...",
351                Style::default().fg(theme::text_muted_color()),
352            ));
353        }
354    } else {
355        // Fallback: no syntax highlighting
356        let content_style = if is_in_selection {
357            Style::default()
358                .fg(theme::text_primary_color())
359                .bg(theme::bg_selection_color())
360        } else if is_selected {
361            Style::default().fg(theme::text_primary_color())
362        } else {
363            Style::default().fg(theme::text_secondary_color())
364        };
365
366        let content_width = content.width();
367        let display_content = if content_width > available_width {
368            // Truncate by display width
369            let mut truncated = String::new();
370            let mut width = 0;
371            let max_width = available_width.saturating_sub(3);
372            for c in content.chars() {
373                let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(1);
374                if width + c_width > max_width {
375                    break;
376                }
377                truncated.push(c);
378                width += c_width;
379            }
380            format!("{}...", truncated)
381        } else {
382            content.clone()
383        };
384
385        spans.push(Span::styled(display_content, content_style));
386    }
387
388    Line::from(spans)
389}