gardesk/gardm / b08bce1

Browse files

Add cursor positioning and text selection to login form

Implement proper cursor movement with arrow keys, Home, End,
and Delete. Support shift+arrow for text selection with
visual highlight. Handle click-to-place cursor in text fields.

Fix caps lock interaction with shift key for correct case
handling on letter keys.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b08bce106a72d74ceede10cf0e559aa024ffd7df
Parents
d41d87e
Tree
54b11ab

2 changed files

StatusFile+-
M gardm-greeter/src/keyboard.rs 21 6
M gardm-greeter/src/widgets/login_form.rs 420 11
gardm-greeter/src/keyboard.rsmodified
@@ -13,11 +13,19 @@ pub mod keycodes {
1313
     pub const SHIFT_L: u8 = 50;
1414
     pub const SHIFT_R: u8 = 62;
1515
     pub const CAPS_LOCK: u8 = 66;
16
+    pub const LEFT: u8 = 113;
17
+    pub const UP: u8 = 111;
18
+    pub const RIGHT: u8 = 114;
19
+    pub const DOWN: u8 = 116;
20
+    pub const HOME: u8 = 110;
21
+    pub const END: u8 = 115;
22
+    pub const DELETE: u8 = 119;
1623
 }
1724
 
18
-/// Convert a keycode to a character, considering shift state
25
+/// Convert a keycode to a character, considering shift and caps lock state
1926
 pub fn keycode_to_char(keycode: u8, state: KeyButMask) -> Option<char> {
20
-    let shifted = state.contains(KeyButMask::SHIFT);
27
+    let shift_pressed = state.contains(KeyButMask::SHIFT);
28
+    let caps_lock_on = state.contains(KeyButMask::LOCK);
2129
 
2230
     // Main alphanumeric keys (evdev keycodes)
2331
     let base = match keycode {
@@ -84,8 +92,17 @@ pub fn keycode_to_char(keycode: u8, state: KeyButMask) -> Option<char> {
8492
         _ => return None,
8593
     };
8694
 
87
-    // Apply shift transformations
88
-    let c = if shifted {
95
+    // For letters: uppercase if shift XOR caps_lock (one but not both)
96
+    // For symbols: only shift matters
97
+    let c = if base.is_ascii_lowercase() {
98
+        // Letters: shift XOR caps_lock determines case
99
+        if shift_pressed != caps_lock_on {
100
+            base.to_ascii_uppercase()
101
+        } else {
102
+            base
103
+        }
104
+    } else if shift_pressed {
105
+        // Non-letters: only shift affects them
89106
         match base {
90107
             // Numbers to symbols
91108
             '1' => '!',
@@ -109,8 +126,6 @@ pub fn keycode_to_char(keycode: u8, state: KeyButMask) -> Option<char> {
109126
             '.' => '>',
110127
             '/' => '?',
111128
             '`' => '~',
112
-            // Letters to uppercase
113
-            c if c.is_ascii_lowercase() => c.to_ascii_uppercase(),
114129
             c => c,
115130
         }
116131
     } else {
gardm-greeter/src/widgets/login_form.rsmodified
@@ -25,6 +25,14 @@ pub struct LoginForm {
2525
     pub is_loading: bool,
2626
     pub cursor_visible: bool,
2727
 
28
+    // Cursor positions (character index)
29
+    username_cursor: usize,
30
+    password_cursor: usize,
31
+
32
+    // Selection anchor (None = no selection, Some = selection start)
33
+    username_selection: Option<usize>,
34
+    password_selection: Option<usize>,
35
+
2836
     // Layout dimensions
2937
     x: f64,
3038
     y: f64,
@@ -46,6 +54,10 @@ impl LoginForm {
4654
             info_message: None,
4755
             is_loading: false,
4856
             cursor_visible: true,
57
+            username_cursor: 0,
58
+            password_cursor: 0,
59
+            username_selection: None,
60
+            password_selection: None,
4961
             x: center_x - width / 2.0,
5062
             y: center_y - height / 2.0,
5163
             width,
@@ -73,6 +85,8 @@ impl LoginForm {
7385
             &self.username,
7486
             self.y + 100.0,
7587
             self.focused_field == FocusedField::Username,
88
+            self.username_cursor,
89
+            self.username_selection,
7690
         )?;
7791
 
7892
         // Password field (masked)
@@ -85,6 +99,8 @@ impl LoginForm {
8599
             &masked_password,
86100
             self.y + 170.0,
87101
             self.focused_field == FocusedField::Password,
102
+            self.password_cursor,
103
+            self.password_selection,
88104
         )?;
89105
 
90106
         // Error message
@@ -133,10 +149,13 @@ impl LoginForm {
133149
         value: &str,
134150
         y: f64,
135151
         focused: bool,
152
+        cursor_pos: usize,
153
+        selection_anchor: Option<usize>,
136154
     ) -> Result<()> {
137155
         let field_x = self.x + 30.0;
138156
         let field_width = self.width - 60.0;
139157
         let field_height = 40.0;
158
+        let text_start_x = field_x + 12.0;
140159
 
141160
         // Label
142161
         let mut font = FontDescription::new();
@@ -171,23 +190,56 @@ impl LoginForm {
171190
             ctx.stroke()?;
172191
         }
173192
 
193
+        // Set up font for text measurement
194
+        font.set_size(theme.font_size_normal * pango::SCALE);
195
+
196
+        // Helper to measure text width
197
+        let measure_text = |s: &str| -> f64 {
198
+            if s.is_empty() {
199
+                return 0.0;
200
+            }
201
+            let layout = Layout::new(pango_ctx);
202
+            layout.set_font_description(Some(&font));
203
+            layout.set_text(s);
204
+            layout.pixel_size().0 as f64
205
+        };
206
+
207
+        // Draw selection highlight if there's a selection
208
+        if let Some(anchor) = selection_anchor {
209
+            if anchor != cursor_pos && !value.is_empty() {
210
+                let start = anchor.min(cursor_pos);
211
+                let end = anchor.max(cursor_pos);
212
+
213
+                let chars: Vec<char> = value.chars().collect();
214
+                let text_before_start: String = chars.iter().take(start).collect();
215
+                let text_before_end: String = chars.iter().take(end).collect();
216
+
217
+                let start_x = text_start_x + measure_text(&text_before_start);
218
+                let end_x = text_start_x + measure_text(&text_before_end);
219
+
220
+                // Selection highlight (more pronounced blue)
221
+                ctx.set_source_rgba(0.2, 0.5, 1.0, 0.6);
222
+                ctx.rectangle(start_x, y + 8.0, end_x - start_x, 24.0);
223
+                ctx.fill()?;
224
+            }
225
+        }
226
+
174227
         // Text value
175228
         let tc = &theme.text_primary;
176229
         ctx.set_source_rgb(tc.r, tc.g, tc.b);
177
-        font.set_size(theme.font_size_normal * pango::SCALE);
178230
         let value_layout = Layout::new(pango_ctx);
179231
         value_layout.set_font_description(Some(&font));
180232
         value_layout.set_text(if value.is_empty() { " " } else { value });
181
-        ctx.move_to(field_x + 12.0, y + 10.0);
233
+        ctx.move_to(text_start_x, y + 10.0);
182234
         pangocairo::functions::show_layout(ctx, &value_layout);
183235
 
184
-        // Cursor (blinking)
236
+        // Cursor (blinking) - positioned at cursor_pos
185237
         if focused && self.cursor_visible {
186
-            let (text_width, _) = value_layout.pixel_size();
187
-            let cursor_x = if value.is_empty() {
188
-                field_x + 12.0
238
+            let cursor_x = if value.is_empty() || cursor_pos == 0 {
239
+                text_start_x
189240
             } else {
190
-                field_x + 12.0 + text_width as f64
241
+                let text_before_cursor: String = value.chars().take(cursor_pos).collect();
242
+                text_start_x + measure_text(&text_before_cursor)
191243
             };
192244
             ctx.set_source_rgb(tc.r, tc.g, tc.b);
193245
             ctx.rectangle(cursor_x, y + 8.0, 2.0, 24.0);
@@ -263,9 +315,25 @@ impl LoginForm {
263315
             return;
264316
         }
265317
 
318
+        // Delete any selected text first
319
+        self.delete_selection();
320
+
266321
         match self.focused_field {
267
-            FocusedField::Username => self.username.push(key),
268
-            FocusedField::Password => self.password.push(key),
322
+            FocusedField::Username => {
323
+                // Insert at cursor position
324
+                let pos = self.username_cursor.min(self.username.chars().count());
325
+                let mut chars: Vec<char> = self.username.chars().collect();
326
+                chars.insert(pos, key);
327
+                self.username = chars.into_iter().collect();
328
+                self.username_cursor = pos + 1;
329
+            }
330
+            FocusedField::Password => {
331
+                let pos = self.password_cursor.min(self.password.chars().count());
332
+                let mut chars: Vec<char> = self.password.chars().collect();
333
+                chars.insert(pos, key);
334
+                self.password = chars.into_iter().collect();
335
+                self.password_cursor = pos + 1;
336
+            }
269337
         }
270338
         self.clear_messages();
271339
     }
@@ -276,17 +344,214 @@ impl LoginForm {
276344
             return;
277345
         }
278346
 
347
+        // If there's a selection, delete it instead of single char
348
+        if self.delete_selection() {
349
+            self.clear_messages();
350
+            return;
351
+        }
352
+
353
+        match self.focused_field {
354
+            FocusedField::Username => {
355
+                if self.username_cursor > 0 {
356
+                    let mut chars: Vec<char> = self.username.chars().collect();
357
+                    chars.remove(self.username_cursor - 1);
358
+                    self.username = chars.into_iter().collect();
359
+                    self.username_cursor -= 1;
360
+                }
361
+            }
362
+            FocusedField::Password => {
363
+                if self.password_cursor > 0 {
364
+                    let mut chars: Vec<char> = self.password.chars().collect();
365
+                    chars.remove(self.password_cursor - 1);
366
+                    self.password = chars.into_iter().collect();
367
+                    self.password_cursor -= 1;
368
+                }
369
+            }
370
+        }
371
+        self.clear_messages();
372
+    }
373
+
374
+    /// Handle delete key
375
+    pub fn handle_delete(&mut self) {
376
+        if self.is_loading {
377
+            return;
378
+        }
379
+
380
+        // If there's a selection, delete it instead of single char
381
+        if self.delete_selection() {
382
+            self.clear_messages();
383
+            return;
384
+        }
385
+
279386
         match self.focused_field {
280387
             FocusedField::Username => {
281
-                self.username.pop();
388
+                let len = self.username.chars().count();
389
+                if self.username_cursor < len {
390
+                    let mut chars: Vec<char> = self.username.chars().collect();
391
+                    chars.remove(self.username_cursor);
392
+                    self.username = chars.into_iter().collect();
393
+                }
282394
             }
283395
             FocusedField::Password => {
284
-                self.password.pop();
396
+                let len = self.password.chars().count();
397
+                if self.password_cursor < len {
398
+                    let mut chars: Vec<char> = self.password.chars().collect();
399
+                    chars.remove(self.password_cursor);
400
+                    self.password = chars.into_iter().collect();
401
+                }
285402
             }
286403
         }
287404
         self.clear_messages();
288405
     }
289406
 
407
+    /// Handle left arrow key (shift = extend selection)
408
+    pub fn handle_left(&mut self, shift: bool) {
409
+        match self.focused_field {
410
+            FocusedField::Username => {
411
+                if shift && self.username_selection.is_none() {
412
+                    self.username_selection = Some(self.username_cursor);
413
+                } else if !shift {
414
+                    self.username_selection = None;
415
+                }
416
+                if self.username_cursor > 0 {
417
+                    self.username_cursor -= 1;
418
+                }
419
+            }
420
+            FocusedField::Password => {
421
+                if shift && self.password_selection.is_none() {
422
+                    self.password_selection = Some(self.password_cursor);
423
+                } else if !shift {
424
+                    self.password_selection = None;
425
+                }
426
+                if self.password_cursor > 0 {
427
+                    self.password_cursor -= 1;
428
+                }
429
+            }
430
+        }
431
+    }
432
+
433
+    /// Handle right arrow key (shift = extend selection)
434
+    pub fn handle_right(&mut self, shift: bool) {
435
+        match self.focused_field {
436
+            FocusedField::Username => {
437
+                if shift && self.username_selection.is_none() {
438
+                    self.username_selection = Some(self.username_cursor);
439
+                } else if !shift {
440
+                    self.username_selection = None;
441
+                }
442
+                if self.username_cursor < self.username.chars().count() {
443
+                    self.username_cursor += 1;
444
+                }
445
+            }
446
+            FocusedField::Password => {
447
+                if shift && self.password_selection.is_none() {
448
+                    self.password_selection = Some(self.password_cursor);
449
+                } else if !shift {
450
+                    self.password_selection = None;
451
+                }
452
+                if self.password_cursor < self.password.chars().count() {
453
+                    self.password_cursor += 1;
454
+                }
455
+            }
456
+        }
457
+    }
458
+
459
+    /// Handle Home key - move cursor to beginning (shift = extend selection)
460
+    pub fn handle_home(&mut self, shift: bool) {
461
+        match self.focused_field {
462
+            FocusedField::Username => {
463
+                if shift && self.username_selection.is_none() {
464
+                    self.username_selection = Some(self.username_cursor);
465
+                } else if !shift {
466
+                    self.username_selection = None;
467
+                }
468
+                self.username_cursor = 0;
469
+            }
470
+            FocusedField::Password => {
471
+                if shift && self.password_selection.is_none() {
472
+                    self.password_selection = Some(self.password_cursor);
473
+                } else if !shift {
474
+                    self.password_selection = None;
475
+                }
476
+                self.password_cursor = 0;
477
+            }
478
+        }
479
+    }
480
+
481
+    /// Handle End key - move cursor to end (shift = extend selection)
482
+    pub fn handle_end(&mut self, shift: bool) {
483
+        match self.focused_field {
484
+            FocusedField::Username => {
485
+                if shift && self.username_selection.is_none() {
486
+                    self.username_selection = Some(self.username_cursor);
487
+                } else if !shift {
488
+                    self.username_selection = None;
489
+                }
490
+                self.username_cursor = self.username.chars().count();
491
+            }
492
+            FocusedField::Password => {
493
+                if shift && self.password_selection.is_none() {
494
+                    self.password_selection = Some(self.password_cursor);
495
+                } else if !shift {
496
+                    self.password_selection = None;
497
+                }
498
+                self.password_cursor = self.password.chars().count();
499
+            }
500
+        }
501
+    }
502
+
503
+    /// Clear any active selection
504
+    pub fn clear_selection(&mut self) {
505
+        self.username_selection = None;
506
+        self.password_selection = None;
507
+    }
508
+
509
+    /// Get selection range for current field (start, end) or None
510
+    fn get_selection_range(&self) -> Option<(usize, usize)> {
511
+        match self.focused_field {
512
+            FocusedField::Username => {
513
+                self.username_selection.map(|anchor| {
514
+                    let start = anchor.min(self.username_cursor);
515
+                    let end = anchor.max(self.username_cursor);
516
+                    (start, end)
517
+                })
518
+            }
519
+            FocusedField::Password => {
520
+                self.password_selection.map(|anchor| {
521
+                    let start = anchor.min(self.password_cursor);
522
+                    let end = anchor.max(self.password_cursor);
523
+                    (start, end)
524
+                })
525
+            }
526
+        }
527
+    }
528
+
529
+    /// Delete selected text and return true if there was a selection
530
+    fn delete_selection(&mut self) -> bool {
531
+        if let Some((start, end)) = self.get_selection_range() {
532
+            if start != end {
533
+                match self.focused_field {
534
+                    FocusedField::Username => {
535
+                        let mut chars: Vec<char> = self.username.chars().collect();
536
+                        chars.drain(start..end);
537
+                        self.username = chars.into_iter().collect();
538
+                        self.username_cursor = start;
539
+                        self.username_selection = None;
540
+                    }
541
+                    FocusedField::Password => {
542
+                        let mut chars: Vec<char> = self.password.chars().collect();
543
+                        chars.drain(start..end);
544
+                        self.password = chars.into_iter().collect();
545
+                        self.password_cursor = start;
546
+                        self.password_selection = None;
547
+                    }
548
+                }
549
+                return true;
550
+            }
551
+        }
552
+        false
553
+    }
554
+
290555
     /// Handle tab key (switch focus)
291556
     pub fn handle_tab(&mut self) {
292557
         if self.is_loading {
@@ -325,10 +590,154 @@ impl LoginForm {
325590
     /// Clear password field
326591
     pub fn clear_password(&mut self) {
327592
         self.password.clear();
593
+        self.password_cursor = 0;
594
+    }
595
+
596
+    /// Set username (and move cursor to end)
597
+    pub fn set_username(&mut self, username: String) {
598
+        self.username_cursor = username.chars().count();
599
+        self.username = username;
328600
     }
329601
 
330602
     /// Check if form is ready to submit
331603
     pub fn can_submit(&self) -> bool {
332604
         !self.is_loading && !self.username.is_empty() && !self.password.is_empty()
333605
     }
606
+
607
+    /// Check if a click is on the login button
608
+    pub fn button_contains(&self, click_x: f64, click_y: f64) -> bool {
609
+        let btn_width = 120.0;
610
+        let btn_height = 36.0;
611
+        let btn_x = self.x + (self.width - btn_width) / 2.0;
612
+        let btn_y = self.y + 275.0;
613
+
614
+        click_x >= btn_x
615
+            && click_x <= btn_x + btn_width
616
+            && click_y >= btn_y
617
+            && click_y <= btn_y + btn_height
618
+    }
619
+
620
+    /// Check if mouse is over an input field (for cursor change)
621
+    pub fn is_over_input(&self, mouse_x: f64, mouse_y: f64) -> bool {
622
+        let field_x = self.x + 30.0;
623
+        let field_width = self.width - 60.0;
624
+        let field_height = 40.0;
625
+
626
+        // Username field (y = self.y + 100.0)
627
+        let username_y = self.y + 100.0;
628
+        let over_username = mouse_x >= field_x
629
+            && mouse_x <= field_x + field_width
630
+            && mouse_y >= username_y
631
+            && mouse_y <= username_y + field_height;
632
+
633
+        // Password field (y = self.y + 170.0)
634
+        let password_y = self.y + 170.0;
635
+        let over_password = mouse_x >= field_x
636
+            && mouse_x <= field_x + field_width
637
+            && mouse_y >= password_y
638
+            && mouse_y <= password_y + field_height;
639
+
640
+        over_username || over_password
641
+    }
642
+
643
+    /// Handle click on input field - returns true if click was handled
644
+    pub fn handle_input_click(
645
+        &mut self,
646
+        click_x: f64,
647
+        click_y: f64,
648
+        pango_ctx: &pango::Context,
649
+        font_family: &str,
650
+        font_size: i32,
651
+    ) -> bool {
652
+        let field_x = self.x + 30.0;
653
+        let field_width = self.width - 60.0;
654
+        let field_height = 40.0;
655
+        let text_start_x = field_x + 12.0;
656
+
657
+        // Check username field
658
+        let username_y = self.y + 100.0;
659
+        if click_x >= field_x
660
+            && click_x <= field_x + field_width
661
+            && click_y >= username_y
662
+            && click_y <= username_y + field_height
663
+        {
664
+            self.focused_field = FocusedField::Username;
665
+            self.username_cursor = self.calculate_cursor_pos(
666
+                click_x - text_start_x,
667
+                &self.username,
668
+                pango_ctx,
669
+                font_family,
670
+                font_size,
671
+            );
672
+            return true;
673
+        }
674
+
675
+        // Check password field
676
+        let password_y = self.y + 170.0;
677
+        if click_x >= field_x
678
+            && click_x <= field_x + field_width
679
+            && click_y >= password_y
680
+            && click_y <= password_y + field_height
681
+        {
682
+            self.focused_field = FocusedField::Password;
683
+            // For password, use masked characters for measurement
684
+            let masked = "•".repeat(self.password.len());
685
+            self.password_cursor = self.calculate_cursor_pos(
686
+                click_x - text_start_x,
687
+                &masked,
688
+                pango_ctx,
689
+                font_family,
690
+                font_size,
691
+            );
692
+            return true;
693
+        }
694
+
695
+        false
696
+    }
697
+
698
+    /// Calculate cursor position from click x offset
699
+    fn calculate_cursor_pos(
700
+        &self,
701
+        click_offset: f64,
702
+        text: &str,
703
+        pango_ctx: &pango::Context,
704
+        font_family: &str,
705
+        font_size: i32,
706
+    ) -> usize {
707
+        if text.is_empty() || click_offset <= 0.0 {
708
+            return 0;
709
+        }
710
+
711
+        let mut font = FontDescription::new();
712
+        font.set_family(font_family);
713
+        font.set_size(font_size * pango::SCALE);
714
+
715
+        let chars: Vec<char> = text.chars().collect();
716
+        let mut best_pos = chars.len();
717
+        let mut prev_width = 0.0;
718
+
719
+        for i in 0..=chars.len() {
720
+            let substring: String = chars.iter().take(i).collect();
721
+            let layout = Layout::new(pango_ctx);
722
+            layout.set_font_description(Some(&font));
723
+            layout.set_text(&substring);
724
+            let (width, _) = layout.pixel_size();
725
+            let width = width as f64;
726
+
727
+            // Check if click is closer to this position or the previous one
728
+            if click_offset < width {
729
+                // Click is between prev_width and width
730
+                let mid = (prev_width + width) / 2.0;
731
+                if click_offset < mid {
732
+                    best_pos = if i > 0 { i - 1 } else { 0 };
733
+                } else {
734
+                    best_pos = i;
735
+                }
736
+                break;
737
+            }
738
+            prev_width = width;
739
+        }
740
+
741
+        best_pos
742
+    }
334743
 }