| 1 | use anyhow::Result; |
| 2 | use crossterm::{ |
| 3 | cursor::{Hide, MoveTo, Show}, |
| 4 | event::{ |
| 5 | DisableMouseCapture, EnableMouseCapture, |
| 6 | KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, |
| 7 | }, |
| 8 | execute, |
| 9 | style::{Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor}, |
| 10 | terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, |
| 11 | }; |
| 12 | use std::io::{stdout, Stdout, Write}; |
| 13 | |
| 14 | use crate::buffer::Buffer; |
| 15 | use crate::editor::{Cursors, Position}; |
| 16 | |
| 17 | // Editor color scheme (256-color palette) |
| 18 | const BG_COLOR: Color = Color::AnsiValue(234); // Off-black editor background |
| 19 | const CURRENT_LINE_BG: Color = Color::AnsiValue(236); // Slightly lighter for current line |
| 20 | const LINE_NUM_COLOR: Color = Color::AnsiValue(243); // Gray for line numbers |
| 21 | const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow; // Yellow for active line number |
| 22 | const BRACKET_MATCH_BG: Color = Color::AnsiValue(240); // Highlight for matching brackets |
| 23 | // Secondary cursors use Color::Magenta for visibility |
| 24 | |
| 25 | /// Terminal screen renderer |
| 26 | pub struct Screen { |
| 27 | stdout: Stdout, |
| 28 | pub rows: u16, |
| 29 | pub cols: u16, |
| 30 | keyboard_enhanced: bool, |
| 31 | } |
| 32 | |
| 33 | impl Screen { |
| 34 | pub fn new() -> Result<Self> { |
| 35 | let (cols, rows) = terminal::size()?; |
| 36 | Ok(Self { |
| 37 | stdout: stdout(), |
| 38 | rows, |
| 39 | cols, |
| 40 | keyboard_enhanced: false, |
| 41 | }) |
| 42 | } |
| 43 | |
| 44 | pub fn enter_raw_mode(&mut self) -> Result<()> { |
| 45 | terminal::enable_raw_mode()?; |
| 46 | execute!(self.stdout, EnterAlternateScreen, Hide, EnableMouseCapture)?; |
| 47 | |
| 48 | // Try to enable keyboard enhancement for better modifier key detection |
| 49 | // This enables the kitty keyboard protocol on supporting terminals |
| 50 | if execute!( |
| 51 | self.stdout, |
| 52 | PushKeyboardEnhancementFlags( |
| 53 | KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES |
| 54 | | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES |
| 55 | ) |
| 56 | ) |
| 57 | .is_ok() |
| 58 | { |
| 59 | self.keyboard_enhanced = true; |
| 60 | } |
| 61 | |
| 62 | Ok(()) |
| 63 | } |
| 64 | |
| 65 | pub fn leave_raw_mode(&mut self) -> Result<()> { |
| 66 | if self.keyboard_enhanced { |
| 67 | let _ = execute!(self.stdout, PopKeyboardEnhancementFlags); |
| 68 | } |
| 69 | execute!(self.stdout, Show, DisableMouseCapture, LeaveAlternateScreen)?; |
| 70 | terminal::disable_raw_mode()?; |
| 71 | Ok(()) |
| 72 | } |
| 73 | |
| 74 | pub fn refresh_size(&mut self) -> Result<()> { |
| 75 | let (cols, rows) = terminal::size()?; |
| 76 | self.cols = cols; |
| 77 | self.rows = rows; |
| 78 | Ok(()) |
| 79 | } |
| 80 | |
| 81 | #[allow(dead_code)] |
| 82 | pub fn clear(&mut self) -> Result<()> { |
| 83 | execute!(self.stdout, Clear(ClearType::All))?; |
| 84 | Ok(()) |
| 85 | } |
| 86 | |
| 87 | /// Render the editor view |
| 88 | pub fn render( |
| 89 | &mut self, |
| 90 | buffer: &Buffer, |
| 91 | cursors: &Cursors, |
| 92 | viewport_line: usize, |
| 93 | filename: Option<&str>, |
| 94 | message: Option<&str>, |
| 95 | bracket_match: Option<(usize, usize)>, |
| 96 | ) -> Result<()> { |
| 97 | // Hide cursor during render to prevent flicker |
| 98 | execute!(self.stdout, Hide)?; |
| 99 | |
| 100 | let line_num_width = self.line_number_width(buffer.line_count()); |
| 101 | let text_cols = self.cols as usize - line_num_width - 1; |
| 102 | |
| 103 | // Get primary cursor for current line highlighting |
| 104 | let primary = cursors.primary(); |
| 105 | |
| 106 | // Collect all selections from all cursors |
| 107 | let selections: Vec<(Position, Position)> = cursors.all() |
| 108 | .iter() |
| 109 | .filter_map(|c| c.selection_bounds()) |
| 110 | .collect(); |
| 111 | |
| 112 | // Collect all cursor positions for rendering |
| 113 | let primary_idx = cursors.primary_index(); |
| 114 | let cursor_positions: Vec<(usize, usize, bool)> = cursors.all() |
| 115 | .iter() |
| 116 | .enumerate() |
| 117 | .map(|(i, c)| (c.line, c.col, i == primary_idx)) // (line, col, is_primary) |
| 118 | .collect(); |
| 119 | |
| 120 | // Reserve 1 row for status bar |
| 121 | let text_rows = self.rows.saturating_sub(1) as usize; |
| 122 | |
| 123 | // Draw text area |
| 124 | for row in 0..text_rows { |
| 125 | let line_idx = viewport_line + row; |
| 126 | let is_current_line = line_idx == primary.line; |
| 127 | execute!(self.stdout, MoveTo(0, row as u16))?; |
| 128 | |
| 129 | if line_idx < buffer.line_count() { |
| 130 | // Line number with appropriate color |
| 131 | let line_num_fg = if is_current_line { |
| 132 | CURRENT_LINE_NUM_COLOR |
| 133 | } else { |
| 134 | LINE_NUM_COLOR |
| 135 | }; |
| 136 | let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR }; |
| 137 | |
| 138 | execute!( |
| 139 | self.stdout, |
| 140 | SetBackgroundColor(line_bg), |
| 141 | SetForegroundColor(line_num_fg), |
| 142 | Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)), |
| 143 | )?; |
| 144 | |
| 145 | // Line content with selection and cursor highlighting |
| 146 | if let Some(line) = buffer.line_str(line_idx) { |
| 147 | // Check if bracket match is on this line |
| 148 | let bracket_col = bracket_match |
| 149 | .filter(|(bl, _)| *bl == line_idx) |
| 150 | .map(|(_, bc)| bc); |
| 151 | |
| 152 | // Get cursors on this line (excluding primary which uses hardware cursor) |
| 153 | let secondary_cursors: Vec<usize> = cursor_positions.iter() |
| 154 | .filter(|(l, _, is_primary)| *l == line_idx && !*is_primary) |
| 155 | .map(|(_, c, _)| *c) |
| 156 | .collect(); |
| 157 | |
| 158 | self.render_line_with_cursors( |
| 159 | &line, |
| 160 | line_idx, |
| 161 | text_cols, |
| 162 | &selections, |
| 163 | is_current_line, |
| 164 | bracket_col, |
| 165 | &secondary_cursors, |
| 166 | )?; |
| 167 | } |
| 168 | |
| 169 | // Fill rest of line with background color |
| 170 | execute!( |
| 171 | self.stdout, |
| 172 | SetBackgroundColor(line_bg), |
| 173 | Clear(ClearType::UntilNewLine), |
| 174 | ResetColor |
| 175 | )?; |
| 176 | } else { |
| 177 | // Empty line indicator |
| 178 | execute!( |
| 179 | self.stdout, |
| 180 | SetBackgroundColor(BG_COLOR), |
| 181 | SetForegroundColor(Color::DarkBlue), |
| 182 | Print(format!("{:>width$} ", "~", width = line_num_width)), |
| 183 | Clear(ClearType::UntilNewLine), |
| 184 | ResetColor |
| 185 | )?; |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | // Status bar |
| 190 | self.render_status_bar(buffer, cursors, filename, message)?; |
| 191 | |
| 192 | // Position hardware cursor at primary cursor |
| 193 | let cursor_row = primary.line.saturating_sub(viewport_line); |
| 194 | let cursor_col = line_num_width + 1 + primary.col; |
| 195 | execute!( |
| 196 | self.stdout, |
| 197 | MoveTo(cursor_col as u16, cursor_row as u16), |
| 198 | Show |
| 199 | )?; |
| 200 | |
| 201 | self.stdout.flush()?; |
| 202 | Ok(()) |
| 203 | } |
| 204 | |
| 205 | fn render_line_with_cursors( |
| 206 | &mut self, |
| 207 | line: &str, |
| 208 | line_idx: usize, |
| 209 | max_cols: usize, |
| 210 | selections: &[(Position, Position)], |
| 211 | is_current_line: bool, |
| 212 | bracket_col: Option<usize>, |
| 213 | secondary_cursors: &[usize], |
| 214 | ) -> Result<()> { |
| 215 | let chars: Vec<char> = line.chars().take(max_cols).collect(); |
| 216 | let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR }; |
| 217 | let default_fg = Color::Reset; // Default terminal foreground |
| 218 | |
| 219 | // Build selection ranges for this line from all selections |
| 220 | let mut sel_ranges: Vec<(usize, usize)> = Vec::new(); |
| 221 | for (start, end) in selections { |
| 222 | if line_idx >= start.line && line_idx <= end.line { |
| 223 | let s = if line_idx == start.line { start.col } else { 0 }; |
| 224 | let e = if line_idx == end.line { end.col } else { chars.len() }; |
| 225 | if s < e { |
| 226 | sel_ranges.push((s, e)); |
| 227 | } |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // Render character by character for precise highlighting |
| 232 | for (col, ch) in chars.iter().enumerate() { |
| 233 | let in_selection = sel_ranges.iter().any(|(s, e)| col >= *s && col < *e); |
| 234 | let is_bracket_match = bracket_col == Some(col); |
| 235 | let is_secondary_cursor = secondary_cursors.contains(&col); |
| 236 | |
| 237 | // Determine background color |
| 238 | let bg = if in_selection { |
| 239 | Color::Blue |
| 240 | } else if is_secondary_cursor { |
| 241 | Color::Magenta // Use magenta for better visibility |
| 242 | } else if is_bracket_match { |
| 243 | BRACKET_MATCH_BG |
| 244 | } else { |
| 245 | line_bg |
| 246 | }; |
| 247 | |
| 248 | // Determine foreground color |
| 249 | let fg = if in_selection { |
| 250 | Color::White |
| 251 | } else if is_secondary_cursor { |
| 252 | Color::White // White text on magenta bg |
| 253 | } else { |
| 254 | default_fg |
| 255 | }; |
| 256 | |
| 257 | execute!( |
| 258 | self.stdout, |
| 259 | SetBackgroundColor(bg), |
| 260 | SetForegroundColor(fg), |
| 261 | Print(ch) |
| 262 | )?; |
| 263 | } |
| 264 | |
| 265 | // Reset to line background for rest of line |
| 266 | execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?; |
| 267 | |
| 268 | // Handle secondary cursors at end of line (past text content) |
| 269 | // Find the rightmost secondary cursor past text |
| 270 | let max_cursor_past_text = secondary_cursors.iter() |
| 271 | .filter(|&&c| c >= chars.len()) |
| 272 | .max() |
| 273 | .copied(); |
| 274 | |
| 275 | if let Some(max_cursor) = max_cursor_past_text { |
| 276 | if max_cursor < max_cols { |
| 277 | // Fill spaces up to and including the cursor positions |
| 278 | for col in chars.len()..=max_cursor { |
| 279 | if secondary_cursors.contains(&col) { |
| 280 | execute!( |
| 281 | self.stdout, |
| 282 | SetBackgroundColor(Color::Magenta), |
| 283 | SetForegroundColor(Color::White), |
| 284 | Print(" ") |
| 285 | )?; |
| 286 | } else { |
| 287 | execute!( |
| 288 | self.stdout, |
| 289 | SetBackgroundColor(line_bg), |
| 290 | Print(" ") |
| 291 | )?; |
| 292 | } |
| 293 | } |
| 294 | // Reset for the rest of the line |
| 295 | execute!(self.stdout, SetBackgroundColor(line_bg), SetForegroundColor(default_fg))?; |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | Ok(()) |
| 300 | } |
| 301 | |
| 302 | fn render_status_bar( |
| 303 | &mut self, |
| 304 | buffer: &Buffer, |
| 305 | cursors: &Cursors, |
| 306 | filename: Option<&str>, |
| 307 | message: Option<&str>, |
| 308 | ) -> Result<()> { |
| 309 | let status_row = self.rows.saturating_sub(1); |
| 310 | execute!(self.stdout, MoveTo(0, status_row))?; |
| 311 | |
| 312 | // Status bar background |
| 313 | execute!( |
| 314 | self.stdout, |
| 315 | SetBackgroundColor(Color::DarkGrey), |
| 316 | SetForegroundColor(Color::White) |
| 317 | )?; |
| 318 | |
| 319 | // Left side: filename + modified indicator + cursor count |
| 320 | let name = filename.unwrap_or("[No Name]"); |
| 321 | let modified = if buffer.modified { " [+]" } else { "" }; |
| 322 | let cursor_count = if cursors.len() > 1 { |
| 323 | format!(" ({} cursors)", cursors.len()) |
| 324 | } else { |
| 325 | String::new() |
| 326 | }; |
| 327 | let left = format!(" {}{}{}", name, modified, cursor_count); |
| 328 | |
| 329 | // Right side: position (and message if any) |
| 330 | let primary = cursors.primary(); |
| 331 | let pos = format!("Ln {}, Col {}", primary.line + 1, primary.col + 1); |
| 332 | let right = if let Some(msg) = message { |
| 333 | format!(" {} | {} ", msg, pos) |
| 334 | } else { |
| 335 | format!(" {} ", pos) |
| 336 | }; |
| 337 | |
| 338 | // Pad middle |
| 339 | let padding = (self.cols as usize).saturating_sub(left.len() + right.len()); |
| 340 | let middle = " ".repeat(padding); |
| 341 | |
| 342 | execute!( |
| 343 | self.stdout, |
| 344 | Print(&left), |
| 345 | Print(&middle), |
| 346 | Print(&right), |
| 347 | ResetColor |
| 348 | )?; |
| 349 | |
| 350 | Ok(()) |
| 351 | } |
| 352 | |
| 353 | fn line_number_width(&self, line_count: usize) -> usize { |
| 354 | let digits = if line_count == 0 { |
| 355 | 1 |
| 356 | } else { |
| 357 | (line_count as f64).log10().floor() as usize + 1 |
| 358 | }; |
| 359 | digits.max(3) // Minimum 3 characters |
| 360 | } |
| 361 | } |
| 362 |