Skip to main content

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