tenseleyflow/fackr / 2fb3c6c

Browse files

feat: mouse support - click, drag select, scroll, Ctrl+click multi-cursor

- Add Mouse event abstraction with Click, Drag, ScrollUp, ScrollDown
- Enable mouse capture in terminal
- Left click positions cursor
- Drag to select text
- Scroll wheel moves viewport (3 lines per tick)
- Ctrl+click adds/removes cursors at click position
- Fix primary cursor index tracking for correct secondary cursor rendering
Authored by espadonne
SHA
2fb3c6c553e9b6cc98e46022640d8583b96387ba
Parents
78fc2ab
Tree
acb8a83

3 changed files

StatusFile+-
M src/input/mod.rs 3 0
A src/input/mouse.rs 94 0
M src/render/screen.rs 109 41
src/input/mod.rsmodified
@@ -1,3 +1,6 @@
11
 mod key;
2
+mod mouse;
23
 
34
 pub use key::{Key, Modifiers};
5
+#[allow(unused_imports)]
6
+pub use mouse::{Button, Mouse, MouseModifiers};
src/input/mouse.rsadded
@@ -0,0 +1,94 @@
1
+//! Mouse event handling
2
+
3
+#![allow(dead_code)]
4
+
5
+use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
6
+
7
+/// Mouse button types
8
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9
+pub enum Button {
10
+    Left,
11
+    Right,
12
+    Middle,
13
+}
14
+
15
+/// Modifiers held during mouse event
16
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17
+pub struct MouseModifiers {
18
+    pub ctrl: bool,
19
+    pub alt: bool,
20
+    pub shift: bool,
21
+}
22
+
23
+impl From<KeyModifiers> for MouseModifiers {
24
+    fn from(m: KeyModifiers) -> Self {
25
+        Self {
26
+            ctrl: m.contains(KeyModifiers::CONTROL),
27
+            alt: m.contains(KeyModifiers::ALT),
28
+            shift: m.contains(KeyModifiers::SHIFT),
29
+        }
30
+    }
31
+}
32
+
33
+/// Abstracted mouse event
34
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35
+pub enum Mouse {
36
+    /// Click at (column, row) - 0-indexed
37
+    Click { button: Button, col: u16, row: u16, modifiers: MouseModifiers },
38
+    /// Drag to (column, row)
39
+    Drag { button: Button, col: u16, row: u16, modifiers: MouseModifiers },
40
+    /// Scroll up at (column, row)
41
+    ScrollUp { col: u16, row: u16 },
42
+    /// Scroll down at (column, row)
43
+    ScrollDown { col: u16, row: u16 },
44
+}
45
+
46
+impl Mouse {
47
+    pub fn from_crossterm(event: MouseEvent) -> Option<Self> {
48
+        let col = event.column;
49
+        let row = event.row;
50
+        let modifiers = MouseModifiers::from(event.modifiers);
51
+
52
+        match event.kind {
53
+            MouseEventKind::Down(button) => {
54
+                let button = match button {
55
+                    MouseButton::Left => Button::Left,
56
+                    MouseButton::Right => Button::Right,
57
+                    MouseButton::Middle => Button::Middle,
58
+                };
59
+                Some(Mouse::Click { button, col, row, modifiers })
60
+            }
61
+            MouseEventKind::Drag(button) => {
62
+                let button = match button {
63
+                    MouseButton::Left => Button::Left,
64
+                    MouseButton::Right => Button::Right,
65
+                    MouseButton::Middle => Button::Middle,
66
+                };
67
+                Some(Mouse::Drag { button, col, row, modifiers })
68
+            }
69
+            MouseEventKind::ScrollUp => Some(Mouse::ScrollUp { col, row }),
70
+            MouseEventKind::ScrollDown => Some(Mouse::ScrollDown { col, row }),
71
+            _ => None, // Ignore Up, Moved events for now
72
+        }
73
+    }
74
+
75
+    /// Get the column position
76
+    pub fn col(&self) -> u16 {
77
+        match self {
78
+            Mouse::Click { col, .. } => *col,
79
+            Mouse::Drag { col, .. } => *col,
80
+            Mouse::ScrollUp { col, .. } => *col,
81
+            Mouse::ScrollDown { col, .. } => *col,
82
+        }
83
+    }
84
+
85
+    /// Get the row position
86
+    pub fn row(&self) -> u16 {
87
+        match self {
88
+            Mouse::Click { row, .. } => *row,
89
+            Mouse::Drag { row, .. } => *row,
90
+            Mouse::ScrollUp { row, .. } => *row,
91
+            Mouse::ScrollDown { row, .. } => *row,
92
+        }
93
+    }
94
+}
src/render/screen.rsmodified
@@ -2,6 +2,7 @@ use anyhow::Result;
22
 use crossterm::{
33
     cursor::{Hide, MoveTo, Show},
44
     event::{
5
+        DisableMouseCapture, EnableMouseCapture,
56
         KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
67
     },
78
     execute,
@@ -11,7 +12,7 @@ use crossterm::{
1112
 use std::io::{stdout, Stdout, Write};
1213
 
1314
 use crate::buffer::Buffer;
14
-use crate::editor::{Cursor, Position};
15
+use crate::editor::{Cursors, Position};
1516
 
1617
 // Editor color scheme (256-color palette)
1718
 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
1920
 const LINE_NUM_COLOR: Color = Color::AnsiValue(243);     // Gray for line numbers
2021
 const CURRENT_LINE_NUM_COLOR: Color = Color::Yellow;     // Yellow for active line number
2122
 const BRACKET_MATCH_BG: Color = Color::AnsiValue(240);   // Highlight for matching brackets
23
+// Secondary cursors use Color::Magenta for visibility
2224
 
2325
 /// Terminal screen renderer
2426
 pub struct Screen {
@@ -41,7 +43,7 @@ impl Screen {
4143
 
4244
     pub fn enter_raw_mode(&mut self) -> Result<()> {
4345
         terminal::enable_raw_mode()?;
44
-        execute!(self.stdout, EnterAlternateScreen, Hide)?;
46
+        execute!(self.stdout, EnterAlternateScreen, Hide, EnableMouseCapture)?;
4547
 
4648
         // Try to enable keyboard enhancement for better modifier key detection
4749
         // This enables the kitty keyboard protocol on supporting terminals
@@ -64,7 +66,7 @@ impl Screen {
6466
         if self.keyboard_enhanced {
6567
             let _ = execute!(self.stdout, PopKeyboardEnhancementFlags);
6668
         }
67
-        execute!(self.stdout, Show, LeaveAlternateScreen)?;
69
+        execute!(self.stdout, Show, DisableMouseCapture, LeaveAlternateScreen)?;
6870
         terminal::disable_raw_mode()?;
6971
         Ok(())
7072
     }
@@ -86,7 +88,7 @@ impl Screen {
8688
     pub fn render(
8789
         &mut self,
8890
         buffer: &Buffer,
89
-        cursor: &Cursor,
91
+        cursors: &Cursors,
9092
         viewport_line: usize,
9193
         filename: Option<&str>,
9294
         message: Option<&str>,
@@ -98,8 +100,22 @@ impl Screen {
98100
         let line_num_width = self.line_number_width(buffer.line_count());
99101
         let text_cols = self.cols as usize - line_num_width - 1;
100102
 
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();
103119
 
104120
         // Reserve 1 row for status bar
105121
         let text_rows = self.rows.saturating_sub(1) as usize;
@@ -107,7 +123,7 @@ impl Screen {
107123
         // Draw text area
108124
         for row in 0..text_rows {
109125
             let line_idx = viewport_line + row;
110
-            let is_current_line = line_idx == cursor.line;
126
+            let is_current_line = line_idx == primary.line;
111127
             execute!(self.stdout, MoveTo(0, row as u16))?;
112128
 
113129
             if line_idx < buffer.line_count() {
@@ -126,19 +142,27 @@ impl Screen {
126142
                     Print(format!("{:>width$} ", line_idx + 1, width = line_num_width)),
127143
                 )?;
128144
 
129
-                // Line content with selection highlighting
145
+                // Line content with selection and cursor highlighting
130146
                 if let Some(line) = buffer.line_str(line_idx) {
131147
                     // Check if bracket match is on this line
132148
                     let bracket_col = bracket_match
133149
                         .filter(|(bl, _)| *bl == line_idx)
134150
                         .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(
136159
                         &line,
137160
                         line_idx,
138161
                         text_cols,
139
-                        selection.as_ref(),
162
+                        &selections,
140163
                         is_current_line,
141164
                         bracket_col,
165
+                        &secondary_cursors,
142166
                     )?;
143167
                 }
144168
 
@@ -163,11 +187,11 @@ impl Screen {
163187
         }
164188
 
165189
         // Status bar
166
-        self.render_status_bar(buffer, cursor, filename, message)?;
190
+        self.render_status_bar(buffer, cursors, filename, message)?;
167191
 
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;
171195
         execute!(
172196
             self.stdout,
173197
             MoveTo(cursor_col as u16, cursor_row as u16),
@@ -178,59 +202,97 @@ impl Screen {
178202
         Ok(())
179203
     }
180204
 
181
-    fn render_line_with_selection(
205
+    fn render_line_with_cursors(
182206
         &mut self,
183207
         line: &str,
184208
         line_idx: usize,
185209
         max_cols: usize,
186
-        selection: Option<&(Position, Position)>,
210
+        selections: &[(Position, Position)],
187211
         is_current_line: bool,
188212
         bracket_col: Option<usize>,
213
+        secondary_cursors: &[usize],
189214
     ) -> Result<()> {
190215
         let chars: Vec<char> = line.chars().take(max_cols).collect();
191216
         let line_bg = if is_current_line { CURRENT_LINE_BG } else { BG_COLOR };
217
+        let default_fg = Color::Reset; // Default terminal foreground
192218
 
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 {
197223
                 let s = if line_idx == start.line { start.col } else { 0 };
198224
                 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
+                }
202228
             }
203
-        } else {
204
-            (None, None)
205
-        };
229
+        }
206230
 
207231
         // Render character by character for precise highlighting
208232
         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);
211234
             let is_bracket_match = bracket_col == Some(col);
235
+            let is_secondary_cursor = secondary_cursors.contains(&col);
212236
 
237
+            // Determine background color
213238
             let bg = if in_selection {
214239
                 Color::Blue
240
+            } else if is_secondary_cursor {
241
+                Color::Magenta  // Use magenta for better visibility
215242
             } else if is_bracket_match {
216243
                 BRACKET_MATCH_BG
217244
             } else {
218245
                 line_bg
219246
             };
220247
 
248
+            // Determine foreground color
221249
             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
223253
             } else {
224
-                None
254
+                default_fg
225255
             };
226256
 
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))?;
234296
             }
235297
         }
236298
 
@@ -240,7 +302,7 @@ impl Screen {
240302
     fn render_status_bar(
241303
         &mut self,
242304
         buffer: &Buffer,
243
-        cursor: &Cursor,
305
+        cursors: &Cursors,
244306
         filename: Option<&str>,
245307
         message: Option<&str>,
246308
     ) -> Result<()> {
@@ -254,13 +316,19 @@ impl Screen {
254316
             SetForegroundColor(Color::White)
255317
         )?;
256318
 
257
-        // Left side: filename + modified indicator
319
+        // Left side: filename + modified indicator + cursor count
258320
         let name = filename.unwrap_or("[No Name]");
259321
         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);
261328
 
262329
         // 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);
264332
         let right = if let Some(msg) = message {
265333
             format!(" {} | {} ", msg, pos)
266334
         } else {