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