@@ -2,6 +2,7 @@ use anyhow::Result; |
| 2 | 2 | use crossterm::{ |
| 3 | 3 | cursor::{Hide, MoveTo, Show}, |
| 4 | 4 | event::{ |
| 5 | + DisableMouseCapture, EnableMouseCapture, |
| 5 | 6 | KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, |
| 6 | 7 | }, |
| 7 | 8 | execute, |
@@ -11,7 +12,7 @@ use crossterm::{ |
| 11 | 12 | use std::io::{stdout, Stdout, Write}; |
| 12 | 13 | |
| 13 | 14 | use crate::buffer::Buffer; |
| 14 | | -use crate::editor::{Cursor, Position}; |
| 15 | +use crate::editor::{Cursors, Position}; |
| 15 | 16 | |
| 16 | 17 | // Editor color scheme (256-color palette) |
| 17 | 18 | const BG_COLOR: Color = Color::AnsiValue(234); // Off-black editor background |
@@ -19,6 +20,7 @@ const CURRENT_LINE_BG: Color = Color::AnsiValue(236); // Slightly lighter for |
| 19 | 20 | const LINE_NUM_COLOR: Color = Color::AnsiValue(243); // Gray for line numbers |
| 20 | 21 | const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow; // Yellow for active line number |
| 21 | 22 | const BRACKET_MATCH_BG: Color = Color::AnsiValue(240); // Highlight for matching brackets |
| 23 | +// Secondary cursors use Color::Magenta for visibility |
| 22 | 24 | |
| 23 | 25 | /// Terminal screen renderer |
| 24 | 26 | pub struct Screen { |
@@ -41,7 +43,7 @@ impl Screen { |
| 41 | 43 | |
| 42 | 44 | pub fn enter_raw_mode(&mut self) -> Result<()> { |
| 43 | 45 | terminal::enable_raw_mode()?; |
| 44 | | - execute!(self.stdout, EnterAlternateScreen, Hide)?; |
| 46 | + execute!(self.stdout, EnterAlternateScreen, Hide, EnableMouseCapture)?; |
| 45 | 47 | |
| 46 | 48 | // Try to enable keyboard enhancement for better modifier key detection |
| 47 | 49 | // This enables the kitty keyboard protocol on supporting terminals |
@@ -64,7 +66,7 @@ impl Screen { |
| 64 | 66 | if self.keyboard_enhanced { |
| 65 | 67 | let _ = execute!(self.stdout, PopKeyboardEnhancementFlags); |
| 66 | 68 | } |
| 67 | | - execute!(self.stdout, Show, LeaveAlternateScreen)?; |
| 69 | + execute!(self.stdout, Show, DisableMouseCapture, LeaveAlternateScreen)?; |
| 68 | 70 | terminal::disable_raw_mode()?; |
| 69 | 71 | Ok(()) |
| 70 | 72 | } |
@@ -86,7 +88,7 @@ impl Screen { |
| 86 | 88 | pub fn render( |
| 87 | 89 | &mut self, |
| 88 | 90 | buffer: &Buffer, |
| 89 | | - cursor: &Cursor, |
| 91 | + cursors: &Cursors, |
| 90 | 92 | viewport_line: usize, |
| 91 | 93 | filename: Option<&str>, |
| 92 | 94 | message: Option<&str>, |
@@ -98,8 +100,22 @@ impl Screen { |
| 98 | 100 | let line_num_width = self.line_number_width(buffer.line_count()); |
| 99 | 101 | let text_cols = self.cols as usize - line_num_width - 1; |
| 100 | 102 | |
| 101 | | - // Get selection bounds if any |
| 102 | | - let selection = cursor.selection_bounds(); |
| 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(); |
| 103 | 119 | |
| 104 | 120 | // Reserve 1 row for status bar |
| 105 | 121 | let text_rows = self.rows.saturating_sub(1) as usize; |
@@ -107,7 +123,7 @@ impl Screen { |
| 107 | 123 | // Draw text area |
| 108 | 124 | for row in 0..text_rows { |
| 109 | 125 | let line_idx = viewport_line + row; |
| 110 | | - let is_current_line = line_idx == cursor.line; |
| 126 | + let is_current_line = line_idx == primary.line; |
| 111 | 127 | execute!(self.stdout, MoveTo(0, row as u16))?; |
| 112 | 128 | |
| 113 | 129 | if line_idx < buffer.line_count() { |
@@ -126,19 +142,27 @@ impl Screen { |
| 126 | 142 | Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)), |
| 127 | 143 | )?; |
| 128 | 144 | |
| 129 | | - // Line content with selection highlighting |
| 145 | + // Line content with selection and cursor highlighting |
| 130 | 146 | if let Some(line) = buffer.line_str(line_idx) { |
| 131 | 147 | // Check if bracket match is on this line |
| 132 | 148 | let bracket_col = bracket_match |
| 133 | 149 | .filter(|(bl, _)| *bl == line_idx) |
| 134 | 150 | .map(|(_, bc)| bc); |
| 135 | | - self.render_line_with_selection( |
| 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( |
| 136 | 159 | &line, |
| 137 | 160 | line_idx, |
| 138 | 161 | text_cols, |
| 139 | | - selection.as_ref(), |
| 162 | + &selections, |
| 140 | 163 | is_current_line, |
| 141 | 164 | bracket_col, |
| 165 | + &secondary_cursors, |
| 142 | 166 | )?; |
| 143 | 167 | } |
| 144 | 168 | |
@@ -163,11 +187,11 @@ impl Screen { |
| 163 | 187 | } |
| 164 | 188 | |
| 165 | 189 | // Status bar |
| 166 | | - self.render_status_bar(buffer, cursor, filename, message)?; |
| 190 | + self.render_status_bar(buffer, cursors, filename, message)?; |
| 167 | 191 | |
| 168 | | - // Position cursor |
| 169 | | - let cursor_row = cursor.line.saturating_sub(viewport_line); |
| 170 | | - let cursor_col = line_num_width + 1 + cursor.col; |
| 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; |
| 171 | 195 | execute!( |
| 172 | 196 | self.stdout, |
| 173 | 197 | MoveTo(cursor_col as u16, cursor_row as u16), |
@@ -178,59 +202,97 @@ impl Screen { |
| 178 | 202 | Ok(()) |
| 179 | 203 | } |
| 180 | 204 | |
| 181 | | - fn render_line_with_selection( |
| 205 | + fn render_line_with_cursors( |
| 182 | 206 | &mut self, |
| 183 | 207 | line: &str, |
| 184 | 208 | line_idx: usize, |
| 185 | 209 | max_cols: usize, |
| 186 | | - selection: Option<&(Position, Position)>, |
| 210 | + selections: &[(Position, Position)], |
| 187 | 211 | is_current_line: bool, |
| 188 | 212 | bracket_col: Option<usize>, |
| 213 | + secondary_cursors: &[usize], |
| 189 | 214 | ) -> Result<()> { |
| 190 | 215 | let chars: Vec<char> = line.chars().take(max_cols).collect(); |
| 191 | 216 | let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR }; |
| 217 | + let default_fg = Color::Reset; // Default terminal foreground |
| 192 | 218 | |
| 193 | | - // Determine selection range for this line |
| 194 | | - let (sel_start, sel_end) = if let Some((start, end)) = selection { |
| 195 | | - let line_has_selection = line_idx >= start.line && line_idx <= end.line; |
| 196 | | - if line_has_selection { |
| 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 { |
| 197 | 223 | let s = if line_idx == start.line { start.col } else { 0 }; |
| 198 | 224 | let e = if line_idx == end.line { end.col } else { chars.len() }; |
| 199 | | - (Some(s), Some(e)) |
| 200 | | - } else { |
| 201 | | - (None, None) |
| 225 | + if s < e { |
| 226 | + sel_ranges.push((s, e)); |
| 227 | + } |
| 202 | 228 | } |
| 203 | | - } else { |
| 204 | | - (None, None) |
| 205 | | - }; |
| 229 | + } |
| 206 | 230 | |
| 207 | 231 | // Render character by character for precise highlighting |
| 208 | 232 | for (col, ch) in chars.iter().enumerate() { |
| 209 | | - let in_selection = sel_start.map_or(false, |s| col >= s) |
| 210 | | - && sel_end.map_or(false, |e| col < e); |
| 233 | + let in_selection = sel_ranges.iter().any(|(s, e)| col >= *s && col < *e); |
| 211 | 234 | let is_bracket_match = bracket_col == Some(col); |
| 235 | + let is_secondary_cursor = secondary_cursors.contains(&col); |
| 212 | 236 | |
| 237 | + // Determine background color |
| 213 | 238 | let bg = if in_selection { |
| 214 | 239 | Color::Blue |
| 240 | + } else if is_secondary_cursor { |
| 241 | + Color::Magenta // Use magenta for better visibility |
| 215 | 242 | } else if is_bracket_match { |
| 216 | 243 | BRACKET_MATCH_BG |
| 217 | 244 | } else { |
| 218 | 245 | line_bg |
| 219 | 246 | }; |
| 220 | 247 | |
| 248 | + // Determine foreground color |
| 221 | 249 | let fg = if in_selection { |
| 222 | | - Some(Color::White) |
| 250 | + Color::White |
| 251 | + } else if is_secondary_cursor { |
| 252 | + Color::White // White text on magenta bg |
| 223 | 253 | } else { |
| 224 | | - None |
| 254 | + default_fg |
| 225 | 255 | }; |
| 226 | 256 | |
| 227 | | - execute!(self.stdout, SetBackgroundColor(bg))?; |
| 228 | | - if let Some(fg_color) = fg { |
| 229 | | - execute!(self.stdout, SetForegroundColor(fg_color))?; |
| 230 | | - } |
| 231 | | - execute!(self.stdout, Print(ch))?; |
| 232 | | - if fg.is_some() { |
| 233 | | - execute!(self.stdout, ResetColor)?; |
| 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))?; |
| 234 | 296 | } |
| 235 | 297 | } |
| 236 | 298 | |
@@ -240,7 +302,7 @@ impl Screen { |
| 240 | 302 | fn render_status_bar( |
| 241 | 303 | &mut self, |
| 242 | 304 | buffer: &Buffer, |
| 243 | | - cursor: &Cursor, |
| 305 | + cursors: &Cursors, |
| 244 | 306 | filename: Option<&str>, |
| 245 | 307 | message: Option<&str>, |
| 246 | 308 | ) -> Result<()> { |
@@ -254,13 +316,19 @@ impl Screen { |
| 254 | 316 | SetForegroundColor(Color::White) |
| 255 | 317 | )?; |
| 256 | 318 | |
| 257 | | - // Left side: filename + modified indicator |
| 319 | + // Left side: filename + modified indicator + cursor count |
| 258 | 320 | let name = filename.unwrap_or("[No Name]"); |
| 259 | 321 | let modified = if buffer.modified { " [+]" } else { "" }; |
| 260 | | - let left = format!(" {}{}", name, modified); |
| 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); |
| 261 | 328 | |
| 262 | 329 | // Right side: position (and message if any) |
| 263 | | - let pos = format!("Ln {}, Col {}", cursor.line + 1, cursor.col + 1); |
| 330 | + let primary = cursors.primary(); |
| 331 | + let pos = format!("Ln {}, Col {}", primary.line + 1, primary.col + 1); |
| 264 | 332 | let right = if let Some(msg) = message { |
| 265 | 333 | format!(" {} | {} ", msg, pos) |
| 266 | 334 | } else { |