Rust · 148092 bytes Raw Blame History
1 use anyhow::Result;
2 use crossterm::{
3 cursor::{Hide, MoveTo, Show},
4 event::{
5 DisableMouseCapture, EnableMouseCapture,
6 KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
7 },
8 execute,
9 style::{Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor},
10 terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
11 };
12 use std::io::{stdout, Stdout, Write};
13 use unicode_width::UnicodeWidthStr;
14
15 use crate::buffer::Buffer;
16 use crate::editor::{Cursors, Position};
17 use crate::fuss::VisibleItem;
18 use crate::lsp::{CompletionItem, Diagnostic, DiagnosticSeverity, HoverInfo, Location, ServerManagerPanel};
19 use crate::syntax::{Highlighter, Token};
20 use crate::terminal::TerminalPanel;
21
22 // Editor color scheme (256-color palette)
23 const BG_COLOR: Color = Color::AnsiValue(234); // Off-black editor background
24 const CURRENT_LINE_BG: Color = Color::AnsiValue(236); // Slightly lighter for current line
25 const LINE_NUM_COLOR: Color = Color::AnsiValue(243); // Gray for line numbers
26 const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow; // Yellow for active line number
27 const BRACKET_MATCH_BG: Color = Color::AnsiValue(240); // Highlight for matching brackets
28 // Secondary cursors use Color::Magenta for visibility
29
30 // Tab bar colors
31 const TAB_BAR_BG: Color = Color::AnsiValue(235); // Slightly lighter than editor bg
32 const TAB_ACTIVE_BG: Color = Color::AnsiValue(238); // Active tab background
33 const TAB_INACTIVE_FG: Color = Color::AnsiValue(245); // Inactive tab text
34 const TAB_ACTIVE_FG: Color = Color::White; // Active tab text
35 const TAB_MODIFIED_FG: Color = Color::Yellow; // Modified indicator
36
37 /// Tab information for rendering
38 pub struct TabInfo {
39 pub name: String,
40 pub is_active: bool,
41 pub is_modified: bool,
42 pub index: usize,
43 }
44
45 /// Pane information for rendering
46 pub struct PaneInfo<'a> {
47 pub buffer: &'a Buffer,
48 pub cursors: &'a Cursors,
49 pub viewport_line: usize,
50 pub bounds: PaneBounds,
51 pub is_active: bool,
52 pub bracket_match: Option<(usize, usize)>,
53 pub is_modified: bool,
54 }
55
56 /// Normalized pane bounds (0.0 to 1.0)
57 #[derive(Debug, Clone)]
58 pub struct PaneBounds {
59 pub x_start: f32,
60 pub y_start: f32,
61 pub x_end: f32,
62 pub y_end: f32,
63 }
64
65 // Pane colors
66 const PANE_SEPARATOR_FG: Color = Color::AnsiValue(240);
67 const PANE_ACTIVE_SEPARATOR_FG: Color = Color::AnsiValue(250);
68 // Inactive pane uses darker colors
69 const INACTIVE_BG_COLOR: Color = Color::AnsiValue(233); // Darker than active
70 const INACTIVE_CURRENT_LINE_BG: Color = Color::AnsiValue(234); // Dimmed current line
71 const INACTIVE_LINE_NUM_COLOR: Color = Color::AnsiValue(240); // Dimmed line numbers
72 const INACTIVE_TEXT_COLOR: Color = Color::AnsiValue(245); // Dimmed text
73
74 /// Extract the last component of a path for display
75 fn extract_dirname(path: &str) -> String {
76 // Handle home directory
77 if path == "/" {
78 return "/".to_string();
79 }
80
81 // Get the last path component
82 path.rsplit('/')
83 .find(|s| !s.is_empty())
84 .map(|s| {
85 // If it starts with ~, keep it
86 if path.starts_with('~') || path == "/" {
87 s.to_string()
88 } else {
89 s.to_string()
90 }
91 })
92 .unwrap_or_else(|| path.to_string())
93 }
94
95 /// Terminal screen renderer
96 pub struct Screen {
97 stdout: Stdout,
98 pub rows: u16,
99 pub cols: u16,
100 keyboard_enhanced: bool,
101 }
102
103 impl Screen {
104 pub fn new() -> Result<Self> {
105 let (cols, rows) = terminal::size()?;
106 Ok(Self {
107 stdout: stdout(),
108 rows,
109 cols,
110 keyboard_enhanced: false,
111 })
112 }
113
114 pub fn enter_raw_mode(&mut self) -> Result<()> {
115 terminal::enable_raw_mode()?;
116 execute!(self.stdout, EnterAlternateScreen, Hide, EnableMouseCapture)?;
117
118 // Try to enable keyboard enhancement for better modifier key detection
119 // This enables the kitty keyboard protocol on supporting terminals.
120 // We use REPORT_ALTERNATE_KEYS so crossterm receives the shifted character
121 // (e.g., 'A' instead of 'a' with shift modifier) for consistent behavior.
122 // See: https://github.com/helix-editor/helix/pull/4939
123 if execute!(
124 self.stdout,
125 PushKeyboardEnhancementFlags(
126 KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
127 | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
128 )
129 )
130 .is_ok()
131 {
132 self.keyboard_enhanced = true;
133 }
134
135 Ok(())
136 }
137
138 pub fn leave_raw_mode(&mut self) -> Result<()> {
139 if self.keyboard_enhanced {
140 let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
141 }
142 execute!(self.stdout, Show, DisableMouseCapture, LeaveAlternateScreen)?;
143 terminal::disable_raw_mode()?;
144 Ok(())
145 }
146
147 pub fn refresh_size(&mut self) -> Result<()> {
148 let (cols, rows) = terminal::size()?;
149 self.cols = cols;
150 self.rows = rows;
151 Ok(())
152 }
153
154 /// Position and show the hardware cursor at the given screen coordinates
155 pub fn show_cursor_at(&mut self, col: u16, row: u16) -> Result<()> {
156 execute!(self.stdout, MoveTo(col, row), Show)?;
157 self.stdout.flush()?;
158 Ok(())
159 }
160
161 #[allow(dead_code)]
162 pub fn clear(&mut self) -> Result<()> {
163 execute!(self.stdout, Clear(ClearType::All))?;
164 Ok(())
165 }
166
167 /// Render the tab bar
168 /// Returns the height of the tab bar (always 1)
169 pub fn render_tab_bar(&mut self, tabs: &[TabInfo], left_offset: u16) -> Result<u16> {
170 execute!(self.stdout, MoveTo(left_offset, 0))?;
171
172 // Fill the tab bar background
173 let available_width = self.cols.saturating_sub(left_offset) as usize;
174 execute!(
175 self.stdout,
176 SetBackgroundColor(TAB_BAR_BG),
177 SetForegroundColor(TAB_INACTIVE_FG),
178 )?;
179
180 // Calculate max width per tab
181 let tab_count = tabs.len();
182 let separators = tab_count.saturating_sub(1);
183 let available_for_tabs = available_width.saturating_sub(separators);
184 let max_tab_width = (available_for_tabs / tab_count).max(3); // At least 3 chars per tab
185
186 let mut current_col = left_offset as usize;
187
188 for (i, tab) in tabs.iter().enumerate() {
189 // Build tab label: [index] name [*]
190 let index_str = if tab.index < 9 {
191 format!("{}", tab.index + 1)
192 } else {
193 String::new()
194 };
195
196 let modified_str = if tab.is_modified { "*" } else { "" };
197
198 // Calculate available space for name
199 let prefix_len = if index_str.is_empty() { 0 } else { index_str.len() + 1 }; // "1 "
200 let suffix_len = modified_str.len();
201 let name_max = max_tab_width.saturating_sub(prefix_len + suffix_len);
202
203 // Truncate name if needed
204 let display_name: String = if tab.name.len() > name_max {
205 tab.name.chars().take(name_max.saturating_sub(1)).collect::<String>() + "…"
206 } else {
207 tab.name.clone()
208 };
209
210 // Set colors based on active state
211 let (bg, fg) = if tab.is_active {
212 (TAB_ACTIVE_BG, TAB_ACTIVE_FG)
213 } else {
214 (TAB_BAR_BG, TAB_INACTIVE_FG)
215 };
216
217 execute!(
218 self.stdout,
219 MoveTo(current_col as u16, 0),
220 SetBackgroundColor(bg),
221 )?;
222
223 // Print index number (for Alt+N shortcut hint)
224 if !index_str.is_empty() {
225 execute!(
226 self.stdout,
227 SetForegroundColor(LINE_NUM_COLOR),
228 Print(&index_str),
229 Print(" "),
230 )?;
231 }
232
233 // Print tab name
234 execute!(
235 self.stdout,
236 SetForegroundColor(fg),
237 Print(&display_name),
238 )?;
239
240 // Print modified indicator
241 if tab.is_modified {
242 execute!(
243 self.stdout,
244 SetForegroundColor(TAB_MODIFIED_FG),
245 Print(modified_str),
246 )?;
247 }
248
249 current_col += prefix_len + display_name.len() + suffix_len;
250
251 // Add separator between tabs
252 if i + 1 < tab_count {
253 execute!(
254 self.stdout,
255 SetBackgroundColor(TAB_BAR_BG),
256 SetForegroundColor(LINE_NUM_COLOR),
257 Print("│"),
258 )?;
259 current_col += 1;
260 }
261 }
262
263 // Fill the rest of the line
264 execute!(
265 self.stdout,
266 SetBackgroundColor(TAB_BAR_BG),
267 Clear(ClearType::UntilNewLine),
268 ResetColor,
269 )?;
270
271 Ok(1)
272 }
273
274 /// Render multiple panes with their separators
275 /// Returns the position of the hardware cursor (for the active pane)
276 pub fn render_panes(
277 &mut self,
278 panes: &[PaneInfo],
279 filename: Option<&str>,
280 message: Option<&str>,
281 left_offset: u16,
282 top_offset: u16,
283 ) -> Result<()> {
284 execute!(self.stdout, Hide)?;
285
286 // Calculate available screen area
287 let available_width = self.cols.saturating_sub(left_offset) as f32;
288 let available_height = self.rows.saturating_sub(2 + top_offset) as f32; // -2 for gap + status bar
289
290 // Track where to place the hardware cursor (active pane's primary cursor)
291 let mut cursor_screen_pos: Option<(u16, u16)> = None;
292
293 for pane in panes {
294 // Convert normalized bounds to screen coordinates
295 let pane_x = left_offset + (pane.bounds.x_start * available_width) as u16;
296 let pane_y = top_offset + (pane.bounds.y_start * available_height) as u16;
297 let pane_width = ((pane.bounds.x_end - pane.bounds.x_start) * available_width) as u16;
298 let pane_height = ((pane.bounds.y_end - pane.bounds.y_start) * available_height) as u16;
299
300 // Render this pane
301 let cursor_pos = self.render_single_pane(
302 pane,
303 pane_x,
304 pane_y,
305 pane_width,
306 pane_height,
307 )?;
308
309 // Track active pane's cursor position
310 if pane.is_active {
311 cursor_screen_pos = cursor_pos;
312 }
313
314 // Draw separator on the left edge if not at left boundary
315 if pane.bounds.x_start > 0.01 {
316 let sep_x = pane_x.saturating_sub(1);
317 let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG };
318 for row in 0..pane_height {
319 execute!(
320 self.stdout,
321 MoveTo(sep_x, pane_y + row),
322 SetBackgroundColor(BG_COLOR),
323 SetForegroundColor(sep_color),
324 Print("│"),
325 )?;
326 }
327 }
328
329 // Draw separator on the top edge if not at top boundary
330 if pane.bounds.y_start > 0.01 {
331 let sep_y = pane_y.saturating_sub(1);
332 let sep_color = if pane.is_active { PANE_ACTIVE_SEPARATOR_FG } else { PANE_SEPARATOR_FG };
333 for col in 0..pane_width {
334 execute!(
335 self.stdout,
336 MoveTo(pane_x + col, sep_y),
337 SetBackgroundColor(BG_COLOR),
338 SetForegroundColor(sep_color),
339 Print("─"),
340 )?;
341 }
342 }
343 }
344
345 // Render the gap row (empty line between text and status bar)
346 let gap_row = top_offset + available_height as u16;
347 execute!(
348 self.stdout,
349 MoveTo(left_offset, gap_row),
350 SetBackgroundColor(BG_COLOR),
351 Clear(ClearType::UntilNewLine),
352 ResetColor
353 )?;
354
355 // Render status bar (use active pane's info)
356 if let Some(active_pane) = panes.iter().find(|p| p.is_active) {
357 self.render_status_bar_with_offset(
358 active_pane.cursors,
359 filename,
360 message,
361 left_offset,
362 active_pane.is_modified,
363 )?;
364 }
365
366 // Position hardware cursor
367 if let Some((col, row)) = cursor_screen_pos {
368 execute!(self.stdout, MoveTo(col, row), Show)?;
369 }
370
371 self.stdout.flush()?;
372 Ok(())
373 }
374
375 /// Render a single pane within its screen bounds
376 /// Returns the screen position of the primary cursor if this is the active pane
377 fn render_single_pane(
378 &mut self,
379 pane: &PaneInfo,
380 x: u16,
381 y: u16,
382 width: u16,
383 height: u16,
384 ) -> Result<Option<(u16, u16)>> {
385 let buffer = pane.buffer;
386 let cursors = pane.cursors;
387 let is_active = pane.is_active;
388
389 // Choose colors based on active state
390 let bg_color = if is_active { BG_COLOR } else { INACTIVE_BG_COLOR };
391 let current_line_bg = if is_active { CURRENT_LINE_BG } else { INACTIVE_CURRENT_LINE_BG };
392 let line_num_color = if is_active { LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR };
393 let current_line_num_color = if is_active { CURRENT_LINE_NUM_COLOR } else { INACTIVE_LINE_NUM_COLOR };
394 let text_color = if is_active { Color::Reset } else { INACTIVE_TEXT_COLOR };
395
396 let line_num_width = self.line_number_width(buffer.line_count());
397 let text_cols = (width as usize).saturating_sub(line_num_width + 1);
398
399 let primary = cursors.primary();
400
401 // Collect selections and cursor positions (only show in active pane)
402 let selections: Vec<(Position, Position)> = if is_active {
403 cursors.all()
404 .iter()
405 .filter_map(|c| c.selection_bounds())
406 .collect()
407 } else {
408 Vec::new()
409 };
410
411 let primary_idx = cursors.primary_index();
412 let cursor_positions: Vec<(usize, usize, bool)> = if is_active {
413 cursors.all()
414 .iter()
415 .enumerate()
416 .map(|(i, c)| (c.line, c.col, i == primary_idx))
417 .collect()
418 } else {
419 Vec::new()
420 };
421
422 // Draw text area
423 for row in 0..height as usize {
424 let line_idx = pane.viewport_line + row;
425 let is_current_line = line_idx == primary.line;
426 execute!(self.stdout, MoveTo(x, y + row as u16))?;
427
428 if line_idx < buffer.line_count() {
429 let line_num_fg = if is_current_line {
430 current_line_num_color
431 } else {
432 line_num_color
433 };
434 let line_bg = if is_current_line { current_line_bg } else { bg_color };
435
436 execute!(
437 self.stdout,
438 SetBackgroundColor(line_bg),
439 SetForegroundColor(line_num_fg),
440 Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
441 )?;
442
443 if let Some(line) = buffer.line_str(line_idx) {
444 if is_active {
445 // Active pane: full highlighting
446 let bracket_col = pane.bracket_match
447 .filter(|(bl, _)| *bl == line_idx)
448 .map(|(_, bc)| bc);
449
450 let secondary_cursors: Vec<usize> = cursor_positions.iter()
451 .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
452 .map(|(_, c, _)| *c)
453 .collect();
454
455 self.render_line_with_cursors_bounded(
456 &line,
457 line_idx,
458 text_cols,
459 &selections,
460 is_current_line,
461 bracket_col,
462 &secondary_cursors,
463 )?;
464 } else {
465 // Inactive pane: simple dimmed text
466 let chars: String = line.chars().take(text_cols).collect();
467 execute!(
468 self.stdout,
469 SetBackgroundColor(line_bg),
470 SetForegroundColor(text_color),
471 Print(&chars),
472 )?;
473 }
474 }
475
476 // Fill rest of pane width
477 execute!(
478 self.stdout,
479 SetBackgroundColor(line_bg),
480 )?;
481 let line_len = buffer.line_str(line_idx).map(|l| l.len()).unwrap_or(0);
482 let current_col = x + line_num_width as u16 + 1 + text_cols.min(line_len) as u16;
483 let remaining = (x + width).saturating_sub(current_col);
484 if remaining > 0 {
485 execute!(self.stdout, Print(" ".repeat(remaining as usize)))?;
486 }
487 execute!(self.stdout, ResetColor)?;
488 } else {
489 execute!(
490 self.stdout,
491 SetBackgroundColor(bg_color),
492 SetForegroundColor(if is_active { Color::DarkBlue } else { INACTIVE_LINE_NUM_COLOR }),
493 Print(format!("{:>width$} ", "~", width = line_num_width)),
494 )?;
495 // Fill rest of line within pane bounds
496 let remaining = width.saturating_sub(line_num_width as u16 + 1);
497 execute!(self.stdout, Print(" ".repeat(remaining as usize)), ResetColor)?;
498 }
499 }
500
501 // Return cursor position if this is the active pane
502 if pane.is_active {
503 let cursor_row = primary.line.saturating_sub(pane.viewport_line);
504 if cursor_row < height as usize {
505 let cursor_screen_row = y + cursor_row as u16;
506 let cursor_screen_col = x + line_num_width as u16 + 1 + primary.col as u16;
507 return Ok(Some((cursor_screen_col, cursor_screen_row)));
508 }
509 }
510
511 Ok(None)
512 }
513
514 /// Render line with cursors, bounded to a specific width
515 fn render_line_with_cursors_bounded(
516 &mut self,
517 line: &str,
518 line_idx: usize,
519 max_cols: usize,
520 selections: &[(Position, Position)],
521 is_current_line: bool,
522 bracket_col: Option<usize>,
523 secondary_cursors: &[usize],
524 ) -> Result<()> {
525 // Delegate to existing method - it already handles max_cols
526 self.render_line_with_cursors(
527 line,
528 line_idx,
529 max_cols,
530 selections,
531 is_current_line,
532 bracket_col,
533 secondary_cursors,
534 )
535 }
536
537 /// Render the editor view (without offsets - use render_with_offset instead)
538 #[allow(dead_code)]
539 pub fn render(
540 &mut self,
541 buffer: &Buffer,
542 cursors: &Cursors,
543 viewport_line: usize,
544 filename: Option<&str>,
545 message: Option<&str>,
546 bracket_match: Option<(usize, usize)>,
547 ) -> Result<()> {
548 // Hide cursor during render to prevent flicker
549 execute!(self.stdout, Hide)?;
550
551 let line_num_width = self.line_number_width(buffer.line_count());
552 let text_cols = self.cols as usize - line_num_width - 1;
553
554 // Get primary cursor for current line highlighting
555 let primary = cursors.primary();
556
557 // Collect all selections from all cursors
558 let selections: Vec<(Position, Position)> = cursors.all()
559 .iter()
560 .filter_map(|c| c.selection_bounds())
561 .collect();
562
563 // Collect all cursor positions for rendering
564 let primary_idx = cursors.primary_index();
565 let cursor_positions: Vec<(usize, usize, bool)> = cursors.all()
566 .iter()
567 .enumerate()
568 .map(|(i, c)| (c.line, c.col, i == primary_idx)) // (line, col, is_primary)
569 .collect();
570
571 // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
572 let text_rows = self.rows.saturating_sub(2) as usize;
573
574 // Draw text area
575 for row in 0..text_rows {
576 let line_idx = viewport_line + row;
577 let is_current_line = line_idx == primary.line;
578 execute!(self.stdout, MoveTo(0, row as u16))?;
579
580 if line_idx < buffer.line_count() {
581 // Line number with appropriate color
582 let line_num_fg = if is_current_line {
583 CURRENT_LINE_NUM_COLOR
584 } else {
585 LINE_NUM_COLOR
586 };
587 let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
588
589 execute!(
590 self.stdout,
591 SetBackgroundColor(line_bg),
592 SetForegroundColor(line_num_fg),
593 Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
594 )?;
595
596 // Line content with selection and cursor highlighting
597 if let Some(line) = buffer.line_str(line_idx) {
598 // Check if bracket match is on this line
599 let bracket_col = bracket_match
600 .filter(|(bl, _)| *bl == line_idx)
601 .map(|(_, bc)| bc);
602
603 // Get cursors on this line (excluding primary which uses hardware cursor)
604 let secondary_cursors: Vec<usize> = cursor_positions.iter()
605 .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
606 .map(|(_, c, _)| *c)
607 .collect();
608
609 self.render_line_with_cursors(
610 &line,
611 line_idx,
612 text_cols,
613 &selections,
614 is_current_line,
615 bracket_col,
616 &secondary_cursors,
617 )?;
618 }
619
620 // Fill rest of line with background color
621 execute!(
622 self.stdout,
623 SetBackgroundColor(line_bg),
624 Clear(ClearType::UntilNewLine),
625 ResetColor
626 )?;
627 } else {
628 // Empty line indicator
629 execute!(
630 self.stdout,
631 SetBackgroundColor(BG_COLOR),
632 SetForegroundColor(Color::DarkBlue),
633 Print(format!("{:>width$} ", "~", width = line_num_width)),
634 Clear(ClearType::UntilNewLine),
635 ResetColor
636 )?;
637 }
638 }
639
640 // Render the gap row (empty line between text and status bar)
641 let gap_row = text_rows as u16;
642 execute!(
643 self.stdout,
644 MoveTo(0, gap_row),
645 SetBackgroundColor(BG_COLOR),
646 Clear(ClearType::UntilNewLine),
647 ResetColor
648 )?;
649
650 // Status bar
651 self.render_status_bar(buffer, cursors, filename, message)?;
652
653 // Position hardware cursor at primary cursor
654 let cursor_row = primary.line.saturating_sub(viewport_line);
655 let cursor_col = line_num_width + 1 + primary.col;
656 execute!(
657 self.stdout,
658 MoveTo(cursor_col as u16, cursor_row as u16),
659 Show
660 )?;
661
662 self.stdout.flush()?;
663 Ok(())
664 }
665
666 fn render_line_with_cursors(
667 &mut self,
668 line: &str,
669 line_idx: usize,
670 max_cols: usize,
671 selections: &[(Position, Position)],
672 is_current_line: bool,
673 bracket_col: Option<usize>,
674 secondary_cursors: &[usize],
675 ) -> Result<()> {
676 // Call the syntax-aware version with no tokens
677 self.render_line_with_syntax(
678 line,
679 line_idx,
680 max_cols,
681 selections,
682 is_current_line,
683 bracket_col,
684 secondary_cursors,
685 &[],
686 )
687 }
688
689 fn render_line_with_syntax(
690 &mut self,
691 line: &str,
692 line_idx: usize,
693 max_cols: usize,
694 selections: &[(Position, Position)],
695 is_current_line: bool,
696 bracket_col: Option<usize>,
697 secondary_cursors: &[usize],
698 tokens: &[Token],
699 ) -> Result<()> {
700 let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
701 let default_fg = Color::Reset; // Default terminal foreground
702
703 // Pre-compute selection ranges for this line (small fixed array to avoid allocation)
704 // Most users have at most a few cursors with selections
705 let mut sel_start: [usize; 8] = [0; 8];
706 let mut sel_end: [usize; 8] = [0; 8];
707 let mut sel_count = 0;
708 for (start, end) in selections {
709 if line_idx >= start.line && line_idx <= end.line && sel_count < 8 {
710 sel_start[sel_count] = if line_idx == start.line { start.col } else { 0 };
711 sel_end[sel_count] = if line_idx == end.line { end.col } else { usize::MAX };
712 if sel_start[sel_count] < sel_end[sel_count] {
713 sel_count += 1;
714 }
715 }
716 }
717
718 // Track current token index for efficient lookup (tokens are sorted by position)
719 let mut current_token_idx = 0;
720
721 // Count characters rendered for end-of-line cursor handling
722 let mut char_count = 0;
723
724 // Render character by character for precise highlighting
725 for (col, ch) in line.chars().enumerate() {
726 if col >= max_cols {
727 break;
728 }
729 char_count = col + 1;
730
731 // Check selection (inline check against fixed array)
732 let in_selection = (0..sel_count).any(|i| col >= sel_start[i] && col < sel_end[i]);
733 let is_bracket_match = bracket_col == Some(col);
734 let is_secondary_cursor = secondary_cursors.contains(&col);
735
736 // Advance token index if needed (tokens are sorted by start position)
737 while current_token_idx < tokens.len() && tokens[current_token_idx].end <= col {
738 current_token_idx += 1;
739 }
740
741 // Get current token if any
742 let current_token = if current_token_idx < tokens.len() {
743 let t = &tokens[current_token_idx];
744 if col >= t.start && col < t.end {
745 Some(t)
746 } else {
747 None
748 }
749 } else {
750 None
751 };
752
753 // Determine background color (priority: selection > cursor > bracket > syntax/line)
754 let bg = if in_selection {
755 Color::Blue
756 } else if is_secondary_cursor {
757 Color::Magenta
758 } else if is_bracket_match {
759 BRACKET_MATCH_BG
760 } else {
761 line_bg
762 };
763
764 // Determine foreground color and boldness
765 let (fg, bold) = if in_selection {
766 (Color::White, false)
767 } else if is_secondary_cursor {
768 (Color::White, false)
769 } else if let Some(token) = current_token {
770 (token.token_type.color(), token.token_type.bold())
771 } else {
772 (default_fg, false)
773 };
774
775 // Apply styling
776 if bold {
777 execute!(
778 self.stdout,
779 SetBackgroundColor(bg),
780 SetForegroundColor(fg),
781 SetAttribute(Attribute::Bold),
782 Print(ch),
783 SetAttribute(Attribute::NoBold),
784 )?;
785 } else {
786 execute!(
787 self.stdout,
788 SetBackgroundColor(bg),
789 SetForegroundColor(fg),
790 Print(ch)
791 )?;
792 }
793 }
794
795 // Reset to line background for rest of line
796 execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?;
797
798 // Handle secondary cursors at end of line (past text content)
799 let max_cursor_past_text = secondary_cursors.iter()
800 .filter(|&&c| c >= char_count)
801 .max()
802 .copied();
803
804 if let Some(max_cursor) = max_cursor_past_text {
805 if max_cursor < max_cols {
806 for col in char_count..=max_cursor {
807 if secondary_cursors.contains(&col) {
808 execute!(
809 self.stdout,
810 SetBackgroundColor(Color::Magenta),
811 SetForegroundColor(Color::White),
812 Print(" ")
813 )?;
814 } else {
815 execute!(
816 self.stdout,
817 SetBackgroundColor(line_bg),
818 Print(" ")
819 )?;
820 }
821 }
822 execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?;
823 }
824 }
825
826 Ok(())
827 }
828
829 #[allow(dead_code)]
830 fn render_status_bar(
831 &mut self,
832 buffer: &Buffer,
833 cursors: &Cursors,
834 filename: Option<&str>,
835 message: Option<&str>,
836 ) -> Result<()> {
837 let status_row = self.rows.saturating_sub(1);
838 execute!(self.stdout, MoveTo(0, status_row))?;
839
840 // Status bar background
841 execute!(
842 self.stdout,
843 SetBackgroundColor(Color::DarkGrey),
844 SetForegroundColor(Color::White)
845 )?;
846
847 // Left side: filename + modified indicator + cursor count
848 let name = filename.unwrap_or("[No Name]");
849 let modified = if buffer.modified { " [+]" } else { "" };
850 let cursor_count = if cursors.len() > 1 {
851 format!(" ({} cursors)", cursors.len())
852 } else {
853 String::new()
854 };
855 let left = format!(" {}{}{}", name, modified, cursor_count);
856
857 // Right side: help hint, position, and message if any
858 let primary = cursors.primary();
859 let pos = format!("Ln {}, Col {}", primary.line + 1, primary.col + 1);
860 let right = if let Some(msg) = message {
861 format!(" {} | Shift+F1: Help | {} ", msg, pos)
862 } else {
863 format!(" Shift+F1: Help | {} ", pos)
864 };
865
866 // Pad middle
867 let padding = (self.cols as usize).saturating_sub(left.len() + right.len());
868 let middle = " ".repeat(padding);
869
870 execute!(
871 self.stdout,
872 Print(&left),
873 Print(&middle),
874 Print(&right),
875 ResetColor
876 )?;
877
878 Ok(())
879 }
880
881 pub fn line_number_width(&self, line_count: usize) -> usize {
882 let digits = if line_count == 0 {
883 1
884 } else {
885 (line_count as f64).log10().floor() as usize + 1
886 };
887 digits.max(3) // Minimum 3 characters
888 }
889
890 /// Render the fuss mode sidebar
891 pub fn render_fuss(
892 &mut self,
893 items: &[VisibleItem],
894 selected: usize,
895 scroll: usize,
896 width: u16,
897 hints_expanded: bool,
898 repo_name: &str,
899 branch: Option<&str>,
900 git_mode: bool,
901 ) -> Result<()> {
902 let width = width as usize;
903 let text_rows = self.rows.saturating_sub(1) as usize;
904 let hint_rows = if hints_expanded { 4 } else { 1 };
905 // Header line + separator + optional git mode line
906 let header_rows = if git_mode { 3 } else { 2 };
907 let tree_rows = text_rows.saturating_sub(hint_rows + header_rows);
908
909 // Draw header: repo_name:branch
910 execute!(self.stdout, MoveTo(0, 0))?;
911 let header_text = if let Some(b) = branch {
912 format!("{}:{}", repo_name, b)
913 } else {
914 repo_name.to_string()
915 };
916 let truncated: String = header_text.chars().take(width.saturating_sub(1)).collect();
917 let padded = format!("{:<width$}", truncated, width = width);
918
919 // Render header with cyan repo name, yellow branch
920 execute!(
921 self.stdout,
922 SetBackgroundColor(BG_COLOR),
923 SetForegroundColor(Color::Cyan),
924 )?;
925 if let Some(b) = branch {
926 let repo_display: String = repo_name.chars().take(width.saturating_sub(1)).collect();
927 execute!(self.stdout, Print(&repo_display))?;
928 execute!(
929 self.stdout,
930 SetForegroundColor(Color::DarkGrey),
931 Print(":"),
932 SetForegroundColor(Color::Yellow),
933 )?;
934 let remaining = width.saturating_sub(repo_display.len() + 1);
935 let branch_display: String = b.chars().take(remaining).collect();
936 let branch_padded = format!("{:<width$}", branch_display, width = remaining);
937 execute!(self.stdout, Print(&branch_padded))?;
938 } else {
939 execute!(self.stdout, Print(&padded))?;
940 }
941 execute!(self.stdout, ResetColor)?;
942
943 // Draw separator
944 execute!(self.stdout, MoveTo(0, 1))?;
945 let separator = "─".repeat(width);
946 execute!(
947 self.stdout,
948 SetBackgroundColor(BG_COLOR),
949 SetForegroundColor(Color::DarkGrey),
950 Print(&separator),
951 ResetColor,
952 )?;
953
954 // Draw git mode indicator line
955 if git_mode {
956 let git_row = 2u16;
957 execute!(self.stdout, MoveTo(0, git_row))?;
958 let git_hint = "Git: a/u/d/m/p/l/f/t";
959 let padded = format!("{:<width$}", git_hint, width = width);
960 execute!(
961 self.stdout,
962 SetBackgroundColor(Color::AnsiValue(235)),
963 SetForegroundColor(Color::Yellow),
964 Print(&padded),
965 ResetColor,
966 )?;
967 }
968
969 // Draw file tree (starting after header)
970 for row in 0..tree_rows {
971 let screen_row = (row + header_rows) as u16;
972 execute!(self.stdout, MoveTo(0, screen_row))?;
973
974 let item_idx = scroll + row;
975 if item_idx < items.len() {
976 let item = &items[item_idx];
977 let is_selected = item_idx == selected;
978
979 // Build git status indicator
980 let git_indicator = if item.git_status.staged {
981 " \x1b[32m↑\x1b[0m" // Green up arrow
982 } else if item.git_status.unstaged {
983 " \x1b[31m✗\x1b[0m" // Red X
984 } else if item.git_status.untracked {
985 " \x1b[90m?\x1b[0m" // Gray question mark
986 } else if item.git_status.incoming {
987 " \x1b[34m↓\x1b[0m" // Blue down arrow
988 } else {
989 ""
990 };
991
992 // Build display line
993 let indent = " ".repeat(item.depth.saturating_sub(1));
994 let icon = if item.is_dir {
995 if item.expanded { "- " } else { "+ " }
996 } else {
997 " "
998 };
999 let suffix = if item.is_dir { "/" } else { "" };
1000
1001 // Calculate space for name (leave room for git indicator)
1002 let prefix_len = indent.len() + icon.len();
1003 let indicator_display_len = if git_indicator.is_empty() { 0 } else { 2 }; // " X"
1004 let name_max = width.saturating_sub(prefix_len + suffix.len() + indicator_display_len);
1005 let name_truncated: String = item.name.chars().take(name_max).collect();
1006
1007 let display_base = format!("{}{}{}{}", indent, icon, name_truncated, suffix);
1008
1009 if is_selected {
1010 // Highlight selected - need to handle git indicator specially
1011 let padded_len = width.saturating_sub(indicator_display_len);
1012 let padded = format!("{:<width$}", display_base, width = padded_len);
1013 execute!(
1014 self.stdout,
1015 SetBackgroundColor(Color::DarkGrey),
1016 SetForegroundColor(Color::White),
1017 Print(&padded),
1018 )?;
1019 if !git_indicator.is_empty() {
1020 // Git indicator with selection background
1021 if item.git_status.staged {
1022 execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?;
1023 } else if item.git_status.unstaged {
1024 execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?;
1025 } else if item.git_status.untracked {
1026 execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?;
1027 } else if item.git_status.incoming {
1028 execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?;
1029 }
1030 }
1031 execute!(self.stdout, ResetColor)?;
1032 } else if item.is_dir {
1033 // Directories in blue
1034 let padded_len = width.saturating_sub(indicator_display_len);
1035 let padded = format!("{:<width$}", display_base, width = padded_len);
1036 execute!(
1037 self.stdout,
1038 SetBackgroundColor(BG_COLOR),
1039 SetForegroundColor(Color::Blue),
1040 Print(&padded),
1041 ResetColor
1042 )?;
1043 } else if item.git_status.gitignored {
1044 // Gitignored files in dark gray
1045 let padded = format!("{:<width$}", display_base, width = width);
1046 execute!(
1047 self.stdout,
1048 SetBackgroundColor(BG_COLOR),
1049 SetForegroundColor(Color::DarkGrey),
1050 Print(&padded),
1051 ResetColor
1052 )?;
1053 } else {
1054 // Files in default color with git status
1055 let padded_len = width.saturating_sub(indicator_display_len);
1056 let padded = format!("{:<width$}", display_base, width = padded_len);
1057 execute!(
1058 self.stdout,
1059 SetBackgroundColor(BG_COLOR),
1060 SetForegroundColor(Color::Reset),
1061 Print(&padded),
1062 )?;
1063 // Add git status indicator
1064 if item.git_status.staged {
1065 execute!(self.stdout, SetForegroundColor(Color::Green), Print(" ↑"))?;
1066 } else if item.git_status.unstaged {
1067 execute!(self.stdout, SetForegroundColor(Color::Red), Print(" ✗"))?;
1068 } else if item.git_status.untracked {
1069 execute!(self.stdout, SetForegroundColor(Color::DarkGrey), Print(" ?"))?;
1070 } else if item.git_status.incoming {
1071 execute!(self.stdout, SetForegroundColor(Color::Blue), Print(" ↓"))?;
1072 }
1073 execute!(self.stdout, ResetColor)?;
1074 }
1075 } else {
1076 // Empty row
1077 let empty = " ".repeat(width);
1078 execute!(
1079 self.stdout,
1080 SetBackgroundColor(BG_COLOR),
1081 Print(&empty),
1082 ResetColor
1083 )?;
1084 }
1085 }
1086
1087 // Draw hints at bottom (after header + tree)
1088 let hint_start = header_rows + tree_rows;
1089 if hints_expanded {
1090 let hints = [
1091 "type:jump spc:toggle enter:open",
1092 "alt-.:hidden alt-g:git ctrl-v/s:split",
1093 "ctrl-b:close ctrl-/:hints",
1094 "",
1095 ];
1096 for (i, hint) in hints.iter().enumerate() {
1097 if hint_start + i < text_rows {
1098 execute!(self.stdout, MoveTo(0, (hint_start + i) as u16))?;
1099 let padded = format!("{:<width$}", hint, width = width);
1100 execute!(
1101 self.stdout,
1102 SetBackgroundColor(BG_COLOR),
1103 SetForegroundColor(Color::DarkGrey),
1104 Print(&padded),
1105 ResetColor
1106 )?;
1107 }
1108 }
1109 } else {
1110 if hint_start < text_rows {
1111 execute!(self.stdout, MoveTo(0, hint_start as u16))?;
1112 let hint = "ctrl-/:hints";
1113 let padded = format!("{:<width$}", hint, width = width);
1114 execute!(
1115 self.stdout,
1116 SetBackgroundColor(BG_COLOR),
1117 SetForegroundColor(Color::DarkGrey),
1118 Print(&padded),
1119 ResetColor
1120 )?;
1121 }
1122 }
1123
1124 // Fill the status bar row for fuss mode column (prevents terminal bleed-through)
1125 let status_row = self.rows.saturating_sub(1);
1126 execute!(self.stdout, MoveTo(0, status_row))?;
1127 let status_fill = " ".repeat(width);
1128 execute!(
1129 self.stdout,
1130 SetBackgroundColor(BG_COLOR),
1131 Print(&status_fill),
1132 ResetColor
1133 )?;
1134
1135 Ok(())
1136 }
1137
1138 /// Render the editor view with horizontal and vertical offsets (for fuss mode and tab bar)
1139 #[allow(dead_code)]
1140 pub fn render_with_offset(
1141 &mut self,
1142 buffer: &Buffer,
1143 cursors: &Cursors,
1144 viewport_line: usize,
1145 filename: Option<&str>,
1146 message: Option<&str>,
1147 bracket_match: Option<(usize, usize)>,
1148 left_offset: u16,
1149 top_offset: u16,
1150 is_modified: bool,
1151 ) -> Result<()> {
1152 // Hide cursor during render to prevent flicker
1153 execute!(self.stdout, Hide)?;
1154
1155 let available_cols = self.cols.saturating_sub(left_offset) as usize;
1156 let line_num_width = self.line_number_width(buffer.line_count());
1157 let text_cols = available_cols.saturating_sub(line_num_width + 1);
1158
1159 // Get primary cursor for current line highlighting
1160 let primary = cursors.primary();
1161
1162 // Collect all selections from all cursors
1163 let selections: Vec<(Position, Position)> = cursors.all()
1164 .iter()
1165 .filter_map(|c| c.selection_bounds())
1166 .collect();
1167
1168 // Collect all cursor positions for rendering
1169 let primary_idx = cursors.primary_index();
1170 let cursor_positions: Vec<(usize, usize, bool)> = cursors.all()
1171 .iter()
1172 .enumerate()
1173 .map(|(i, c)| (c.line, c.col, i == primary_idx))
1174 .collect();
1175
1176 // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
1177 let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
1178
1179 // Draw text area
1180 for row in 0..text_rows {
1181 let line_idx = viewport_line + row;
1182 let is_current_line = line_idx == primary.line;
1183 execute!(self.stdout, MoveTo(left_offset, (row as u16) + top_offset))?;
1184
1185 if line_idx < buffer.line_count() {
1186 let line_num_fg = if is_current_line {
1187 CURRENT_LINE_NUM_COLOR
1188 } else {
1189 LINE_NUM_COLOR
1190 };
1191 let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
1192
1193 execute!(
1194 self.stdout,
1195 SetBackgroundColor(line_bg),
1196 SetForegroundColor(line_num_fg),
1197 Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
1198 )?;
1199
1200 if let Some(line) = buffer.line_str(line_idx) {
1201 let bracket_col = bracket_match
1202 .filter(|(bl, _)| *bl == line_idx)
1203 .map(|(_, bc)| bc);
1204
1205 let secondary_cursors: Vec<usize> = cursor_positions.iter()
1206 .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
1207 .map(|(_, c, _)| *c)
1208 .collect();
1209
1210 self.render_line_with_cursors(
1211 &line,
1212 line_idx,
1213 text_cols,
1214 &selections,
1215 is_current_line,
1216 bracket_col,
1217 &secondary_cursors,
1218 )?;
1219 }
1220
1221 execute!(
1222 self.stdout,
1223 SetBackgroundColor(line_bg),
1224 Clear(ClearType::UntilNewLine),
1225 ResetColor
1226 )?;
1227 } else {
1228 execute!(
1229 self.stdout,
1230 SetBackgroundColor(BG_COLOR),
1231 SetForegroundColor(Color::DarkBlue),
1232 Print(format!("{:>width$} ", "~", width = line_num_width)),
1233 Clear(ClearType::UntilNewLine),
1234 ResetColor
1235 )?;
1236 }
1237 }
1238
1239 // Render the gap row (empty line between text and status bar)
1240 let gap_row = text_rows as u16 + top_offset;
1241 execute!(
1242 self.stdout,
1243 MoveTo(left_offset, gap_row),
1244 SetBackgroundColor(BG_COLOR),
1245 Clear(ClearType::UntilNewLine),
1246 ResetColor
1247 )?;
1248
1249 // Status bar
1250 self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?;
1251
1252 // Position hardware cursor at primary cursor
1253 let cursor_row = (primary.line.saturating_sub(viewport_line) as u16) + top_offset;
1254 let cursor_col = left_offset as usize + line_num_width + 1 + primary.col;
1255 execute!(
1256 self.stdout,
1257 MoveTo(cursor_col as u16, cursor_row),
1258 Show
1259 )?;
1260
1261 self.stdout.flush()?;
1262 Ok(())
1263 }
1264
1265 /// Render the editor view with syntax highlighting
1266 pub fn render_with_syntax(
1267 &mut self,
1268 buffer: &Buffer,
1269 cursors: &Cursors,
1270 viewport_line: usize,
1271 viewport_col: usize,
1272 filename: Option<&str>,
1273 message: Option<&str>,
1274 bracket_match: Option<(usize, usize)>,
1275 left_offset: u16,
1276 top_offset: u16,
1277 is_modified: bool,
1278 highlighter: &mut Highlighter,
1279 ghost_text: Option<&str>,
1280 ) -> Result<()> {
1281 execute!(self.stdout, Hide)?;
1282
1283 let available_cols = self.cols.saturating_sub(left_offset) as usize;
1284 let line_num_width = self.line_number_width(buffer.line_count());
1285 let text_cols = available_cols.saturating_sub(line_num_width + 1);
1286
1287 let primary = cursors.primary();
1288
1289 // Adjust selections for horizontal scroll
1290 let selections: Vec<(Position, Position)> = cursors.all()
1291 .iter()
1292 .filter_map(|c| c.selection_bounds())
1293 .map(|(start, end)| {
1294 (
1295 Position { line: start.line, col: start.col.saturating_sub(viewport_col) },
1296 Position { line: end.line, col: end.col.saturating_sub(viewport_col) },
1297 )
1298 })
1299 .collect();
1300
1301 let primary_idx = cursors.primary_index();
1302 // Adjust cursor positions for horizontal scroll
1303 let cursor_positions: Vec<(usize, usize, bool)> = cursors.all()
1304 .iter()
1305 .enumerate()
1306 .map(|(i, c)| (c.line, c.col.saturating_sub(viewport_col), i == primary_idx))
1307 .collect();
1308
1309 // Reserve 2 rows: 1 for gap above status bar, 1 for status bar itself
1310 let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
1311
1312 // Get the starting highlight state for the viewport using the cache.
1313 // Only tokenize lines from the last cached point if needed.
1314 let cache_valid = highlighter.cache_valid_from();
1315 let start_line = cache_valid.min(viewport_line);
1316 let mut highlight_state = highlighter.get_state_for_line(start_line);
1317
1318 // Build cache from last valid point up to viewport (only if needed)
1319 for line_idx in start_line..viewport_line {
1320 if let Some(line) = buffer.line_str(line_idx) {
1321 let _ = highlighter.tokenize_line(&line, &mut highlight_state);
1322 highlighter.update_cache(line_idx, &highlight_state);
1323 }
1324 }
1325
1326 // Draw text area with syntax highlighting
1327 for row in 0..text_rows {
1328 let line_idx = viewport_line + row;
1329 let is_current_line = line_idx == primary.line;
1330 execute!(self.stdout, MoveTo(left_offset, (row as u16) + top_offset))?;
1331
1332 if line_idx < buffer.line_count() {
1333 let line_num_fg = if is_current_line {
1334 CURRENT_LINE_NUM_COLOR
1335 } else {
1336 LINE_NUM_COLOR
1337 };
1338 let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
1339
1340 execute!(
1341 self.stdout,
1342 SetBackgroundColor(line_bg),
1343 SetForegroundColor(line_num_fg),
1344 Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
1345 )?;
1346
1347 if let Some(line) = buffer.line_str(line_idx) {
1348 // Tokenize this line and update cache
1349 let tokens = highlighter.tokenize_line(&line, &mut highlight_state);
1350 highlighter.update_cache(line_idx, &highlight_state);
1351
1352 // Apply horizontal scroll to bracket match column
1353 // Only show if the bracket is in the visible area
1354 let bracket_col = bracket_match
1355 .filter(|(bl, bc)| *bl == line_idx && *bc >= viewport_col)
1356 .map(|(_, bc)| bc - viewport_col);
1357
1358 let secondary_cursors: Vec<usize> = cursor_positions.iter()
1359 .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary)
1360 .map(|(_, c, _)| *c)
1361 .collect();
1362
1363 // Skip characters before viewport_col
1364 let display_line: String = line.chars().skip(viewport_col).collect();
1365
1366 // Adjust tokens for horizontal scroll
1367 let adjusted_tokens: Vec<Token> = tokens.iter()
1368 .filter_map(|t| {
1369 let new_start = t.start.saturating_sub(viewport_col);
1370 let new_end = t.end.saturating_sub(viewport_col);
1371 if t.end <= viewport_col {
1372 None // Token is entirely before viewport
1373 } else {
1374 Some(Token {
1375 start: new_start,
1376 end: new_end,
1377 token_type: t.token_type,
1378 })
1379 }
1380 })
1381 .collect();
1382
1383 self.render_line_with_syntax(
1384 &display_line,
1385 line_idx,
1386 text_cols,
1387 &selections,
1388 is_current_line,
1389 bracket_col,
1390 &secondary_cursors,
1391 &adjusted_tokens,
1392 )?;
1393
1394 // Render ghost text on the current line after the cursor
1395 if is_current_line {
1396 if let Some(ghost) = ghost_text {
1397 // Calculate remaining space for ghost text
1398 let line_len = display_line.chars().count();
1399 let remaining_cols = text_cols.saturating_sub(line_len);
1400 if remaining_cols > 0 {
1401 // Truncate ghost text if it doesn't fit
1402 let ghost_display: String = ghost.chars().take(remaining_cols).collect();
1403 execute!(
1404 self.stdout,
1405 SetBackgroundColor(line_bg),
1406 SetForegroundColor(Color::AnsiValue(240)), // Dim gray
1407 Print(&ghost_display),
1408 )?;
1409 }
1410 }
1411 }
1412 }
1413
1414 execute!(
1415 self.stdout,
1416 SetBackgroundColor(line_bg),
1417 Clear(ClearType::UntilNewLine),
1418 ResetColor
1419 )?;
1420 } else {
1421 execute!(
1422 self.stdout,
1423 SetBackgroundColor(BG_COLOR),
1424 SetForegroundColor(Color::DarkBlue),
1425 Print(format!("{:>width$} ", "~", width = line_num_width)),
1426 Clear(ClearType::UntilNewLine),
1427 ResetColor
1428 )?;
1429 }
1430 }
1431
1432 // Render the gap row (empty line between text and status bar)
1433 let gap_row = text_rows as u16 + top_offset;
1434 execute!(
1435 self.stdout,
1436 MoveTo(left_offset, gap_row),
1437 SetBackgroundColor(BG_COLOR),
1438 Clear(ClearType::UntilNewLine),
1439 ResetColor
1440 )?;
1441
1442 // Status bar
1443 self.render_status_bar_with_offset(cursors, filename, message, left_offset, is_modified)?;
1444
1445 // Position hardware cursor (adjusted for horizontal scroll)
1446 let cursor_row = (primary.line.saturating_sub(viewport_line) as u16) + top_offset;
1447 let cursor_col = left_offset as usize + line_num_width + 1 + primary.col.saturating_sub(viewport_col);
1448 execute!(
1449 self.stdout,
1450 MoveTo(cursor_col as u16, cursor_row),
1451 Show
1452 )?;
1453
1454 self.stdout.flush()?;
1455 Ok(())
1456 }
1457
1458 fn render_status_bar_with_offset(
1459 &mut self,
1460 cursors: &Cursors,
1461 filename: Option<&str>,
1462 message: Option<&str>,
1463 offset: u16,
1464 is_modified: bool,
1465 ) -> Result<()> {
1466 let status_row = self.rows.saturating_sub(1);
1467 let available_cols = self.cols.saturating_sub(offset) as usize;
1468 execute!(self.stdout, MoveTo(offset, status_row))?;
1469
1470 execute!(
1471 self.stdout,
1472 SetBackgroundColor(Color::DarkGrey),
1473 SetForegroundColor(Color::White)
1474 )?;
1475
1476 let name = filename.unwrap_or("[No Name]");
1477 let modified = if is_modified { " [+]" } else { "" };
1478 let cursor_count = if cursors.len() > 1 {
1479 format!(" ({} cursors)", cursors.len())
1480 } else {
1481 String::new()
1482 };
1483 let left = format!(" {}{}{}", name, modified, cursor_count);
1484
1485 let primary = cursors.primary();
1486 let pos = format!("Ln {}, Col {}", primary.line + 1, primary.col + 1);
1487 let right = if let Some(msg) = message {
1488 format!(" {} | Shift+F1: Help | {} ", msg, pos)
1489 } else {
1490 format!(" Shift+F1: Help | {} ", pos)
1491 };
1492
1493 let padding = available_cols.saturating_sub(left.len() + right.len());
1494 let middle = " ".repeat(padding);
1495
1496 execute!(
1497 self.stdout,
1498 Print(&left),
1499 Print(&middle),
1500 Print(&right),
1501 ResetColor
1502 )?;
1503
1504 Ok(())
1505 }
1506
1507 /// Render the welcome menu
1508 pub fn render_welcome(
1509 &mut self,
1510 items: &[(String, String, bool, bool)], // (label, path, is_selected, is_current_dir)
1511 scroll: usize,
1512 ) -> Result<()> {
1513 execute!(self.stdout, Hide)?;
1514
1515 let cols = self.cols as usize;
1516 let rows = self.rows as usize;
1517
1518 // Fill background
1519 for row in 0..rows {
1520 execute!(
1521 self.stdout,
1522 MoveTo(0, row as u16),
1523 SetBackgroundColor(BG_COLOR),
1524 Clear(ClearType::UntilNewLine),
1525 )?;
1526 }
1527
1528 // Calculate box dimensions
1529 let box_width = cols.min(60).max(40);
1530 let box_height = rows.saturating_sub(4).min(items.len() + 6).max(10);
1531 let box_x = (cols.saturating_sub(box_width)) / 2;
1532 let box_y = (rows.saturating_sub(box_height)) / 2;
1533
1534 // Draw box border
1535 let top_border = format!("╭{}╮", "─".repeat(box_width.saturating_sub(2)));
1536 let bottom_border = format!("╰{}╯", "─".repeat(box_width.saturating_sub(2)));
1537
1538 execute!(
1539 self.stdout,
1540 MoveTo(box_x as u16, box_y as u16),
1541 SetBackgroundColor(BG_COLOR),
1542 SetForegroundColor(Color::DarkGrey),
1543 Print(&top_border),
1544 )?;
1545
1546 // Title
1547 let title = "Welcome to fackr";
1548 let title_row = box_y + 1;
1549 let title_x = box_x + (box_width.saturating_sub(title.len())) / 2;
1550 execute!(
1551 self.stdout,
1552 MoveTo(box_x as u16, title_row as u16),
1553 SetForegroundColor(Color::DarkGrey),
1554 Print("│"),
1555 SetForegroundColor(Color::White),
1556 )?;
1557 let padding_left = title_x.saturating_sub(box_x + 1);
1558 let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + title.len());
1559 execute!(
1560 self.stdout,
1561 Print(&" ".repeat(padding_left)),
1562 Print(title),
1563 Print(&" ".repeat(padding_right)),
1564 SetForegroundColor(Color::DarkGrey),
1565 Print("│"),
1566 )?;
1567
1568 // Subtitle
1569 let subtitle = "Select a workspace:";
1570 let subtitle_row = box_y + 2;
1571 execute!(
1572 self.stdout,
1573 MoveTo(box_x as u16, subtitle_row as u16),
1574 SetForegroundColor(Color::DarkGrey),
1575 Print("│"),
1576 SetForegroundColor(Color::AnsiValue(245)),
1577 )?;
1578 let padding_left = (box_width.saturating_sub(2).saturating_sub(subtitle.len())) / 2;
1579 let padding_right = box_width.saturating_sub(2).saturating_sub(padding_left + subtitle.len());
1580 execute!(
1581 self.stdout,
1582 Print(&" ".repeat(padding_left)),
1583 Print(subtitle),
1584 Print(&" ".repeat(padding_right)),
1585 SetForegroundColor(Color::DarkGrey),
1586 Print("│"),
1587 )?;
1588
1589 // Separator
1590 let separator_row = box_y + 3;
1591 execute!(
1592 self.stdout,
1593 MoveTo(box_x as u16, separator_row as u16),
1594 SetForegroundColor(Color::DarkGrey),
1595 Print("├"),
1596 Print(&"─".repeat(box_width.saturating_sub(2))),
1597 Print("┤"),
1598 )?;
1599
1600 // Item list area
1601 let list_start_row = box_y + 4;
1602 let list_height = box_height.saturating_sub(6);
1603 let inner_width = box_width.saturating_sub(4);
1604
1605 for i in 0..list_height {
1606 let row = list_start_row + i;
1607 let item_idx = scroll + i;
1608
1609 execute!(
1610 self.stdout,
1611 MoveTo(box_x as u16, row as u16),
1612 SetForegroundColor(Color::DarkGrey),
1613 Print("│ "),
1614 )?;
1615
1616 if item_idx < items.len() {
1617 let (label, _path, is_selected, is_current_dir) = &items[item_idx];
1618
1619 // Truncate label to fit
1620 let display_label: String = label.chars().take(inner_width).collect();
1621 let padded = format!("{:<width$}", display_label, width = inner_width);
1622
1623 if *is_selected {
1624 execute!(
1625 self.stdout,
1626 SetBackgroundColor(Color::DarkGrey),
1627 SetForegroundColor(Color::White),
1628 Print(&padded),
1629 SetBackgroundColor(BG_COLOR),
1630 )?;
1631 } else if *is_current_dir {
1632 execute!(
1633 self.stdout,
1634 SetForegroundColor(Color::Cyan),
1635 Print(&padded),
1636 )?;
1637 } else {
1638 execute!(
1639 self.stdout,
1640 SetForegroundColor(Color::Reset),
1641 Print(&padded),
1642 )?;
1643 }
1644
1645 // Show path hint for selected item
1646 if *is_selected && inner_width > 30 {
1647 // Clear and show path below
1648 }
1649 } else {
1650 execute!(
1651 self.stdout,
1652 SetForegroundColor(Color::Reset),
1653 Print(&" ".repeat(inner_width)),
1654 )?;
1655 }
1656
1657 execute!(
1658 self.stdout,
1659 SetForegroundColor(Color::DarkGrey),
1660 Print(" │"),
1661 )?;
1662 }
1663
1664 // Path display row (show selected path)
1665 let path_row = list_start_row + list_height;
1666 execute!(
1667 self.stdout,
1668 MoveTo(box_x as u16, path_row as u16),
1669 SetForegroundColor(Color::DarkGrey),
1670 Print("├"),
1671 Print(&"─".repeat(box_width.saturating_sub(2))),
1672 Print("┤"),
1673 )?;
1674
1675 // Show selected path
1676 let selected_item = items.iter().find(|(_, _, sel, _)| *sel);
1677 let path_display_row = path_row + 1;
1678 execute!(
1679 self.stdout,
1680 MoveTo(box_x as u16, path_display_row as u16),
1681 SetForegroundColor(Color::DarkGrey),
1682 Print("│ "),
1683 )?;
1684 if let Some((_, path, _, _)) = selected_item {
1685 let truncated_path: String = path.chars().take(inner_width).collect();
1686 let padded_path = format!("{:<width$}", truncated_path, width = inner_width);
1687 execute!(
1688 self.stdout,
1689 SetForegroundColor(Color::AnsiValue(240)),
1690 Print(&padded_path),
1691 )?;
1692 } else {
1693 execute!(
1694 self.stdout,
1695 Print(&" ".repeat(inner_width)),
1696 )?;
1697 }
1698 execute!(
1699 self.stdout,
1700 SetForegroundColor(Color::DarkGrey),
1701 Print(" │"),
1702 )?;
1703
1704 // Bottom border
1705 let bottom_row = path_display_row + 1;
1706 execute!(
1707 self.stdout,
1708 MoveTo(box_x as u16, bottom_row as u16),
1709 SetForegroundColor(Color::DarkGrey),
1710 Print(&bottom_border),
1711 )?;
1712
1713 // Hints at bottom
1714 let hint_row = bottom_row + 1;
1715 let hints = "↑/↓: navigate Enter: select ESC: quit";
1716 let hints_x = (cols.saturating_sub(hints.len())) / 2;
1717 execute!(
1718 self.stdout,
1719 MoveTo(hints_x as u16, hint_row as u16),
1720 SetForegroundColor(Color::AnsiValue(240)),
1721 Print(hints),
1722 ResetColor,
1723 )?;
1724
1725 self.stdout.flush()?;
1726 Ok(())
1727 }
1728
1729 /// Render a completion popup at the given screen position
1730 pub fn render_completion_popup(
1731 &mut self,
1732 completions: &[CompletionItem],
1733 selected_index: usize,
1734 cursor_row: u16,
1735 cursor_col: u16,
1736 left_offset: u16,
1737 ) -> Result<()> {
1738 if completions.is_empty() {
1739 return Ok(());
1740 }
1741
1742 // Popup settings
1743 let max_items = 10.min(completions.len());
1744 let popup_width = 40;
1745 let popup_bg = Color::AnsiValue(237);
1746 let selected_bg = Color::AnsiValue(24);
1747 let item_fg = Color::AnsiValue(252);
1748 let detail_fg = Color::AnsiValue(244);
1749
1750 // Position popup below cursor, or above if not enough space
1751 let popup_row = if cursor_row + (max_items as u16) + 2 < self.rows {
1752 cursor_row + 1
1753 } else {
1754 cursor_row.saturating_sub(max_items as u16 + 1)
1755 };
1756
1757 let popup_col = (cursor_col + left_offset).min(self.cols.saturating_sub(popup_width as u16));
1758
1759 // Calculate scroll offset to keep selection visible
1760 let scroll_offset = if selected_index >= max_items {
1761 selected_index - max_items + 1
1762 } else {
1763 0
1764 };
1765
1766 // Draw border and items
1767 for (i, item) in completions.iter().skip(scroll_offset).take(max_items).enumerate() {
1768 let row = popup_row + i as u16;
1769 let is_selected = i + scroll_offset == selected_index;
1770 let bg = if is_selected { selected_bg } else { popup_bg };
1771
1772 execute!(
1773 self.stdout,
1774 MoveTo(popup_col, row),
1775 SetBackgroundColor(bg),
1776 SetForegroundColor(item_fg),
1777 )?;
1778
1779 // Format: [icon] label detail
1780 let icon = item.kind.map(|k| k.icon()).unwrap_or(" ");
1781 let label = &item.label;
1782 let detail = item.detail.as_deref().unwrap_or("");
1783
1784 let label_width = popup_width - 4;
1785 let truncated_label: String = if label.len() > label_width - 2 {
1786 format!("{}...", &label[..label_width - 5])
1787 } else {
1788 label.clone()
1789 };
1790
1791 write!(self.stdout, " {} ", icon)?;
1792 write!(self.stdout, "{:<width$}", truncated_label, width = label_width - detail.len().min(15))?;
1793
1794 if !detail.is_empty() {
1795 execute!(self.stdout, SetForegroundColor(detail_fg))?;
1796 let truncated_detail: String = if detail.len() > 12 {
1797 format!("{}...", &detail[..9])
1798 } else {
1799 detail.to_string()
1800 };
1801 write!(self.stdout, "{}", truncated_detail)?;
1802 }
1803
1804 // Clear to popup width
1805 execute!(self.stdout, ResetColor)?;
1806 }
1807
1808 // Show scroll indicator if needed
1809 if completions.len() > max_items {
1810 let indicator_row = popup_row + max_items as u16;
1811 execute!(
1812 self.stdout,
1813 MoveTo(popup_col, indicator_row),
1814 SetBackgroundColor(popup_bg),
1815 SetForegroundColor(detail_fg),
1816 Print(format!(" {}/{} items ", selected_index + 1, completions.len())),
1817 ResetColor,
1818 )?;
1819 }
1820
1821 Ok(())
1822 }
1823
1824 /// Render diagnostics in the gutter or inline
1825 pub fn render_diagnostics_gutter(
1826 &mut self,
1827 diagnostics: &[Diagnostic],
1828 viewport_line: usize,
1829 left_offset: u16,
1830 top_offset: u16,
1831 ) -> Result<()> {
1832 // Match text_rows calculation from render functions
1833 let text_rows = self.rows.saturating_sub(2 + top_offset) as usize;
1834
1835 for diagnostic in diagnostics {
1836 let line = diagnostic.range.start.line as usize;
1837
1838 // Only render if in visible viewport
1839 if line >= viewport_line && line < viewport_line + text_rows {
1840 let row = (line - viewport_line) as u16 + top_offset;
1841
1842 // Determine color based on severity
1843 let color = match diagnostic.severity {
1844 Some(DiagnosticSeverity::Error) => Color::Red,
1845 Some(DiagnosticSeverity::Warning) => Color::Yellow,
1846 Some(DiagnosticSeverity::Information) => Color::Blue,
1847 Some(DiagnosticSeverity::Hint) => Color::Cyan,
1848 None => Color::Yellow,
1849 };
1850
1851 // Draw indicator at the start of the line (before line number)
1852 execute!(
1853 self.stdout,
1854 MoveTo(left_offset, row),
1855 SetForegroundColor(color),
1856 Print("●"),
1857 ResetColor,
1858 )?;
1859 }
1860 }
1861
1862 Ok(())
1863 }
1864
1865 /// Render a hover info popup at the given screen position
1866 pub fn render_hover_popup(
1867 &mut self,
1868 hover: &HoverInfo,
1869 cursor_row: u16,
1870 cursor_col: u16,
1871 left_offset: u16,
1872 ) -> Result<()> {
1873 let (width, height) = (self.cols, self.rows);
1874
1875 // Split content into lines
1876 let lines: Vec<&str> = hover.contents.lines().collect();
1877 if lines.is_empty() {
1878 return Ok(());
1879 }
1880
1881 // Calculate popup dimensions
1882 let max_popup_width = (width as usize).saturating_sub(left_offset as usize + 4).min(80);
1883 let popup_width = lines
1884 .iter()
1885 .map(|l| l.len().min(max_popup_width))
1886 .max()
1887 .unwrap_or(20)
1888 .max(20);
1889 let max_popup_height = (height as usize).saturating_sub(4).min(15);
1890 let popup_height = lines.len().min(max_popup_height);
1891
1892 // Determine position - prefer above cursor, but go below if needed
1893 let (popup_row, show_above) = if cursor_row as usize >= popup_height + 2 {
1894 (cursor_row.saturating_sub(popup_height as u16 + 1), true)
1895 } else {
1896 (cursor_row + 1, false)
1897 };
1898
1899 let popup_col = cursor_col.max(left_offset);
1900
1901 // Ensure popup fits on screen
1902 let popup_col = if popup_col as usize + popup_width + 2 > width as usize {
1903 (width as usize).saturating_sub(popup_width + 3) as u16
1904 } else {
1905 popup_col
1906 };
1907
1908 // Draw popup border and content
1909 for (i, line) in lines.iter().take(popup_height).enumerate() {
1910 let row = popup_row + i as u16;
1911
1912 // Background and border
1913 execute!(
1914 self.stdout,
1915 MoveTo(popup_col, row),
1916 SetBackgroundColor(Color::AnsiValue(238)),
1917 SetForegroundColor(Color::White),
1918 )?;
1919
1920 // Truncate line if needed
1921 let display_line: String = if line.len() > popup_width {
1922 format!(" {}... ", &line[..popup_width.saturating_sub(4)])
1923 } else {
1924 format!(" {:width$} ", line, width = popup_width)
1925 };
1926
1927 execute!(self.stdout, Print(&display_line), ResetColor)?;
1928 }
1929
1930 // Show indicator if content is truncated
1931 if lines.len() > popup_height {
1932 let row = popup_row + popup_height as u16;
1933 execute!(
1934 self.stdout,
1935 MoveTo(popup_col, row),
1936 SetBackgroundColor(Color::AnsiValue(238)),
1937 SetForegroundColor(Color::DarkGrey),
1938 Print(format!(" [{} more lines] ", lines.len() - popup_height)),
1939 ResetColor
1940 )?;
1941 }
1942
1943 // Hide cursor position indicator
1944 let _ = show_above; // suppress unused warning
1945
1946 Ok(())
1947 }
1948
1949 /// Render a centered rename modal dialog
1950 pub fn render_rename_modal(&mut self, original_name: &str, new_name: &str) -> Result<()> {
1951 let (width, height) = (self.cols, self.rows);
1952
1953 // Calculate modal dimensions
1954 let title = "Rename Symbol";
1955 let from_label = "From: ";
1956 let to_label = "To: ";
1957 let content_width = original_name.len().max(new_name.len()).max(20).max(title.len());
1958 let modal_width = content_width + 8; // padding + border
1959 let modal_height = 6; // title + from + to + bottom border + padding
1960
1961 // Center the modal
1962 let start_col = ((width as usize).saturating_sub(modal_width)) / 2;
1963 let start_row = ((height as usize).saturating_sub(modal_height)) / 2;
1964
1965 let bg = Color::AnsiValue(236);
1966 let border_color = Color::AnsiValue(244);
1967 let label_color = Color::AnsiValue(248);
1968 let value_color = Color::White;
1969 let input_bg = Color::AnsiValue(238);
1970
1971 // Draw top border
1972 execute!(
1973 self.stdout,
1974 MoveTo(start_col as u16, start_row as u16),
1975 SetBackgroundColor(bg),
1976 SetForegroundColor(border_color),
1977 Print(format!("┌{:─<width$}┐", "", width = modal_width - 2)),
1978 )?;
1979
1980 // Draw title row
1981 let title_padding = (modal_width - 2 - title.len()) / 2;
1982 execute!(
1983 self.stdout,
1984 MoveTo(start_col as u16, start_row as u16 + 1),
1985 SetBackgroundColor(bg),
1986 SetForegroundColor(border_color),
1987 Print("│"),
1988 SetForegroundColor(Color::Cyan),
1989 Print(format!("{:>pad$}{}{:<rpad$}", "", title, "", pad = title_padding, rpad = modal_width - 2 - title_padding - title.len())),
1990 SetForegroundColor(border_color),
1991 Print("│"),
1992 )?;
1993
1994 // Draw separator
1995 execute!(
1996 self.stdout,
1997 MoveTo(start_col as u16, start_row as u16 + 2),
1998 SetBackgroundColor(bg),
1999 SetForegroundColor(border_color),
2000 Print(format!("├{:─<width$}┤", "", width = modal_width - 2)),
2001 )?;
2002
2003 // Draw "From:" row
2004 execute!(
2005 self.stdout,
2006 MoveTo(start_col as u16, start_row as u16 + 3),
2007 SetBackgroundColor(bg),
2008 SetForegroundColor(border_color),
2009 Print("│ "),
2010 SetForegroundColor(label_color),
2011 Print(from_label),
2012 SetForegroundColor(value_color),
2013 Print(format!("{:<width$}", original_name, width = modal_width - 4 - from_label.len())),
2014 SetForegroundColor(border_color),
2015 Print(" │"),
2016 )?;
2017
2018 // Draw "To:" row with input field
2019 let input_width = modal_width - 4 - to_label.len();
2020 execute!(
2021 self.stdout,
2022 MoveTo(start_col as u16, start_row as u16 + 4),
2023 SetBackgroundColor(bg),
2024 SetForegroundColor(border_color),
2025 Print("│ "),
2026 SetForegroundColor(label_color),
2027 Print(to_label),
2028 SetBackgroundColor(input_bg),
2029 SetForegroundColor(Color::White),
2030 Print(format!("{:<width$}", new_name, width = input_width)),
2031 SetBackgroundColor(bg),
2032 SetForegroundColor(border_color),
2033 Print(" │"),
2034 )?;
2035
2036 // Draw bottom border
2037 execute!(
2038 self.stdout,
2039 MoveTo(start_col as u16, start_row as u16 + 5),
2040 SetBackgroundColor(bg),
2041 SetForegroundColor(border_color),
2042 Print(format!("└{:─<width$}┘", "", width = modal_width - 2)),
2043 ResetColor,
2044 )?;
2045
2046 // Position cursor in the input field
2047 let cursor_col = start_col + 2 + to_label.len() + new_name.len();
2048 execute!(
2049 self.stdout,
2050 MoveTo(cursor_col as u16, start_row as u16 + 4),
2051 SetBackgroundColor(input_bg),
2052 crossterm::cursor::Show,
2053 )?;
2054
2055 Ok(())
2056 }
2057
2058 /// Render the find/replace bar in the status area
2059 pub fn render_find_replace_bar(
2060 &mut self,
2061 find_query: &str,
2062 replace_text: &str,
2063 active_field: bool, // true = find, false = replace
2064 case_insensitive: bool,
2065 regex_mode: bool,
2066 match_count: usize,
2067 current_match: usize,
2068 left_offset: u16,
2069 ) -> Result<()> {
2070 let status_row = self.rows.saturating_sub(1);
2071 let available_cols = (self.cols.saturating_sub(left_offset)) as usize;
2072
2073 execute!(self.stdout, MoveTo(left_offset, status_row))?;
2074
2075 // Colors
2076 let bg = Color::DarkGrey;
2077 let active_bg = Color::AnsiValue(238);
2078 let inactive_bg = Color::AnsiValue(236);
2079 let label_color = Color::AnsiValue(250);
2080 let active_label = Color::White;
2081 let toggle_on = Color::Yellow;
2082 let toggle_off = Color::AnsiValue(243);
2083
2084 // Calculate widths
2085 // Layout: Find: [____] Replace: [____] [.*] [Aa] | N/M matches
2086 let find_label = "Find: ";
2087 let replace_label = " Replace: ";
2088 let suffix_len = 25; // toggles + match count
2089 let input_width = (available_cols.saturating_sub(find_label.len() + replace_label.len() + suffix_len)) / 2;
2090 let input_width = input_width.max(10).min(40);
2091
2092 // Start with background
2093 execute!(self.stdout, SetBackgroundColor(bg))?;
2094
2095 // Find label and input
2096 let find_bg = if active_field { active_bg } else { inactive_bg };
2097 let find_label_color = if active_field { active_label } else { label_color };
2098
2099 execute!(
2100 self.stdout,
2101 SetForegroundColor(find_label_color),
2102 Print(find_label),
2103 SetBackgroundColor(find_bg),
2104 SetForegroundColor(Color::White),
2105 )?;
2106
2107 // Truncate or pad find query
2108 let find_display: String = if find_query.len() > input_width {
2109 find_query.chars().skip(find_query.len() - input_width).collect()
2110 } else {
2111 format!("{:<width$}", find_query, width = input_width)
2112 };
2113 execute!(self.stdout, Print(&find_display))?;
2114
2115 // Replace label and input
2116 let replace_bg = if !active_field { active_bg } else { inactive_bg };
2117 let replace_label_color = if !active_field { active_label } else { label_color };
2118
2119 execute!(
2120 self.stdout,
2121 SetBackgroundColor(bg),
2122 SetForegroundColor(replace_label_color),
2123 Print(replace_label),
2124 SetBackgroundColor(replace_bg),
2125 SetForegroundColor(Color::White),
2126 )?;
2127
2128 // Truncate or pad replace text
2129 let replace_display: String = if replace_text.len() > input_width {
2130 replace_text.chars().skip(replace_text.len() - input_width).collect()
2131 } else {
2132 format!("{:<width$}", replace_text, width = input_width)
2133 };
2134 execute!(self.stdout, Print(&replace_display))?;
2135
2136 // Toggle buttons
2137 execute!(self.stdout, SetBackgroundColor(bg))?;
2138
2139 // Regex toggle [.*]
2140 let regex_color = if regex_mode { toggle_on } else { toggle_off };
2141 execute!(
2142 self.stdout,
2143 Print(" "),
2144 SetForegroundColor(regex_color),
2145 Print("[.*]"),
2146 )?;
2147
2148 // Case sensitivity toggle [Aa]
2149 let case_color = if case_insensitive { toggle_on } else { toggle_off };
2150 execute!(
2151 self.stdout,
2152 Print(" "),
2153 SetForegroundColor(case_color),
2154 Print("[Aa]"),
2155 )?;
2156
2157 // Match count
2158 execute!(self.stdout, SetForegroundColor(label_color))?;
2159 if match_count > 0 {
2160 execute!(
2161 self.stdout,
2162 Print(format!(" {}/{}", current_match + 1, match_count)),
2163 )?;
2164 } else if !find_query.is_empty() {
2165 execute!(self.stdout, Print(" No matches"))?;
2166 }
2167
2168 // Fill remaining space
2169 let used = find_label.len() + input_width + replace_label.len() + input_width + 5 + 5 +
2170 if match_count > 0 { format!(" {}/{}", current_match + 1, match_count).len() }
2171 else if !find_query.is_empty() { 11 }
2172 else { 0 };
2173 let remaining = available_cols.saturating_sub(used);
2174 execute!(
2175 self.stdout,
2176 Print(" ".repeat(remaining)),
2177 ResetColor,
2178 )?;
2179
2180 // Position cursor in active field
2181 let cursor_col = if active_field {
2182 left_offset as usize + find_label.len() + find_query.len().min(input_width)
2183 } else {
2184 left_offset as usize + find_label.len() + input_width + replace_label.len() + replace_text.len().min(input_width)
2185 };
2186 execute!(
2187 self.stdout,
2188 MoveTo(cursor_col as u16, status_row),
2189 crossterm::cursor::Show,
2190 )?;
2191
2192 Ok(())
2193 }
2194
2195 /// Render the Fortress file browser modal
2196 pub fn render_fortress_modal(
2197 &mut self,
2198 current_path: &std::path::Path,
2199 entries: &[(String, std::path::PathBuf, bool)], // (name, path, is_dir)
2200 selected_index: usize,
2201 filter: &str,
2202 scroll_offset: usize,
2203 ) -> Result<()> {
2204 let (width, height) = (self.cols as usize, self.rows as usize);
2205
2206 // Modal dimensions - centered
2207 let modal_width = 60.min(width - 4);
2208 let modal_height = 20.min(height - 4);
2209 let start_col = (width.saturating_sub(modal_width)) / 2;
2210 let start_row = (height.saturating_sub(modal_height)) / 2;
2211
2212 // Filter entries based on query
2213 let filtered: Vec<(usize, &(String, std::path::PathBuf, bool))> = if filter.is_empty() {
2214 entries.iter().enumerate().collect()
2215 } else {
2216 let f = filter.to_lowercase();
2217 entries.iter().enumerate()
2218 .filter(|(_, (name, _, _))| name.to_lowercase().contains(&f))
2219 .collect()
2220 };
2221
2222 // Colors
2223 let bg = Color::AnsiValue(235);
2224 let border_color = Color::AnsiValue(244);
2225 let header_color = Color::Cyan;
2226 let dir_color = Color::Blue;
2227 let file_color = Color::AnsiValue(252);
2228 let selected_bg = Color::AnsiValue(240);
2229 let input_bg = Color::AnsiValue(238);
2230
2231 // Draw top border with title
2232 let path_str = current_path.to_string_lossy();
2233 let max_path_len = modal_width - 6;
2234 let display_path = if path_str.len() > max_path_len {
2235 format!("...{}", &path_str[path_str.len().saturating_sub(max_path_len - 3)..])
2236 } else {
2237 path_str.to_string()
2238 };
2239 let title = format!(" {} ", display_path);
2240 execute!(
2241 self.stdout,
2242 MoveTo(start_col as u16, start_row as u16),
2243 SetBackgroundColor(bg),
2244 SetForegroundColor(border_color),
2245 Print("┌"),
2246 SetForegroundColor(header_color),
2247 Print(&title),
2248 SetForegroundColor(border_color),
2249 Print(format!("{:─<width$}┐", "", width = modal_width.saturating_sub(title.len() + 2))),
2250 ResetColor,
2251 )?;
2252
2253 // Draw filter input row
2254 execute!(
2255 self.stdout,
2256 MoveTo(start_col as u16, (start_row + 1) as u16),
2257 SetBackgroundColor(bg),
2258 SetForegroundColor(border_color),
2259 Print("│ "),
2260 SetForegroundColor(Color::AnsiValue(248)),
2261 Print("Filter: "),
2262 SetBackgroundColor(input_bg),
2263 SetForegroundColor(Color::White),
2264 Print(format!("{:<width$}", filter, width = modal_width.saturating_sub(12))),
2265 SetBackgroundColor(bg),
2266 SetForegroundColor(border_color),
2267 Print("│"),
2268 ResetColor,
2269 )?;
2270
2271 // Draw separator
2272 execute!(
2273 self.stdout,
2274 MoveTo(start_col as u16, (start_row + 2) as u16),
2275 SetBackgroundColor(bg),
2276 SetForegroundColor(border_color),
2277 Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2278 ResetColor,
2279 )?;
2280
2281 // Calculate visible range
2282 let visible_rows = modal_height.saturating_sub(5); // Account for borders, title, filter, help
2283
2284 // Adjust scroll offset so selected item is visible
2285 let scroll = if selected_index < scroll_offset {
2286 selected_index
2287 } else if selected_index >= scroll_offset + visible_rows {
2288 selected_index - visible_rows + 1
2289 } else {
2290 scroll_offset
2291 };
2292
2293 // Draw file/directory entries
2294 for (display_idx, (_orig_idx, (name, _, is_dir))) in filtered.iter().enumerate().skip(scroll).take(visible_rows) {
2295 let row = (start_row + 3 + display_idx - scroll) as u16;
2296 let is_selected = display_idx == selected_index;
2297
2298 let item_bg = if is_selected { selected_bg } else { bg };
2299 let name_color = if *is_dir { dir_color } else { file_color };
2300 let icon = if *is_dir { "[d] " } else { " " };
2301
2302 // Truncate name if needed
2303 let max_name_len = modal_width.saturating_sub(6);
2304 let display_name = if name.len() > max_name_len {
2305 format!("{}...", &name[..max_name_len - 3])
2306 } else {
2307 name.clone()
2308 };
2309
2310 execute!(
2311 self.stdout,
2312 MoveTo(start_col as u16, row),
2313 SetBackgroundColor(item_bg),
2314 SetForegroundColor(border_color),
2315 Print("│ "),
2316 Print(icon),
2317 SetForegroundColor(name_color),
2318 Print(format!("{:<width$}", display_name, width = modal_width.saturating_sub(6))),
2319 SetForegroundColor(border_color),
2320 Print("│"),
2321 ResetColor,
2322 )?;
2323 }
2324
2325 // Fill remaining rows with empty space
2326 let items_drawn = filtered.len().saturating_sub(scroll).min(visible_rows);
2327 for i in items_drawn..visible_rows {
2328 let row = (start_row + 3 + i) as u16;
2329 execute!(
2330 self.stdout,
2331 MoveTo(start_col as u16, row),
2332 SetBackgroundColor(bg),
2333 SetForegroundColor(border_color),
2334 Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2335 ResetColor,
2336 )?;
2337 }
2338
2339 // Draw help text row
2340 let help_row = (start_row + 3 + visible_rows) as u16;
2341 let help_text = "←:up →/Enter:open ↑↓:nav Esc:close";
2342 execute!(
2343 self.stdout,
2344 MoveTo(start_col as u16, help_row),
2345 SetBackgroundColor(bg),
2346 SetForegroundColor(border_color),
2347 Print("├"),
2348 SetForegroundColor(Color::AnsiValue(243)),
2349 Print(format!(" {:<width$}", help_text, width = modal_width.saturating_sub(3))),
2350 SetForegroundColor(border_color),
2351 Print("┤"),
2352 ResetColor,
2353 )?;
2354
2355 // Draw bottom border
2356 execute!(
2357 self.stdout,
2358 MoveTo(start_col as u16, help_row + 1),
2359 SetBackgroundColor(bg),
2360 SetForegroundColor(border_color),
2361 Print(format!("└{:─<width$}┘", "", width = modal_width.saturating_sub(2))),
2362 ResetColor,
2363 )?;
2364
2365 // Hide cursor when in fortress modal
2366 execute!(self.stdout, Hide)?;
2367
2368 self.stdout.flush()?;
2369 Ok(())
2370 }
2371
2372 /// Render the multi-file search modal (F4)
2373 pub fn render_file_search_modal(
2374 &mut self,
2375 query: &str,
2376 results: &[(std::path::PathBuf, usize, String)], // (path, line_num, line_content)
2377 selected_index: usize,
2378 scroll_offset: usize,
2379 searching: bool,
2380 ) -> Result<()> {
2381 let (width, height) = (self.cols as usize, self.rows as usize);
2382
2383 // Modal dimensions - centered, wider than fortress
2384 let modal_width = 80.min(width - 4);
2385 let modal_height = 25.min(height - 4);
2386 let start_col = (width.saturating_sub(modal_width)) / 2;
2387 let start_row = (height.saturating_sub(modal_height)) / 2;
2388
2389 // Colors
2390 let bg = Color::AnsiValue(235);
2391 let border_color = Color::AnsiValue(244);
2392 let header_color = Color::Cyan;
2393 let path_color = Color::Blue;
2394 let line_num_color = Color::Yellow;
2395 let content_color = Color::AnsiValue(252);
2396 let selected_bg = Color::AnsiValue(240);
2397 let input_bg = Color::AnsiValue(238);
2398
2399 // Draw top border with title
2400 let title = " Search in Files (F4) ";
2401 execute!(
2402 self.stdout,
2403 MoveTo(start_col as u16, start_row as u16),
2404 SetBackgroundColor(bg),
2405 SetForegroundColor(border_color),
2406 Print("┌"),
2407 SetForegroundColor(header_color),
2408 Print(title),
2409 SetForegroundColor(border_color),
2410 Print(format!("{:─<width$}┐", "", width = modal_width.saturating_sub(title.len() + 2))),
2411 ResetColor,
2412 )?;
2413
2414 // Draw search input row
2415 let status = if searching {
2416 "Searching..."
2417 } else if results.is_empty() && !query.is_empty() {
2418 "No results"
2419 } else if !results.is_empty() {
2420 ""
2421 } else {
2422 "Type query, press Enter"
2423 };
2424 let input_width = modal_width.saturating_sub(14 + status.len());
2425 execute!(
2426 self.stdout,
2427 MoveTo(start_col as u16, (start_row + 1) as u16),
2428 SetBackgroundColor(bg),
2429 SetForegroundColor(border_color),
2430 Print("│ "),
2431 SetForegroundColor(Color::AnsiValue(248)),
2432 Print("Search: "),
2433 SetBackgroundColor(input_bg),
2434 SetForegroundColor(Color::White),
2435 Print(format!("{:<width$}", query, width = input_width)),
2436 SetBackgroundColor(bg),
2437 SetForegroundColor(Color::AnsiValue(243)),
2438 Print(format!(" {}", status)),
2439 SetForegroundColor(border_color),
2440 Print(" │"),
2441 ResetColor,
2442 )?;
2443
2444 // Draw separator with result count
2445 let count_str = if results.is_empty() {
2446 String::new()
2447 } else {
2448 format!(" {} results ", results.len())
2449 };
2450 execute!(
2451 self.stdout,
2452 MoveTo(start_col as u16, (start_row + 2) as u16),
2453 SetBackgroundColor(bg),
2454 SetForegroundColor(border_color),
2455 Print("├"),
2456 SetForegroundColor(Color::AnsiValue(243)),
2457 Print(&count_str),
2458 SetForegroundColor(border_color),
2459 Print(format!("{:─<width$}┤", "", width = modal_width.saturating_sub(2 + count_str.len()))),
2460 ResetColor,
2461 )?;
2462
2463 // Calculate visible range
2464 let visible_rows = modal_height.saturating_sub(5); // Account for borders, title, input, help
2465
2466 // Adjust scroll offset so selected item is visible
2467 let scroll = if selected_index < scroll_offset {
2468 selected_index
2469 } else if selected_index >= scroll_offset + visible_rows {
2470 selected_index - visible_rows + 1
2471 } else {
2472 scroll_offset
2473 };
2474
2475 // Draw results
2476 for (display_idx, (path, line_num, content)) in results.iter().enumerate().skip(scroll).take(visible_rows) {
2477 let row = (start_row + 3 + display_idx - scroll) as u16;
2478 let is_selected = display_idx == selected_index;
2479
2480 let item_bg = if is_selected { selected_bg } else { bg };
2481
2482 // Format: path:line: content
2483 let path_str = path.to_string_lossy();
2484 let line_str = format!("{}", line_num);
2485
2486 // Calculate available width for content
2487 let prefix_len = path_str.len().min(30) + 1 + line_str.len() + 2; // path:line:
2488 let content_width = modal_width.saturating_sub(prefix_len + 4);
2489
2490 // Truncate path if needed
2491 let display_path = if path_str.len() > 30 {
2492 format!("...{}", &path_str[path_str.len().saturating_sub(27)..])
2493 } else {
2494 path_str.to_string()
2495 };
2496
2497 // Truncate content if needed
2498 let display_content = if content.len() > content_width {
2499 format!("{}...", &content[..content_width.saturating_sub(3)])
2500 } else {
2501 content.clone()
2502 };
2503
2504 execute!(
2505 self.stdout,
2506 MoveTo(start_col as u16, row),
2507 SetBackgroundColor(item_bg),
2508 SetForegroundColor(border_color),
2509 Print("│ "),
2510 SetForegroundColor(path_color),
2511 Print(&display_path),
2512 SetForegroundColor(Color::AnsiValue(243)),
2513 Print(":"),
2514 SetForegroundColor(line_num_color),
2515 Print(&line_str),
2516 SetForegroundColor(Color::AnsiValue(243)),
2517 Print(": "),
2518 SetForegroundColor(content_color),
2519 )?;
2520
2521 // Calculate remaining width and print content with padding
2522 let used = display_path.len() + 1 + line_str.len() + 2 + 2;
2523 let remaining = modal_width.saturating_sub(used + 2);
2524 execute!(
2525 self.stdout,
2526 Print(format!("{:<width$}", display_content, width = remaining)),
2527 SetForegroundColor(border_color),
2528 Print("│"),
2529 ResetColor,
2530 )?;
2531 }
2532
2533 // Fill remaining rows with empty space
2534 let items_drawn = results.len().saturating_sub(scroll).min(visible_rows);
2535 for i in items_drawn..visible_rows {
2536 let row = (start_row + 3 + i) as u16;
2537 execute!(
2538 self.stdout,
2539 MoveTo(start_col as u16, row),
2540 SetBackgroundColor(bg),
2541 SetForegroundColor(border_color),
2542 Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2543 ResetColor,
2544 )?;
2545 }
2546
2547 // Draw help text row
2548 let help_row = (start_row + 3 + visible_rows) as u16;
2549 let help_text = "Enter:search/open ↑↓:nav PgUp/Dn:scroll Esc:close";
2550 execute!(
2551 self.stdout,
2552 MoveTo(start_col as u16, help_row),
2553 SetBackgroundColor(bg),
2554 SetForegroundColor(border_color),
2555 Print("├"),
2556 SetForegroundColor(Color::AnsiValue(243)),
2557 Print(format!(" {:<width$}", help_text, width = modal_width.saturating_sub(3))),
2558 SetForegroundColor(border_color),
2559 Print("┤"),
2560 ResetColor,
2561 )?;
2562
2563 // Draw bottom border
2564 execute!(
2565 self.stdout,
2566 MoveTo(start_col as u16, help_row + 1),
2567 SetBackgroundColor(bg),
2568 SetForegroundColor(border_color),
2569 Print(format!("└{:─<width$}┘", "", width = modal_width.saturating_sub(2))),
2570 ResetColor,
2571 )?;
2572
2573 // Hide cursor when in modal
2574 execute!(self.stdout, Hide)?;
2575
2576 self.stdout.flush()?;
2577 Ok(())
2578 }
2579
2580 /// Render the command palette modal (Ctrl+P)
2581 pub fn render_command_palette(
2582 &mut self,
2583 query: &str,
2584 commands: &[(String, String, String, String)], // (name, shortcut, category, id)
2585 selected_index: usize,
2586 scroll_offset: usize,
2587 ) -> Result<()> {
2588 let (width, height) = (self.cols as usize, self.rows as usize);
2589
2590 // Modal dimensions - centered at top like VSCode
2591 let modal_width = 60.min(width - 4);
2592 let modal_height = 20.min(height - 4);
2593 let start_col = (width.saturating_sub(modal_width)) / 2;
2594 let start_row = 2; // Near top of screen
2595
2596 // Colors - sleek dark theme
2597 let bg = Color::AnsiValue(236);
2598 let border_color = Color::AnsiValue(240);
2599 let _header_color = Color::Cyan; // reserved for future header styling
2600 let category_color = Color::AnsiValue(243);
2601 let name_color = Color::White;
2602 let shortcut_color = Color::AnsiValue(245);
2603 let selected_bg = Color::AnsiValue(24); // Blue highlight
2604 let selected_name = Color::White;
2605 let input_bg = Color::AnsiValue(238);
2606 let prompt_color = Color::Yellow;
2607
2608 // Draw top border with subtle styling
2609 execute!(
2610 self.stdout,
2611 MoveTo(start_col as u16, start_row as u16),
2612 SetBackgroundColor(bg),
2613 SetForegroundColor(border_color),
2614 Print(format!("╭{:─<width$}╮", "", width = modal_width.saturating_sub(2))),
2615 ResetColor,
2616 )?;
2617
2618 // Draw search input row with > prefix
2619 let display_query = if query.is_empty() { "" } else { query };
2620 let input_display_width = modal_width.saturating_sub(6);
2621 execute!(
2622 self.stdout,
2623 MoveTo(start_col as u16, (start_row + 1) as u16),
2624 SetBackgroundColor(bg),
2625 SetForegroundColor(border_color),
2626 Print("│ "),
2627 SetForegroundColor(prompt_color),
2628 SetAttribute(crossterm::style::Attribute::Bold),
2629 Print(">"),
2630 SetAttribute(crossterm::style::Attribute::Reset),
2631 SetBackgroundColor(input_bg),
2632 SetForegroundColor(Color::White),
2633 Print(format!(" {:<width$}", display_query, width = input_display_width - 1)),
2634 SetBackgroundColor(bg),
2635 SetForegroundColor(border_color),
2636 Print(" │"),
2637 ResetColor,
2638 )?;
2639
2640 // Draw separator
2641 execute!(
2642 self.stdout,
2643 MoveTo(start_col as u16, (start_row + 2) as u16),
2644 SetBackgroundColor(bg),
2645 SetForegroundColor(border_color),
2646 Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2647 ResetColor,
2648 )?;
2649
2650 // Calculate visible range
2651 let visible_rows = modal_height.saturating_sub(5);
2652
2653 // Adjust scroll offset for visibility
2654 let scroll = if selected_index < scroll_offset {
2655 selected_index
2656 } else if selected_index >= scroll_offset + visible_rows {
2657 selected_index - visible_rows + 1
2658 } else {
2659 scroll_offset
2660 };
2661
2662 // Draw commands
2663 for (display_idx, (name, shortcut, category, _id)) in commands.iter().enumerate().skip(scroll).take(visible_rows) {
2664 let row = (start_row + 3 + display_idx - scroll) as u16;
2665 let is_selected = display_idx == selected_index;
2666
2667 let item_bg = if is_selected { selected_bg } else { bg };
2668 let item_name_color = if is_selected { selected_name } else { name_color };
2669
2670 // Format: [Category] Name Shortcut
2671 let category_prefix = if category.is_empty() {
2672 String::new()
2673 } else {
2674 format!("[{}] ", category)
2675 };
2676
2677 let shortcut_display = shortcut.as_str();
2678 let name_width = modal_width.saturating_sub(4 + category_prefix.len() + shortcut_display.len() + 2);
2679
2680 // Truncate name if needed
2681 let display_name = if name.len() > name_width {
2682 format!("{}…", &name[..name_width.saturating_sub(1)])
2683 } else {
2684 name.clone()
2685 };
2686
2687 execute!(
2688 self.stdout,
2689 MoveTo(start_col as u16, row),
2690 SetBackgroundColor(item_bg),
2691 SetForegroundColor(border_color),
2692 Print("│ "),
2693 SetForegroundColor(category_color),
2694 Print(&category_prefix),
2695 SetForegroundColor(item_name_color),
2696 )?;
2697
2698 // Print name with padding
2699 let name_padding = name_width.saturating_sub(display_name.len());
2700 execute!(
2701 self.stdout,
2702 Print(&display_name),
2703 Print(format!("{:width$}", "", width = name_padding)),
2704 SetForegroundColor(shortcut_color),
2705 Print(format!(" {}", shortcut_display)),
2706 SetForegroundColor(border_color),
2707 Print(" │"),
2708 ResetColor,
2709 )?;
2710 }
2711
2712 // Fill remaining rows
2713 let items_drawn = commands.len().saturating_sub(scroll).min(visible_rows);
2714 for i in items_drawn..visible_rows {
2715 let row = (start_row + 3 + i) as u16;
2716 execute!(
2717 self.stdout,
2718 MoveTo(start_col as u16, row),
2719 SetBackgroundColor(bg),
2720 SetForegroundColor(border_color),
2721 Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2722 ResetColor,
2723 )?;
2724 }
2725
2726 // Draw help text row
2727 let help_row = (start_row + 3 + visible_rows) as u16;
2728 let help_text = "↑↓:select Enter:run Esc:close";
2729 let result_count = if commands.is_empty() {
2730 "No matches".to_string()
2731 } else {
2732 format!("{} commands", commands.len())
2733 };
2734 execute!(
2735 self.stdout,
2736 MoveTo(start_col as u16, help_row),
2737 SetBackgroundColor(bg),
2738 SetForegroundColor(border_color),
2739 Print("├"),
2740 SetForegroundColor(Color::AnsiValue(243)),
2741 Print(format!(" {} ", result_count)),
2742 SetForegroundColor(border_color),
2743 Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(result_count.len() + 4))),
2744 Print("┤"),
2745 ResetColor,
2746 )?;
2747
2748 // Draw bottom border
2749 execute!(
2750 self.stdout,
2751 MoveTo(start_col as u16, help_row + 1),
2752 SetBackgroundColor(bg),
2753 SetForegroundColor(border_color),
2754 Print(format!("╰{:─<width$}╯", "", width = modal_width.saturating_sub(2))),
2755 ResetColor,
2756 )?;
2757
2758 // Show help in lighter text at bottom
2759 execute!(
2760 self.stdout,
2761 MoveTo(start_col as u16, help_row + 2),
2762 SetForegroundColor(Color::AnsiValue(243)),
2763 Print(format!("{:^width$}", help_text, width = modal_width)),
2764 ResetColor,
2765 )?;
2766
2767 // Hide cursor when in modal
2768 execute!(self.stdout, Hide)?;
2769
2770 self.stdout.flush()?;
2771 Ok(())
2772 }
2773
2774 /// Render the help menu modal (Shift+F1)
2775 pub fn render_help_menu(
2776 &mut self,
2777 query: &str,
2778 keybinds: &[(String, String, String)], // (shortcut, description, category)
2779 selected_index: usize,
2780 scroll_offset: usize,
2781 show_alt: bool,
2782 ) -> Result<()> {
2783 let (width, height) = (self.cols as usize, self.rows as usize);
2784
2785 // Modal dimensions - larger to show keybindings comfortably
2786 let modal_width = 70.min(width - 4);
2787 let modal_height = 24.min(height - 4);
2788 let start_col = (width.saturating_sub(modal_width)) / 2;
2789 let start_row = 1; // Near top of screen
2790
2791 // Colors - sleek dark theme matching command palette
2792 let bg = Color::AnsiValue(236);
2793 let border_color = Color::AnsiValue(240);
2794 let title_color = Color::Cyan;
2795 let category_color = Color::AnsiValue(243);
2796 let shortcut_color = if show_alt { Color::Magenta } else { Color::Yellow };
2797 let desc_color = Color::White;
2798 let selected_bg = Color::AnsiValue(24); // Blue highlight
2799 let input_bg = Color::AnsiValue(238);
2800
2801 // Draw top border with title (show indicator when viewing alternates)
2802 let title = if show_alt { " Keybindings [/] " } else { " Keybindings " };
2803 let title_padding = (modal_width.saturating_sub(title.len() + 2)) / 2;
2804 execute!(
2805 self.stdout,
2806 MoveTo(start_col as u16, start_row as u16),
2807 SetBackgroundColor(bg),
2808 SetForegroundColor(border_color),
2809 Print("╭"),
2810 Print(format!("{:─<width$}", "", width = title_padding)),
2811 SetForegroundColor(title_color),
2812 SetAttribute(crossterm::style::Attribute::Bold),
2813 Print(title),
2814 SetAttribute(crossterm::style::Attribute::Reset),
2815 SetForegroundColor(border_color),
2816 Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(title_padding + title.len() + 2))),
2817 Print("╮"),
2818 ResetColor,
2819 )?;
2820
2821 // Draw search input row: "│ " + " {query}" + " │" = 2 + 1 + width + 2 = modal_width
2822 let display_query = if query.is_empty() { "Type to filter..." } else { query };
2823 let input_display_width = modal_width.saturating_sub(5);
2824 let placeholder_color = if query.is_empty() { Color::AnsiValue(243) } else { Color::White };
2825 execute!(
2826 self.stdout,
2827 MoveTo(start_col as u16, (start_row + 1) as u16),
2828 SetBackgroundColor(bg),
2829 SetForegroundColor(border_color),
2830 Print("│ "),
2831 SetBackgroundColor(input_bg),
2832 SetForegroundColor(placeholder_color),
2833 Print(format!(" {:<width$}", display_query, width = input_display_width)),
2834 SetBackgroundColor(bg),
2835 SetForegroundColor(border_color),
2836 Print(" │"),
2837 ResetColor,
2838 )?;
2839
2840 // Draw separator
2841 execute!(
2842 self.stdout,
2843 MoveTo(start_col as u16, (start_row + 2) as u16),
2844 SetBackgroundColor(bg),
2845 SetForegroundColor(border_color),
2846 Print(format!("├{:─<width$}┤", "", width = modal_width.saturating_sub(2))),
2847 ResetColor,
2848 )?;
2849
2850 // Calculate visible range
2851 let visible_rows = modal_height.saturating_sub(5);
2852
2853 // Adjust scroll offset for visibility
2854 let scroll = if selected_index < scroll_offset {
2855 selected_index
2856 } else if selected_index >= scroll_offset + visible_rows {
2857 selected_index - visible_rows + 1
2858 } else {
2859 scroll_offset
2860 };
2861
2862 // Draw keybindings
2863 let mut row_offset = 0;
2864 for (idx, (shortcut, description, category)) in keybinds.iter().enumerate().skip(scroll) {
2865 if row_offset >= visible_rows {
2866 break;
2867 }
2868
2869 let row = (start_row + 3 + row_offset) as u16;
2870 let is_selected = idx == selected_index;
2871 let item_bg = if is_selected { selected_bg } else { bg };
2872
2873 // Format: "│ " + shortcut + " " + description + " " + category + " │"
2874 // Widths: 2 + 16 + 1 + desc + 1 + 10 + 2 = 32 + desc = modal_width
2875 let shortcut_width = 16;
2876 let category_width = 10;
2877 let desc_width = modal_width.saturating_sub(shortcut_width + category_width + 6);
2878
2879 // Truncate description if needed
2880 let display_desc = if description.len() > desc_width {
2881 format!("{}…", &description[..desc_width.saturating_sub(1)])
2882 } else {
2883 description.clone()
2884 };
2885
2886 // Truncate shortcut if needed
2887 let display_shortcut = if shortcut.len() > shortcut_width {
2888 format!("{}…", &shortcut[..shortcut_width.saturating_sub(1)])
2889 } else {
2890 shortcut.clone()
2891 };
2892
2893 execute!(
2894 self.stdout,
2895 MoveTo(start_col as u16, row),
2896 SetBackgroundColor(item_bg),
2897 SetForegroundColor(border_color),
2898 Print("│ "),
2899 SetForegroundColor(shortcut_color),
2900 SetAttribute(crossterm::style::Attribute::Bold),
2901 Print(format!("{:<width$}", display_shortcut, width = shortcut_width)),
2902 SetAttribute(crossterm::style::Attribute::Reset),
2903 SetBackgroundColor(item_bg),
2904 SetForegroundColor(desc_color),
2905 Print(format!(" {:<width$}", display_desc, width = desc_width)),
2906 SetForegroundColor(category_color),
2907 Print(format!(" {:>width$}", category, width = category_width)),
2908 SetForegroundColor(border_color),
2909 Print(" │"),
2910 ResetColor,
2911 )?;
2912
2913 row_offset += 1;
2914 }
2915
2916 // Fill remaining rows
2917 for i in row_offset..visible_rows {
2918 let row = (start_row + 3 + i) as u16;
2919 execute!(
2920 self.stdout,
2921 MoveTo(start_col as u16, row),
2922 SetBackgroundColor(bg),
2923 SetForegroundColor(border_color),
2924 Print(format!("│{:width$}│", "", width = modal_width.saturating_sub(2))),
2925 ResetColor,
2926 )?;
2927 }
2928
2929 // Draw info row
2930 let info_row = (start_row + 3 + visible_rows) as u16;
2931 let result_count = if keybinds.is_empty() {
2932 "No matches".to_string()
2933 } else {
2934 format!("{} keybinds", keybinds.len())
2935 };
2936 execute!(
2937 self.stdout,
2938 MoveTo(start_col as u16, info_row),
2939 SetBackgroundColor(bg),
2940 SetForegroundColor(border_color),
2941 Print("├"),
2942 SetForegroundColor(Color::AnsiValue(243)),
2943 Print(format!(" {} ", result_count)),
2944 SetForegroundColor(border_color),
2945 Print(format!("{:─<width$}", "", width = modal_width.saturating_sub(result_count.len() + 4))),
2946 Print("┤"),
2947 ResetColor,
2948 )?;
2949
2950 // Draw bottom border
2951 execute!(
2952 self.stdout,
2953 MoveTo(start_col as u16, info_row + 1),
2954 SetBackgroundColor(bg),
2955 SetForegroundColor(border_color),
2956 Print(format!("╰{:─<width$}╯", "", width = modal_width.saturating_sub(2))),
2957 ResetColor,
2958 )?;
2959
2960 // Show help text below
2961 let help_text = if show_alt {
2962 "↑↓:scroll PgUp/PgDn:page Home/End:jump /:alt binds Esc:close"
2963 } else {
2964 "↑↓:scroll PgUp/PgDn:page Home/End:jump /:alt binds Esc:close"
2965 };
2966 execute!(
2967 self.stdout,
2968 MoveTo(start_col as u16, info_row + 2),
2969 SetForegroundColor(Color::AnsiValue(243)),
2970 Print(format!("{:^width$}", help_text, width = modal_width)),
2971 ResetColor,
2972 )?;
2973
2974 // Hide cursor when in modal
2975 execute!(self.stdout, Hide)?;
2976
2977 self.stdout.flush()?;
2978 Ok(())
2979 }
2980
2981 /// Render the LSP references panel (sidebar style)
2982 pub fn render_references_panel(
2983 &mut self,
2984 locations: &[Location],
2985 selected_index: usize,
2986 query: &str,
2987 workspace_root: &std::path::Path,
2988 ) -> Result<()> {
2989 let (width, height) = (self.cols as usize, self.rows as usize);
2990
2991 // Panel dimensions - sidebar style on the right
2992 let panel_width = 50.min(width / 2);
2993 let panel_height = height.saturating_sub(3); // Leave room for tab bar and status bar
2994 let start_col = width.saturating_sub(panel_width);
2995 let start_row = 1u16; // Below tab bar
2996
2997 // Filter locations based on query
2998 let filtered: Vec<(usize, &Location)> = if query.is_empty() {
2999 locations.iter().enumerate().collect()
3000 } else {
3001 let q = query.to_lowercase();
3002 locations.iter().enumerate()
3003 .filter(|(_, loc)| loc.uri.to_lowercase().contains(&q))
3004 .collect()
3005 };
3006
3007 // Colors
3008 let bg = Color::AnsiValue(235);
3009 let border_color = Color::AnsiValue(244);
3010 let header_color = Color::Cyan;
3011 let file_color = Color::AnsiValue(252);
3012 let line_num_color = Color::AnsiValue(243);
3013 let selected_bg = Color::AnsiValue(240);
3014 let input_bg = Color::AnsiValue(238);
3015
3016 // Draw top border with title
3017 let title = format!(" References ({}) ", filtered.len());
3018 execute!(
3019 self.stdout,
3020 MoveTo(start_col as u16, start_row),
3021 SetBackgroundColor(bg),
3022 SetForegroundColor(border_color),
3023 Print("┌"),
3024 SetForegroundColor(header_color),
3025 Print(&title),
3026 SetForegroundColor(border_color),
3027 Print(format!("{:─<width$}┐", "", width = panel_width.saturating_sub(title.len() + 2))),
3028 ResetColor,
3029 )?;
3030
3031 // Draw filter input row
3032 execute!(
3033 self.stdout,
3034 MoveTo(start_col as u16, start_row + 1),
3035 SetBackgroundColor(bg),
3036 SetForegroundColor(border_color),
3037 Print("│ "),
3038 SetForegroundColor(Color::AnsiValue(248)),
3039 Print("Filter: "),
3040 SetBackgroundColor(input_bg),
3041 SetForegroundColor(Color::White),
3042 Print(format!("{:<width$}", query, width = panel_width.saturating_sub(12))),
3043 SetBackgroundColor(bg),
3044 SetForegroundColor(border_color),
3045 Print("│"),
3046 ResetColor,
3047 )?;
3048
3049 // Draw separator
3050 execute!(
3051 self.stdout,
3052 MoveTo(start_col as u16, start_row + 2),
3053 SetBackgroundColor(bg),
3054 SetForegroundColor(border_color),
3055 Print(format!("├{:─<width$}┤", "", width = panel_width.saturating_sub(2))),
3056 ResetColor,
3057 )?;
3058
3059 // Calculate visible range with scrolling
3060 let visible_rows = panel_height.saturating_sub(5); // Account for borders, title, filter, help
3061 let scroll_offset = if selected_index >= visible_rows {
3062 selected_index - visible_rows + 1
3063 } else {
3064 0
3065 };
3066
3067 // Draw reference items
3068 for (display_idx, (_orig_idx, loc)) in filtered.iter().enumerate().skip(scroll_offset).take(visible_rows) {
3069 let row = start_row + 3 + (display_idx - scroll_offset) as u16;
3070 let is_selected = display_idx == selected_index;
3071
3072 // Extract relative path and line number
3073 let path_str = if loc.uri.starts_with("file://") {
3074 &loc.uri[7..]
3075 } else {
3076 &loc.uri
3077 };
3078
3079 // Make path relative to workspace if possible
3080 let display_path = if let Ok(rel_path) = std::path::Path::new(path_str).strip_prefix(workspace_root) {
3081 rel_path.to_string_lossy().to_string()
3082 } else {
3083 // Just show filename if we can't make it relative
3084 std::path::Path::new(path_str)
3085 .file_name()
3086 .map(|n| n.to_string_lossy().to_string())
3087 .unwrap_or_else(|| path_str.to_string())
3088 };
3089
3090 let line_info = format!(":{}", loc.range.start.line + 1);
3091 let max_path_width = panel_width.saturating_sub(line_info.len() + 4);
3092 let truncated_path = if display_path.len() > max_path_width {
3093 format!("...{}", &display_path[display_path.len().saturating_sub(max_path_width - 3)..])
3094 } else {
3095 display_path
3096 };
3097
3098 let item_bg = if is_selected { selected_bg } else { bg };
3099
3100 // Build a fixed-width line: "│ " + path (padded to max_path_width) + line_info + " │"
3101 // Total: 2 + max_path_width + line_info.len() + 2 = panel_width
3102 // So we need: max_path_width = panel_width - line_info.len() - 4
3103 // The remaining padding goes after line_info
3104 let remaining = panel_width.saturating_sub(max_path_width + line_info.len() + 4);
3105
3106 execute!(
3107 self.stdout,
3108 MoveTo(start_col as u16, row),
3109 SetBackgroundColor(item_bg),
3110 SetForegroundColor(border_color),
3111 Print("│ "),
3112 SetForegroundColor(file_color),
3113 Print(format!("{:<width$}", truncated_path, width = max_path_width)),
3114 SetForegroundColor(line_num_color),
3115 Print(&line_info),
3116 Print(format!("{:width$}", "", width = remaining)),
3117 SetForegroundColor(border_color),
3118 Print(" │"),
3119 ResetColor,
3120 )?;
3121 }
3122
3123 // Fill remaining rows with empty space
3124 let items_drawn = filtered.len().saturating_sub(scroll_offset).min(visible_rows);
3125 for i in items_drawn..visible_rows {
3126 let row = start_row + 3 + i as u16;
3127 execute!(
3128 self.stdout,
3129 MoveTo(start_col as u16, row),
3130 SetBackgroundColor(bg),
3131 SetForegroundColor(border_color),
3132 Print(format!("│{:width$}│", "", width = panel_width.saturating_sub(2))),
3133 ResetColor,
3134 )?;
3135 }
3136
3137 // Draw help text row
3138 let help_row = start_row + 3 + visible_rows as u16;
3139 let help_text = "↑↓:nav Enter:go Esc:close";
3140 execute!(
3141 self.stdout,
3142 MoveTo(start_col as u16, help_row),
3143 SetBackgroundColor(bg),
3144 SetForegroundColor(border_color),
3145 Print("├"),
3146 SetForegroundColor(Color::AnsiValue(243)),
3147 Print(format!(" {:<width$}", help_text, width = panel_width.saturating_sub(3))),
3148 SetForegroundColor(border_color),
3149 Print("┤"),
3150 ResetColor,
3151 )?;
3152
3153 // Draw bottom border
3154 execute!(
3155 self.stdout,
3156 MoveTo(start_col as u16, help_row + 1),
3157 SetBackgroundColor(bg),
3158 SetForegroundColor(border_color),
3159 Print(format!("└{:─<width$}┘", "", width = panel_width.saturating_sub(2))),
3160 ResetColor,
3161 )?;
3162
3163 // Hide cursor when in references panel
3164 execute!(self.stdout, Hide)?;
3165
3166 self.stdout.flush()?;
3167 Ok(())
3168 }
3169
3170 /// Render the LSP server manager panel
3171 pub fn render_server_manager_panel(&mut self, panel: &ServerManagerPanel) -> Result<()> {
3172 if !panel.visible {
3173 return Ok(());
3174 }
3175
3176 let (width, height) = (self.cols, self.rows);
3177 let panel_width = 64.min(width as usize - 4);
3178 let max_visible = 10.min(height as usize - 8);
3179
3180 // Center the panel
3181 let start_col = ((width as usize).saturating_sub(panel_width)) / 2;
3182 let start_row = 2u16;
3183
3184 // Draw confirm dialog if in confirm mode
3185 if panel.confirm_mode {
3186 self.render_server_install_confirm(panel, start_col, start_row + 4)?;
3187 return Ok(());
3188 }
3189
3190 // Draw manual install info dialog
3191 if panel.manual_info_mode {
3192 self.render_manual_install_info(panel, start_col, start_row + 4)?;
3193 return Ok(());
3194 }
3195
3196 // Top border
3197 execute!(
3198 self.stdout,
3199 MoveTo(start_col as u16, start_row),
3200 SetForegroundColor(Color::Cyan),
3201 Print("┌"),
3202 Print("─".repeat(panel_width - 2)),
3203 Print("┐"),
3204 ResetColor
3205 )?;
3206
3207 // Header
3208 execute!(
3209 self.stdout,
3210 MoveTo(start_col as u16, start_row + 1),
3211 SetForegroundColor(Color::Cyan),
3212 Print("│"),
3213 SetForegroundColor(Color::Cyan),
3214 SetAttribute(Attribute::Bold),
3215 Print(" Language Server Manager"),
3216 SetAttribute(Attribute::Reset),
3217 SetForegroundColor(Color::DarkGrey),
3218 )?;
3219 let header_len = 25;
3220 let padding = panel_width - header_len - 7;
3221 execute!(
3222 self.stdout,
3223 Print(" ".repeat(padding)),
3224 Print("Alt+M"),
3225 SetForegroundColor(Color::Cyan),
3226 Print(" │"),
3227 ResetColor
3228 )?;
3229
3230 // Header separator
3231 execute!(
3232 self.stdout,
3233 MoveTo(start_col as u16, start_row + 2),
3234 SetForegroundColor(Color::Cyan),
3235 Print("├"),
3236 Print("─".repeat(panel_width - 2)),
3237 Print("┤"),
3238 ResetColor
3239 )?;
3240
3241 // Server list
3242 let visible_end = (panel.scroll_offset + max_visible).min(panel.servers.len());
3243 for (i, idx) in (panel.scroll_offset..visible_end).enumerate() {
3244 let server = &panel.servers[idx];
3245 let row = start_row + 3 + i as u16;
3246 let is_selected = idx == panel.selected_index;
3247
3248 execute!(
3249 self.stdout,
3250 MoveTo(start_col as u16, row),
3251 SetForegroundColor(Color::Cyan),
3252 Print("│"),
3253 )?;
3254
3255 // Highlight selected row
3256 if is_selected {
3257 execute!(self.stdout, SetAttribute(Attribute::Reverse))?;
3258 }
3259
3260 // Status icon
3261 execute!(self.stdout, Print(" "))?;
3262 if server.is_installed {
3263 execute!(
3264 self.stdout,
3265 SetForegroundColor(Color::Green),
3266 Print("✓"),
3267 )?;
3268 } else {
3269 execute!(
3270 self.stdout,
3271 SetForegroundColor(Color::Red),
3272 Print("✗"),
3273 )?;
3274 }
3275
3276 // Server name and language (or "Installing..." if being installed)
3277 let is_installing = panel.is_installing(idx);
3278 let name_lang = if is_installing {
3279 " Installing...".to_string()
3280 } else {
3281 format!(" {} ({})", server.name, server.language)
3282 };
3283 let name_len = name_lang.len().min(panel_width - 20);
3284 execute!(
3285 self.stdout,
3286 SetForegroundColor(if is_installing { Color::Yellow } else { Color::White }),
3287 Print(&name_lang[..name_len]),
3288 )?;
3289
3290 // Status text
3291 let status = if is_installing {
3292 ""
3293 } else if server.is_installed {
3294 "installed"
3295 } else if server.install_cmd.starts_with('#') {
3296 "manual"
3297 } else {
3298 "Enter to install"
3299 };
3300 // Content width is panel_width - 2 (for the two │ borders)
3301 // We've printed: 1 space + 1 icon + name_len chars
3302 // We need to print: status + 1 trailing space before │
3303 let used = 1 + 1 + name_len + status.len() + 1;
3304 let content_width = panel_width - 2;
3305 let status_padding = content_width.saturating_sub(used);
3306 execute!(self.stdout, Print(" ".repeat(status_padding)))?;
3307
3308 if server.is_installed {
3309 execute!(
3310 self.stdout,
3311 SetForegroundColor(Color::DarkGrey),
3312 Print(status),
3313 )?;
3314 } else {
3315 execute!(
3316 self.stdout,
3317 SetForegroundColor(Color::Yellow),
3318 Print(status),
3319 )?;
3320 }
3321
3322 if is_selected {
3323 execute!(self.stdout, SetAttribute(Attribute::Reset))?;
3324 }
3325
3326 execute!(
3327 self.stdout,
3328 Print(" "),
3329 SetForegroundColor(Color::Cyan),
3330 Print("│"),
3331 ResetColor
3332 )?;
3333 }
3334
3335 // Fill remaining rows
3336 for i in (visible_end - panel.scroll_offset)..max_visible {
3337 let row = start_row + 3 + i as u16;
3338 execute!(
3339 self.stdout,
3340 MoveTo(start_col as u16, row),
3341 SetForegroundColor(Color::Cyan),
3342 Print("│"),
3343 Print(" ".repeat(panel_width - 2)),
3344 Print("│"),
3345 ResetColor
3346 )?;
3347 }
3348
3349 // Footer separator
3350 let footer_row = start_row + 3 + max_visible as u16;
3351 execute!(
3352 self.stdout,
3353 MoveTo(start_col as u16, footer_row),
3354 SetForegroundColor(Color::Cyan),
3355 Print("├"),
3356 Print("─".repeat(panel_width - 2)),
3357 Print("┤"),
3358 ResetColor
3359 )?;
3360
3361 // Status or help
3362 execute!(
3363 self.stdout,
3364 MoveTo(start_col as u16, footer_row + 1),
3365 SetForegroundColor(Color::Cyan),
3366 Print("│"),
3367 )?;
3368
3369 if let Some(ref msg) = panel.status_message {
3370 let content_width = panel_width - 2;
3371 let msg_width = msg.width();
3372 // Truncate if needed (simple truncation, could be smarter)
3373 let msg_display = if msg_width > content_width - 2 {
3374 // Find a safe truncation point
3375 let mut truncated = String::new();
3376 let mut w = 0;
3377 for c in msg.chars() {
3378 let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
3379 if w + cw > content_width - 5 {
3380 break;
3381 }
3382 truncated.push(c);
3383 w += cw;
3384 }
3385 truncated.push_str("...");
3386 truncated
3387 } else {
3388 msg.clone()
3389 };
3390 let display_width = msg_display.width();
3391 execute!(
3392 self.stdout,
3393 SetForegroundColor(Color::Yellow),
3394 Print(format!(" {}", msg_display)),
3395 )?;
3396 // We printed 1 space + msg_display, need to fill to content_width
3397 let pad = content_width.saturating_sub(1 + display_width);
3398 execute!(self.stdout, Print(" ".repeat(pad)))?;
3399 } else {
3400 let help_text = " ↑↓ Navigate Enter Install r Refresh Esc Close ";
3401 let help_width = help_text.width();
3402 execute!(
3403 self.stdout,
3404 SetForegroundColor(Color::DarkGrey),
3405 Print(help_text),
3406 )?;
3407 // Content width is panel_width - 2 (for borders)
3408 let content_width = panel_width - 2;
3409 let pad = content_width.saturating_sub(help_width);
3410 execute!(self.stdout, Print(" ".repeat(pad)))?;
3411 }
3412
3413 execute!(
3414 self.stdout,
3415 SetForegroundColor(Color::Cyan),
3416 Print("│"),
3417 ResetColor
3418 )?;
3419
3420 // Bottom border
3421 execute!(
3422 self.stdout,
3423 MoveTo(start_col as u16, footer_row + 2),
3424 SetForegroundColor(Color::Cyan),
3425 Print("└"),
3426 Print("─".repeat(panel_width - 2)),
3427 Print("┘"),
3428 ResetColor
3429 )?;
3430
3431 Ok(())
3432 }
3433
3434 /// Render the install confirmation dialog
3435 fn render_server_install_confirm(
3436 &mut self,
3437 panel: &ServerManagerPanel,
3438 start_col: usize,
3439 start_row: u16,
3440 ) -> Result<()> {
3441 let panel_width = 60;
3442
3443 let server = match panel.confirm_server() {
3444 Some(s) => s,
3445 None => return Ok(()),
3446 };
3447
3448 // Top border
3449 execute!(
3450 self.stdout,
3451 MoveTo(start_col as u16, start_row),
3452 SetForegroundColor(Color::Cyan),
3453 Print("┌"),
3454 Print("─".repeat(panel_width - 2)),
3455 Print("┐"),
3456 ResetColor
3457 )?;
3458
3459 // Title
3460 let title = format!(" Install {}? ", server.name);
3461 execute!(
3462 self.stdout,
3463 MoveTo(start_col as u16, start_row + 1),
3464 SetForegroundColor(Color::Cyan),
3465 Print("│"),
3466 SetAttribute(Attribute::Bold),
3467 Print(&title),
3468 SetAttribute(Attribute::Reset),
3469 )?;
3470 let pad = panel_width - 2 - title.len();
3471 execute!(
3472 self.stdout,
3473 Print(" ".repeat(pad)),
3474 SetForegroundColor(Color::Cyan),
3475 Print("│"),
3476 ResetColor
3477 )?;
3478
3479 // Blank line
3480 execute!(
3481 self.stdout,
3482 MoveTo(start_col as u16, start_row + 2),
3483 SetForegroundColor(Color::Cyan),
3484 Print("│"),
3485 Print(" ".repeat(panel_width - 2)),
3486 Print("│"),
3487 ResetColor
3488 )?;
3489
3490 // Command
3491 let cmd_display = if server.install_cmd.len() > panel_width - 14 {
3492 format!("{}...", &server.install_cmd[..panel_width - 17])
3493 } else {
3494 server.install_cmd.to_string()
3495 };
3496 execute!(
3497 self.stdout,
3498 MoveTo(start_col as u16, start_row + 3),
3499 SetForegroundColor(Color::Cyan),
3500 Print("│"),
3501 SetForegroundColor(Color::White),
3502 Print(" Command: "),
3503 SetForegroundColor(Color::Yellow),
3504 Print(&cmd_display),
3505 )?;
3506 let pad = panel_width - 12 - cmd_display.len();
3507 execute!(
3508 self.stdout,
3509 Print(" ".repeat(pad)),
3510 SetForegroundColor(Color::Cyan),
3511 Print("│"),
3512 ResetColor
3513 )?;
3514
3515 // Blank line
3516 execute!(
3517 self.stdout,
3518 MoveTo(start_col as u16, start_row + 4),
3519 SetForegroundColor(Color::Cyan),
3520 Print("│"),
3521 Print(" ".repeat(panel_width - 2)),
3522 Print("│"),
3523 ResetColor
3524 )?;
3525
3526 // Buttons
3527 execute!(
3528 self.stdout,
3529 MoveTo(start_col as u16, start_row + 5),
3530 SetForegroundColor(Color::Cyan),
3531 Print("│"),
3532 )?;
3533 let button_text = "[Y]es [N]o";
3534 let button_pad = (panel_width - 2 - button_text.len()) / 2;
3535 execute!(
3536 self.stdout,
3537 Print(" ".repeat(button_pad)),
3538 Print("["),
3539 SetForegroundColor(Color::Green),
3540 Print("Y"),
3541 SetForegroundColor(Color::White),
3542 Print("]es ["),
3543 SetForegroundColor(Color::Red),
3544 Print("N"),
3545 SetForegroundColor(Color::White),
3546 Print("]o"),
3547 Print(" ".repeat(panel_width - 2 - button_pad - button_text.len())),
3548 SetForegroundColor(Color::Cyan),
3549 Print("│"),
3550 ResetColor
3551 )?;
3552
3553 // Bottom border
3554 execute!(
3555 self.stdout,
3556 MoveTo(start_col as u16, start_row + 6),
3557 SetForegroundColor(Color::Cyan),
3558 Print("└"),
3559 Print("─".repeat(panel_width - 2)),
3560 Print("┘"),
3561 ResetColor
3562 )?;
3563
3564 Ok(())
3565 }
3566
3567 /// Render the manual install info dialog
3568 fn render_manual_install_info(
3569 &mut self,
3570 panel: &ServerManagerPanel,
3571 start_col: usize,
3572 start_row: u16,
3573 ) -> Result<()> {
3574 let panel_width = 60;
3575
3576 let server = match panel.manual_info_server() {
3577 Some(s) => s,
3578 None => return Ok(()),
3579 };
3580
3581 // Parse the install instructions (remove leading #)
3582 let instructions = server.install_cmd.trim_start_matches('#').trim();
3583
3584 // Top border
3585 execute!(
3586 self.stdout,
3587 MoveTo(start_col as u16, start_row),
3588 SetForegroundColor(Color::Cyan),
3589 Print("┌"),
3590 Print("─".repeat(panel_width - 2)),
3591 Print("┐"),
3592 ResetColor
3593 )?;
3594
3595 // Title
3596 let title = format!(" {} - Manual Installation ", server.name);
3597 execute!(
3598 self.stdout,
3599 MoveTo(start_col as u16, start_row + 1),
3600 SetForegroundColor(Color::Cyan),
3601 Print("│"),
3602 SetAttribute(Attribute::Bold),
3603 SetForegroundColor(Color::Yellow),
3604 Print(&title),
3605 SetAttribute(Attribute::Reset),
3606 )?;
3607 let pad = panel_width - 2 - title.len();
3608 execute!(
3609 self.stdout,
3610 Print(" ".repeat(pad)),
3611 SetForegroundColor(Color::Cyan),
3612 Print("│"),
3613 ResetColor
3614 )?;
3615
3616 // Separator
3617 execute!(
3618 self.stdout,
3619 MoveTo(start_col as u16, start_row + 2),
3620 SetForegroundColor(Color::Cyan),
3621 Print("├"),
3622 Print("─".repeat(panel_width - 2)),
3623 Print("┤"),
3624 ResetColor
3625 )?;
3626
3627 // Language
3628 execute!(
3629 self.stdout,
3630 MoveTo(start_col as u16, start_row + 3),
3631 SetForegroundColor(Color::Cyan),
3632 Print("│"),
3633 SetForegroundColor(Color::White),
3634 Print(" Language: "),
3635 SetForegroundColor(Color::Green),
3636 Print(server.language),
3637 )?;
3638 let lang_pad = panel_width - 13 - server.language.len();
3639 execute!(
3640 self.stdout,
3641 Print(" ".repeat(lang_pad)),
3642 SetForegroundColor(Color::Cyan),
3643 Print("│"),
3644 ResetColor
3645 )?;
3646
3647 // Blank line
3648 execute!(
3649 self.stdout,
3650 MoveTo(start_col as u16, start_row + 4),
3651 SetForegroundColor(Color::Cyan),
3652 Print("│"),
3653 Print(" ".repeat(panel_width - 2)),
3654 Print("│"),
3655 ResetColor
3656 )?;
3657
3658 // Instructions label
3659 execute!(
3660 self.stdout,
3661 MoveTo(start_col as u16, start_row + 5),
3662 SetForegroundColor(Color::Cyan),
3663 Print("│"),
3664 SetForegroundColor(Color::White),
3665 Print(" Installation:"),
3666 )?;
3667 execute!(
3668 self.stdout,
3669 Print(" ".repeat(panel_width - 16)),
3670 SetForegroundColor(Color::Cyan),
3671 Print("│"),
3672 ResetColor
3673 )?;
3674
3675 // Instructions text (may be multi-line, show up to 3 lines)
3676 let instr_lines: Vec<&str> = instructions.lines().collect();
3677 for (i, line) in instr_lines.iter().take(3).enumerate() {
3678 let row = start_row + 6 + i as u16;
3679 let display_line = if line.len() > panel_width - 6 {
3680 format!("{}...", &line[..panel_width - 9])
3681 } else {
3682 line.to_string()
3683 };
3684 execute!(
3685 self.stdout,
3686 MoveTo(start_col as u16, row),
3687 SetForegroundColor(Color::Cyan),
3688 Print("│"),
3689 SetForegroundColor(Color::Yellow),
3690 Print(format!(" {}", display_line)),
3691 )?;
3692 let line_pad = panel_width - 5 - display_line.len();
3693 execute!(
3694 self.stdout,
3695 Print(" ".repeat(line_pad)),
3696 SetForegroundColor(Color::Cyan),
3697 Print("│"),
3698 ResetColor
3699 )?;
3700 }
3701
3702 // Fill remaining instruction lines if less than 3
3703 for i in instr_lines.len()..3 {
3704 let row = start_row + 6 + i as u16;
3705 execute!(
3706 self.stdout,
3707 MoveTo(start_col as u16, row),
3708 SetForegroundColor(Color::Cyan),
3709 Print("│"),
3710 Print(" ".repeat(panel_width - 2)),
3711 Print("│"),
3712 ResetColor
3713 )?;
3714 }
3715
3716 // Blank line
3717 execute!(
3718 self.stdout,
3719 MoveTo(start_col as u16, start_row + 9),
3720 SetForegroundColor(Color::Cyan),
3721 Print("│"),
3722 Print(" ".repeat(panel_width - 2)),
3723 Print("│"),
3724 ResetColor
3725 )?;
3726
3727 // Status or help line
3728 execute!(
3729 self.stdout,
3730 MoveTo(start_col as u16, start_row + 10),
3731 SetForegroundColor(Color::Cyan),
3732 Print("│"),
3733 )?;
3734
3735 if panel.copied_to_clipboard {
3736 execute!(
3737 self.stdout,
3738 SetForegroundColor(Color::Green),
3739 Print(" ✓ Copied to clipboard!"),
3740 )?;
3741 execute!(self.stdout, Print(" ".repeat(panel_width - 26)))?;
3742 } else {
3743 execute!(
3744 self.stdout,
3745 SetForegroundColor(Color::DarkGrey),
3746 Print(" [C] Copy to clipboard [Esc] Close"),
3747 )?;
3748 execute!(self.stdout, Print(" ".repeat(panel_width - 38)))?;
3749 }
3750
3751 execute!(
3752 self.stdout,
3753 SetForegroundColor(Color::Cyan),
3754 Print("│"),
3755 ResetColor
3756 )?;
3757
3758 // Bottom border
3759 execute!(
3760 self.stdout,
3761 MoveTo(start_col as u16, start_row + 11),
3762 SetForegroundColor(Color::Cyan),
3763 Print("└"),
3764 Print("─".repeat(panel_width - 2)),
3765 Print("┘"),
3766 ResetColor
3767 )?;
3768
3769 Ok(())
3770 }
3771
3772 /// Render the integrated terminal panel
3773 pub fn render_terminal(&mut self, terminal: &TerminalPanel, left_offset: u16) -> Result<()> {
3774 // Hide cursor during render to prevent flicker
3775 execute!(self.stdout, Hide)?;
3776
3777 let start_row = terminal.render_start_row(self.rows);
3778 let height = terminal.height;
3779 let terminal_width = self.cols.saturating_sub(left_offset) as usize;
3780
3781 // Draw terminal border (top line with title)
3782 execute!(
3783 self.stdout,
3784 MoveTo(left_offset, start_row),
3785 SetBackgroundColor(Color::AnsiValue(237)),
3786 SetForegroundColor(Color::White),
3787 )?;
3788
3789 // Terminal title bar with tabs
3790 let session_count = terminal.session_count();
3791 let active_idx = terminal.active_session_index();
3792
3793 if session_count <= 1 {
3794 // Single session: show CWD or "Terminal" centered
3795 let name = terminal.active_cwd()
3796 .map(|p| extract_dirname(p))
3797 .unwrap_or_else(|| "Terminal".to_string());
3798 let title = format!(" {} ", name);
3799 let separator = "─".repeat(terminal_width.saturating_sub(title.len() + 2) / 2);
3800 execute!(
3801 self.stdout,
3802 Print(&separator),
3803 SetAttribute(Attribute::Bold),
3804 Print(&title),
3805 SetAttribute(Attribute::Reset),
3806 SetBackgroundColor(Color::AnsiValue(237)),
3807 SetForegroundColor(Color::White),
3808 Print(&separator),
3809 )?;
3810
3811 // Pad to end of line
3812 let printed = separator.chars().count() * 2 + title.len();
3813 if printed < terminal_width {
3814 execute!(self.stdout, Print(" ".repeat(terminal_width - printed)))?;
3815 }
3816 } else {
3817 // Multiple sessions: render tab bar
3818 let sessions = terminal.sessions();
3819 let available_width = terminal_width;
3820 let tab_width = (available_width / session_count).max(8).min(25);
3821
3822 let mut printed = 0;
3823 for (i, session) in sessions.iter().enumerate() {
3824 let is_active = i == active_idx;
3825 let name = session.cwd()
3826 .map(|p| extract_dirname(p))
3827 .unwrap_or_else(|| format!("Term {}", i + 1));
3828
3829 // Format: "[n] name" with truncation
3830 let prefix = format!("{} ", i + 1);
3831 let max_name_len = tab_width.saturating_sub(prefix.len() + 1);
3832 let display_name = if name.len() > max_name_len {
3833 format!("{}…", &name[..max_name_len.saturating_sub(1)])
3834 } else {
3835 name
3836 };
3837 let tab_content = format!("{}{}", prefix, display_name);
3838
3839 // Set colors based on active state
3840 if is_active {
3841 execute!(
3842 self.stdout,
3843 SetBackgroundColor(Color::AnsiValue(238)),
3844 SetForegroundColor(Color::White),
3845 SetAttribute(Attribute::Bold),
3846 )?;
3847 } else {
3848 execute!(
3849 self.stdout,
3850 SetBackgroundColor(Color::AnsiValue(235)),
3851 SetForegroundColor(Color::AnsiValue(245)),
3852 SetAttribute(Attribute::Reset),
3853 )?;
3854 }
3855
3856 // Print tab with padding
3857 let padding = tab_width.saturating_sub(tab_content.len());
3858 let left_pad = padding / 2;
3859 let right_pad = padding - left_pad;
3860 execute!(
3861 self.stdout,
3862 Print(" ".repeat(left_pad)),
3863 Print(&tab_content),
3864 Print(" ".repeat(right_pad)),
3865 )?;
3866 printed += tab_width;
3867
3868 // Separator between tabs
3869 if i < session_count - 1 {
3870 execute!(
3871 self.stdout,
3872 SetBackgroundColor(Color::AnsiValue(237)),
3873 SetForegroundColor(Color::AnsiValue(240)),
3874 SetAttribute(Attribute::Reset),
3875 Print("│"),
3876 )?;
3877 printed += 1;
3878 }
3879 }
3880
3881 // Fill remaining space
3882 if printed < available_width {
3883 execute!(
3884 self.stdout,
3885 SetBackgroundColor(Color::AnsiValue(237)),
3886 SetForegroundColor(Color::White),
3887 SetAttribute(Attribute::Reset),
3888 Print(" ".repeat(available_width - printed)),
3889 )?;
3890 }
3891 }
3892
3893 // Terminal content area - use batched rendering to reduce flicker
3894 let (cursor_row, cursor_col) = terminal.cursor_pos();
3895 let default_bg = Color::AnsiValue(232);
3896 let default_fg = Color::White;
3897
3898 // Track current colors to avoid redundant escape sequences
3899 let mut current_fg = default_fg;
3900 let mut current_bg = default_bg;
3901 let mut current_bold = false;
3902 let mut current_underline = false;
3903
3904 // Set initial colors
3905 execute!(
3906 self.stdout,
3907 SetBackgroundColor(default_bg),
3908 SetForegroundColor(default_fg)
3909 )?;
3910
3911 for row in 0..(height - 1) {
3912 execute!(self.stdout, MoveTo(left_offset, start_row + 1 + row))?;
3913
3914 // Build a string of characters with same attributes to batch print
3915 let mut batch = String::new();
3916 let mut batch_fg = current_fg;
3917 let mut batch_bg = current_bg;
3918 let mut batch_bold = current_bold;
3919 let mut batch_underline = current_underline;
3920
3921 for col in 0..terminal_width {
3922 let (c, fg, bg, bold, underline) = if let Some(cell) = terminal.get_cell(row as usize, col) {
3923 let (fg, bg) = if cell.inverse {
3924 let fg = TerminalPanel::to_crossterm_color(&cell.bg);
3925 let bg = TerminalPanel::to_crossterm_color(&cell.fg);
3926 (
3927 if fg == Color::Reset { default_bg } else { fg },
3928 if bg == Color::Reset { default_fg } else { bg },
3929 )
3930 } else {
3931 let fg = TerminalPanel::to_crossterm_color(&cell.fg);
3932 let bg = TerminalPanel::to_crossterm_color(&cell.bg);
3933 (
3934 if fg == Color::Reset { default_fg } else { fg },
3935 if bg == Color::Reset { default_bg } else { bg },
3936 )
3937 };
3938 (cell.c, fg, bg, cell.bold, cell.underline)
3939 } else {
3940 (' ', default_fg, default_bg, false, false)
3941 };
3942
3943 // Check if attributes changed
3944 if fg != batch_fg || bg != batch_bg || bold != batch_bold || underline != batch_underline {
3945 // Flush current batch
3946 if !batch.is_empty() {
3947 // Apply batch attributes if different from current
3948 if batch_fg != current_fg {
3949 execute!(self.stdout, SetForegroundColor(batch_fg))?;
3950 current_fg = batch_fg;
3951 }
3952 if batch_bg != current_bg {
3953 execute!(self.stdout, SetBackgroundColor(batch_bg))?;
3954 current_bg = batch_bg;
3955 }
3956 if batch_bold != current_bold {
3957 if batch_bold {
3958 execute!(self.stdout, SetAttribute(Attribute::Bold))?;
3959 } else {
3960 execute!(self.stdout, SetAttribute(Attribute::NoBold))?;
3961 }
3962 current_bold = batch_bold;
3963 }
3964 if batch_underline != current_underline {
3965 if batch_underline {
3966 execute!(self.stdout, SetAttribute(Attribute::Underlined))?;
3967 } else {
3968 execute!(self.stdout, SetAttribute(Attribute::NoUnderline))?;
3969 }
3970 current_underline = batch_underline;
3971 }
3972 execute!(self.stdout, Print(&batch))?;
3973 batch.clear();
3974 }
3975 batch_fg = fg;
3976 batch_bg = bg;
3977 batch_bold = bold;
3978 batch_underline = underline;
3979 }
3980 batch.push(c);
3981 }
3982
3983 // Flush remaining batch for this row
3984 if !batch.is_empty() {
3985 if batch_fg != current_fg {
3986 execute!(self.stdout, SetForegroundColor(batch_fg))?;
3987 current_fg = batch_fg;
3988 }
3989 if batch_bg != current_bg {
3990 execute!(self.stdout, SetBackgroundColor(batch_bg))?;
3991 current_bg = batch_bg;
3992 }
3993 if batch_bold != current_bold {
3994 if batch_bold {
3995 execute!(self.stdout, SetAttribute(Attribute::Bold))?;
3996 } else {
3997 execute!(self.stdout, SetAttribute(Attribute::NoBold))?;
3998 }
3999 current_bold = batch_bold;
4000 }
4001 if batch_underline != current_underline {
4002 if batch_underline {
4003 execute!(self.stdout, SetAttribute(Attribute::Underlined))?;
4004 } else {
4005 execute!(self.stdout, SetAttribute(Attribute::NoUnderline))?;
4006 }
4007 current_underline = batch_underline;
4008 }
4009 execute!(self.stdout, Print(&batch))?;
4010 }
4011 }
4012
4013 // Position cursor in terminal (offset by left_offset)
4014 execute!(
4015 self.stdout,
4016 MoveTo(left_offset + cursor_col, start_row + 1 + cursor_row),
4017 Show,
4018 ResetColor
4019 )?;
4020
4021 Ok(())
4022 }
4023 }
4024