Rust · 25066 bytes Raw Blame History
1 //! Login form widget for the greeter
2 //!
3 //! Renders username/password fields, login button, and handles keyboard input.
4
5 use crate::render::rounded_rectangle;
6 use crate::theme::Theme;
7 use anyhow::Result;
8 use cairo::Context;
9 use pango::{FontDescription, Layout, Weight};
10
11 /// Which field currently has focus
12 #[derive(Clone, Copy, PartialEq, Eq)]
13 pub enum FocusedField {
14 Username,
15 Password,
16 }
17
18 /// Login form widget state and rendering
19 pub struct LoginForm {
20 pub username: String,
21 pub password: String,
22 pub focused_field: FocusedField,
23 pub error_message: Option<String>,
24 pub info_message: Option<String>,
25 pub is_loading: bool,
26 pub cursor_visible: bool,
27
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
36 // Layout dimensions
37 x: f64,
38 y: f64,
39 width: f64,
40 height: f64,
41 }
42
43 impl LoginForm {
44 /// Create a new login form centered at the specified point
45 pub fn new(center_x: f64, center_y: f64) -> Self {
46 let width = 400.0;
47 let height = 320.0;
48
49 Self {
50 username: String::new(),
51 password: String::new(),
52 focused_field: FocusedField::Username,
53 error_message: None,
54 info_message: None,
55 is_loading: false,
56 cursor_visible: true,
57 username_cursor: 0,
58 password_cursor: 0,
59 username_selection: None,
60 password_selection: None,
61 x: center_x - width / 2.0,
62 y: center_y - height / 2.0,
63 width,
64 height,
65 }
66 }
67
68 /// Render the login form
69 pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
70 // Background panel
71 let bg = &theme.panel_background;
72 ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
73 rounded_rectangle(ctx, self.x, self.y, self.width, self.height, theme.corner_radius);
74 ctx.fill()?;
75
76 // Title
77 self.render_title(ctx, pango_ctx, theme)?;
78
79 // Username field
80 self.render_input_field(
81 ctx,
82 pango_ctx,
83 theme,
84 "Username",
85 &self.username,
86 self.y + 100.0,
87 self.focused_field == FocusedField::Username,
88 self.username_cursor,
89 self.username_selection,
90 )?;
91
92 // Password field (masked)
93 let masked_password = "•".repeat(self.password.len());
94 self.render_input_field(
95 ctx,
96 pango_ctx,
97 theme,
98 "Password",
99 &masked_password,
100 self.y + 170.0,
101 self.focused_field == FocusedField::Password,
102 self.password_cursor,
103 self.password_selection,
104 )?;
105
106 // Error message
107 if let Some(ref msg) = self.error_message {
108 let c = &theme.text_error;
109 self.render_message(ctx, pango_ctx, theme, msg, (c.r, c.g, c.b))?;
110 } else if let Some(ref msg) = self.info_message {
111 let c = &theme.text_info;
112 self.render_message(ctx, pango_ctx, theme, msg, (c.r, c.g, c.b))?;
113 }
114
115 // Login button
116 self.render_button(ctx, pango_ctx, theme)?;
117
118 Ok(())
119 }
120
121 fn render_title(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
122 let layout = Layout::new(pango_ctx);
123 let mut font = FontDescription::new();
124 font.set_family(&theme.font_family);
125 font.set_size(theme.font_size_title * pango::SCALE);
126 font.set_weight(Weight::Bold);
127 layout.set_font_description(Some(&font));
128 layout.set_text("Welcome");
129
130 let (text_width, _) = layout.pixel_size();
131
132 let c = &theme.text_primary;
133 ctx.set_source_rgb(c.r, c.g, c.b);
134 ctx.move_to(
135 self.x + (self.width - text_width as f64) / 2.0,
136 self.y + 30.0,
137 );
138 pangocairo::functions::show_layout(ctx, &layout);
139
140 Ok(())
141 }
142
143 fn render_input_field(
144 &self,
145 ctx: &Context,
146 pango_ctx: &pango::Context,
147 theme: &Theme,
148 label: &str,
149 value: &str,
150 y: f64,
151 focused: bool,
152 cursor_pos: usize,
153 selection_anchor: Option<usize>,
154 ) -> Result<()> {
155 let field_x = self.x + 30.0;
156 let field_width = self.width - 60.0;
157 let field_height = 40.0;
158 let text_start_x = field_x + 12.0;
159
160 // Label
161 let mut font = FontDescription::new();
162 font.set_family(&theme.font_family);
163 font.set_size(11 * pango::SCALE);
164
165 let label_layout = Layout::new(pango_ctx);
166 label_layout.set_font_description(Some(&font));
167 label_layout.set_text(label);
168
169 let c = &theme.text_secondary;
170 ctx.set_source_rgba(c.r, c.g, c.b, c.a);
171 ctx.move_to(field_x, y - 18.0);
172 pangocairo::functions::show_layout(ctx, &label_layout);
173
174 // Input box background
175 let bg = if focused {
176 &theme.input_background_focused
177 } else {
178 &theme.input_background
179 };
180 ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
181 rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
182 ctx.fill()?;
183
184 // Input box border (focused only)
185 if focused {
186 let bc = &theme.input_border;
187 ctx.set_source_rgba(bc.r, bc.g, bc.b, bc.a);
188 rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
189 ctx.set_line_width(2.0);
190 ctx.stroke()?;
191 }
192
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
227 // Text value
228 let tc = &theme.text_primary;
229 ctx.set_source_rgb(tc.r, tc.g, tc.b);
230 let value_layout = Layout::new(pango_ctx);
231 value_layout.set_font_description(Some(&font));
232 value_layout.set_text(if value.is_empty() { " " } else { value });
233 ctx.move_to(text_start_x, y + 10.0);
234 pangocairo::functions::show_layout(ctx, &value_layout);
235
236 // Cursor (blinking) - positioned at cursor_pos
237 if focused && self.cursor_visible {
238 let cursor_x = if value.is_empty() || cursor_pos == 0 {
239 text_start_x
240 } else {
241 let text_before_cursor: String = value.chars().take(cursor_pos).collect();
242 text_start_x + measure_text(&text_before_cursor)
243 };
244 ctx.set_source_rgb(tc.r, tc.g, tc.b);
245 ctx.rectangle(cursor_x, y + 8.0, 2.0, 24.0);
246 ctx.fill()?;
247 }
248
249 Ok(())
250 }
251
252 fn render_message(
253 &self,
254 ctx: &Context,
255 pango_ctx: &pango::Context,
256 theme: &Theme,
257 msg: &str,
258 color: (f64, f64, f64),
259 ) -> Result<()> {
260 ctx.set_source_rgb(color.0, color.1, color.2);
261
262 let mut font = FontDescription::new();
263 font.set_family(&theme.font_family);
264 font.set_size(12 * pango::SCALE);
265
266 let layout = Layout::new(pango_ctx);
267 layout.set_font_description(Some(&font));
268 layout.set_text(msg);
269 layout.set_width((self.width - 60.0) as i32 * pango::SCALE);
270
271 ctx.move_to(self.x + 30.0, self.y + 240.0);
272 pangocairo::functions::show_layout(ctx, &layout);
273
274 Ok(())
275 }
276
277 fn render_button(&self, ctx: &Context, pango_ctx: &pango::Context, theme: &Theme) -> Result<()> {
278 let btn_width = 120.0;
279 let btn_height = 36.0;
280 let btn_x = self.x + (self.width - btn_width) / 2.0;
281 let btn_y = self.y + 275.0;
282
283 // Button background
284 let bg = if self.is_loading {
285 &theme.button_background_disabled
286 } else {
287 &theme.button_background
288 };
289 ctx.set_source_rgba(bg.r, bg.g, bg.b, bg.a);
290 rounded_rectangle(ctx, btn_x, btn_y, btn_width, btn_height, 8.0);
291 ctx.fill()?;
292
293 // Button text
294 let tc = &theme.text_primary;
295 ctx.set_source_rgb(tc.r, tc.g, tc.b);
296 let mut font = FontDescription::new();
297 font.set_family(&theme.font_family);
298 font.set_size(theme.font_size_normal * pango::SCALE);
299 font.set_weight(Weight::Bold);
300
301 let layout = Layout::new(pango_ctx);
302 layout.set_font_description(Some(&font));
303 layout.set_text(if self.is_loading { "..." } else { "Login" });
304
305 let (text_w, _) = layout.pixel_size();
306 ctx.move_to(btn_x + (btn_width - text_w as f64) / 2.0, btn_y + 8.0);
307 pangocairo::functions::show_layout(ctx, &layout);
308
309 Ok(())
310 }
311
312 /// Handle a character key press
313 pub fn handle_key(&mut self, key: char) {
314 if self.is_loading {
315 return;
316 }
317
318 // Delete any selected text first
319 self.delete_selection();
320
321 match self.focused_field {
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 }
337 }
338 self.clear_messages();
339 }
340
341 /// Handle backspace key
342 pub fn handle_backspace(&mut self) {
343 if self.is_loading {
344 return;
345 }
346
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
386 match self.focused_field {
387 FocusedField::Username => {
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 }
394 }
395 FocusedField::Password => {
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 }
402 }
403 }
404 self.clear_messages();
405 }
406
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
555 /// Handle tab key (switch focus)
556 pub fn handle_tab(&mut self) {
557 if self.is_loading {
558 return;
559 }
560
561 self.focused_field = match self.focused_field {
562 FocusedField::Username => FocusedField::Password,
563 FocusedField::Password => FocusedField::Username,
564 };
565 }
566
567 /// Toggle cursor visibility for blinking effect
568 pub fn toggle_cursor(&mut self) {
569 self.cursor_visible = !self.cursor_visible;
570 }
571
572 /// Set error message
573 pub fn set_error(&mut self, msg: String) {
574 self.error_message = Some(msg);
575 self.info_message = None;
576 }
577
578 /// Set info message
579 pub fn set_info(&mut self, msg: String) {
580 self.info_message = Some(msg);
581 self.error_message = None;
582 }
583
584 /// Clear all messages
585 pub fn clear_messages(&mut self) {
586 self.error_message = None;
587 self.info_message = None;
588 }
589
590 /// Clear password field
591 pub fn clear_password(&mut self) {
592 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;
600 }
601
602 /// Check if form is ready to submit
603 pub fn can_submit(&self) -> bool {
604 !self.is_loading && !self.username.is_empty() && !self.password.is_empty()
605 }
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 }
743 }
744