git_iris/studio/components/
code_view.rs1use 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#[derive(Debug, Clone, Default)]
26pub struct CodeViewState {
27 current_file: Option<PathBuf>,
29 lines: Vec<String>,
31 scroll_offset: usize,
33 selected_line: usize,
35 selection: Option<(usize, usize)>,
37}
38
39impl CodeViewState {
40 #[must_use]
42 pub fn new() -> Self {
43 Self::default()
44 }
45
46 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 #[must_use]
63 pub fn current_file(&self) -> Option<&Path> {
64 self.current_file.as_deref()
65 }
66
67 #[must_use]
69 pub fn lines(&self) -> &[String] {
70 &self.lines
71 }
72
73 #[must_use]
75 pub fn line_count(&self) -> usize {
76 self.lines.len()
77 }
78
79 #[must_use]
81 pub fn scroll_offset(&self) -> usize {
82 self.scroll_offset
83 }
84
85 #[must_use]
87 pub fn selected_line(&self) -> usize {
88 self.selected_line
89 }
90
91 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 #[must_use]
100 pub fn selection(&self) -> Option<(usize, usize)> {
101 self.selection
102 }
103
104 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 pub fn clear_selection(&mut self) {
113 self.selection = None;
114 }
115
116 pub fn scroll_up(&mut self, amount: usize) {
118 self.scroll_offset = self.scroll_offset.saturating_sub(amount);
119 }
120
121 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 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_idx < self.scroll_offset {
136 self.scroll_offset = line_idx;
137 }
138 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 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 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 pub fn goto_first(&mut self) {
162 self.selected_line = 1;
163 self.scroll_offset = 0;
164 }
165
166 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 #[must_use]
174 pub fn is_loaded(&self) -> bool {
175 self.current_file.is_some()
176 }
177
178 pub fn select_by_row(&mut self, row: usize) -> bool {
181 let target_line = self.scroll_offset + row + 1; 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
191pub 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 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 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, 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 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
275fn 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 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 let line_num_style = if is_selected {
294 theme::line_number().add_modifier(Modifier::BOLD)
295 } else {
296 theme::line_number()
297 };
298
299 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 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 let available_width = max_width.saturating_sub(line_num_width + 4); 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 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 let final_style = if is_in_selection {
348 style.bg(theme::bg_selection_color())
349 } else if is_selected {
350 style
352 } else {
353 style
354 };
355
356 spans.push(Span::styled(truncated, final_style));
357 }
358
359 if content.width() > available_width {
361 spans.push(Span::styled(
362 "...",
363 Style::default().fg(theme::text_muted_color()),
364 ));
365 }
366 } else {
367 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 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}