gardesk/garcard / b7a4602

Browse files

Add localized and accessible prompt baseline

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b7a46020d11cce4b0d685f5098e6f368b7b6482f
Parents
580139f
Tree
b65e932

1 changed file

StatusFile+-
M garcard/src/prompt_ui.rs 303 66
garcard/src/prompt_ui.rsmodified
@@ -9,11 +9,149 @@ use std::time::{Duration, Instant};
99
 use x11rb::connection::Connection as X11Connection;
1010
 use x11rb::protocol::xproto::ConnectionExt;
1111
 
12
-const DIALOG_WIDTH: u32 = 560;
13
-const DIALOG_HEIGHT: u32 = 240;
14
-const CARD_PADDING: i32 = 18;
12
+const BASE_DIALOG_WIDTH: u32 = 560;
13
+const BASE_DIALOG_HEIGHT: u32 = 240;
14
+const BASE_CARD_PADDING: i32 = 18;
1515
 const ERROR_BLINK_INTERVAL: Duration = Duration::from_millis(180);
1616
 const RETRY_ERROR_FLASH_DURATION: Duration = Duration::from_millis(900);
17
+const DEFAULT_UI_SCALE: f32 = 1.0;
18
+const MIN_UI_SCALE: f32 = 0.8;
19
+const MAX_UI_SCALE: f32 = 2.0;
20
+
21
+#[derive(Debug, Clone, Copy)]
22
+struct PromptStrings {
23
+    title_auth_required: &'static str,
24
+    label_password: &'static str,
25
+    label_response: &'static str,
26
+    footer_wait: &'static str,
27
+    footer_controls: &'static str,
28
+    timeout_label: &'static str,
29
+}
30
+
31
+impl PromptStrings {
32
+    fn for_locale(locale: &str) -> Self {
33
+        match locale_bucket(locale) {
34
+            "es" => Self {
35
+                title_auth_required: "Autenticacion requerida",
36
+                label_password: "Contrasena",
37
+                label_response: "Respuesta",
38
+                footer_wait: "Espere",
39
+                footer_controls: "Enter enviar   Esc cancelar",
40
+                timeout_label: "tiempo",
41
+            },
42
+            _ => Self {
43
+                title_auth_required: "Authentication Required",
44
+                label_password: "Password",
45
+                label_response: "Response",
46
+                footer_wait: "Please wait",
47
+                footer_controls: "Enter submit   Esc cancel",
48
+                timeout_label: "timeout",
49
+            },
50
+        }
51
+    }
52
+}
53
+
54
+#[derive(Debug, Clone, Copy)]
55
+struct PromptPalette {
56
+    backdrop: Color,
57
+    card_background: Color,
58
+    card_border: Color,
59
+    accent: Color,
60
+    success: Color,
61
+    error: Color,
62
+    focus_ring: Color,
63
+}
64
+
65
+impl PromptPalette {
66
+    fn from_high_contrast(high_contrast: bool) -> Result<Self> {
67
+        if high_contrast {
68
+            return Ok(Self {
69
+                backdrop: Color::from_hex("#000000")
70
+                    .context("invalid high-contrast prompt backdrop color")?,
71
+                card_background: Color::from_hex("#050505")
72
+                    .context("invalid high-contrast prompt card background color")?,
73
+                card_border: Color::from_hex("#f2f2f2")
74
+                    .context("invalid high-contrast prompt card border color")?,
75
+                accent: Color::from_hex("#30d5ff")
76
+                    .context("invalid high-contrast prompt accent color")?,
77
+                success: Color::from_hex("#54f07a")
78
+                    .context("invalid high-contrast prompt success color")?,
79
+                error: Color::from_hex("#ff5f6d")
80
+                    .context("invalid high-contrast prompt error color")?,
81
+                focus_ring: Color::from_hex("#ffffff")
82
+                    .context("invalid high-contrast prompt focus ring color")?,
83
+            });
84
+        }
85
+
86
+        Ok(Self {
87
+            backdrop: Color::from_hex("#0a0b10").context("invalid prompt backdrop color")?,
88
+            card_background: Color::from_hex("#111318")
89
+                .context("invalid prompt card background color")?,
90
+            card_border: Color::from_hex("#2c3442").context("invalid prompt card border color")?,
91
+            accent: Color::from_hex("#8ab4f8").context("invalid prompt accent color")?,
92
+            success: Color::from_hex("#41c87a").context("invalid prompt success color")?,
93
+            error: Color::from_hex("#ff5f6d").context("invalid prompt error color")?,
94
+            focus_ring: Color::from_hex("#d5e3ff").context("invalid prompt focus ring color")?,
95
+        })
96
+    }
97
+}
98
+
99
+#[derive(Debug, Clone)]
100
+struct PromptUiConfig {
101
+    locale: String,
102
+    ui_scale: f32,
103
+    high_contrast: bool,
104
+}
105
+
106
+impl PromptUiConfig {
107
+    fn from_env() -> Self {
108
+        let locale = std::env::var("GARCARD_LOCALE")
109
+            .or_else(|_| std::env::var("LANG"))
110
+            .unwrap_or_else(|_| "en_US.UTF-8".to_string());
111
+        let ui_scale = prompt_scale_from_env(std::env::var("GARCARD_PROMPT_SCALE").ok().as_deref());
112
+        let high_contrast = parse_bool_env(
113
+            std::env::var("GARCARD_PROMPT_HIGH_CONTRAST")
114
+                .ok()
115
+                .as_deref(),
116
+        );
117
+        Self {
118
+            locale,
119
+            ui_scale,
120
+            high_contrast,
121
+        }
122
+    }
123
+}
124
+
125
+fn locale_bucket(locale: &str) -> &'static str {
126
+    let normalized = locale.trim().to_ascii_lowercase();
127
+    if normalized.starts_with("es") {
128
+        "es"
129
+    } else {
130
+        "en"
131
+    }
132
+}
133
+
134
+fn prompt_scale_from_env(raw: Option<&str>) -> f32 {
135
+    let parsed = raw
136
+        .and_then(|value| value.trim().parse::<f32>().ok())
137
+        .unwrap_or(DEFAULT_UI_SCALE);
138
+    parsed.clamp(MIN_UI_SCALE, MAX_UI_SCALE)
139
+}
140
+
141
+fn parse_bool_env(raw: Option<&str>) -> bool {
142
+    match raw.map(|value| value.trim().to_ascii_lowercase()) {
143
+        Some(value)
144
+            if value == "1"
145
+                || value == "true"
146
+                || value == "yes"
147
+                || value == "on"
148
+                || value == "enabled" =>
149
+        {
150
+            true
151
+        }
152
+        _ => false,
153
+    }
154
+}
17155
 
18156
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19157
 pub enum PromptMode {
@@ -53,6 +191,9 @@ struct PromptDialog {
53191
     renderer: Renderer,
54192
     gc: u32,
55193
     keymap: Option<X11Keymap>,
194
+    strings: PromptStrings,
195
+    ui_scale: f32,
196
+    card_padding: i32,
56197
     request: PromptRequest,
57198
     input: String,
58199
     cursor: usize,
@@ -65,6 +206,7 @@ struct PromptDialog {
65206
     accent: Color,
66207
     success: Color,
67208
     error: Color,
209
+    focus_ring: Color,
68210
     error_blink_on: bool,
69211
     last_blink_toggle: Instant,
70212
     error_flash_until: Option<Instant>,
@@ -157,23 +299,27 @@ impl PromptSession {
157299
             tone: PromptTone::Default,
158300
             feedback_only: false,
159301
         };
302
+        let ui = PromptUiConfig::from_env();
303
+        let strings = PromptStrings::for_locale(&ui.locale);
304
+        let dialog_width = ((BASE_DIALOG_WIDTH as f32) * ui.ui_scale).round() as u32;
305
+        let dialog_height = ((BASE_DIALOG_HEIGHT as f32) * ui.ui_scale).round() as u32;
160306
 
161307
         let conn = Connection::connect(None).context("failed to connect to X11 display")?;
162
-        let (x, y) = centered_position(&conn, DIALOG_WIDTH, DIALOG_HEIGHT);
308
+        let (x, y) = centered_position(&conn, dialog_width, dialog_height);
163309
         let window = Window::create(
164310
             conn.clone(),
165311
             WindowConfig::dialog()
166
-                .title("garcard authentication")
312
+                .title(strings.title_auth_required)
167313
                 .class("garcard")
168314
                 .position(x, y)
169
-                .size(DIALOG_WIDTH, DIALOG_HEIGHT)
315
+                .size(dialog_width, dialog_height)
170316
                 .transparent(true)
171317
                 .modal(true),
172318
         )
173319
         .context("failed to create prompt window")?;
174320
         window.focus().context("failed to focus prompt window")?;
175321
 
176
-        let dialog = PromptDialog::new(window, request)?;
322
+        let dialog = PromptDialog::new(window, request, &ui)?;
177323
         Ok(Self { dialog })
178324
     }
179325
 
@@ -292,17 +438,15 @@ fn centered_position(conn: &Connection, width: u32, height: u32) -> (i32, i32) {
292438
 }
293439
 
294440
 impl PromptDialog {
295
-    fn new(window: Window, request: PromptRequest) -> Result<Self> {
441
+    fn new(window: Window, request: PromptRequest, ui: &PromptUiConfig) -> Result<Self> {
296442
         let mut theme = Theme::dark();
297
-        theme.font_family = "Sans".to_string();
298
-        theme.font_size = 14.0;
299
-        let backdrop = Color::from_hex("#0a0b10").context("invalid prompt backdrop color")?;
300
-        let card_background =
301
-            Color::from_hex("#111318").context("invalid prompt card background color")?;
302
-        let card_border = Color::from_hex("#2c3442").context("invalid prompt card border color")?;
303
-        let accent = Color::from_hex("#8ab4f8").context("invalid prompt accent color")?;
304
-        let success = Color::from_hex("#41c87a").context("invalid prompt success color")?;
305
-        let error = Color::from_hex("#ff5f6d").context("invalid prompt error color")?;
443
+        theme.font_family = std::env::var("GARCARD_PROMPT_FONT_FAMILY")
444
+            .ok()
445
+            .filter(|value| !value.trim().is_empty())
446
+            .unwrap_or_else(|| "Noto Sans".to_string());
447
+        theme.font_size = (14.0_f64 * ui.ui_scale as f64).max(12.0);
448
+        let palette = PromptPalette::from_high_contrast(ui.high_contrast)?;
449
+        let strings = PromptStrings::for_locale(&ui.locale);
306450
         let size = window.size();
307451
         let renderer = Renderer::with_theme(size.width, size.height, theme)?;
308452
 
@@ -333,24 +477,44 @@ impl PromptDialog {
333477
             renderer,
334478
             gc,
335479
             keymap,
480
+            strings,
481
+            ui_scale: ui.ui_scale,
482
+            card_padding: ((BASE_CARD_PADDING as f32) * ui.ui_scale).round().max(12.0) as i32,
336483
             request,
337484
             input: String::new(),
338485
             cursor: 0,
339486
             exit: None,
340487
             deadline,
341488
             remaining_secs: None,
342
-            backdrop,
343
-            card_background,
344
-            card_border,
345
-            accent,
346
-            success,
347
-            error,
489
+            backdrop: palette.backdrop,
490
+            card_background: palette.card_background,
491
+            card_border: palette.card_border,
492
+            accent: palette.accent,
493
+            success: palette.success,
494
+            error: palette.error,
495
+            focus_ring: palette.focus_ring,
348496
             error_blink_on: true,
349497
             last_blink_toggle: Instant::now(),
350498
             error_flash_until: None,
351499
         })
352500
     }
353501
 
502
+    fn scaled_i32(&self, value: i32) -> i32 {
503
+        ((value as f32) * self.ui_scale).round() as i32
504
+    }
505
+
506
+    fn scaled_u32(&self, value: u32) -> u32 {
507
+        ((value as f32) * self.ui_scale).round().max(1.0) as u32
508
+    }
509
+
510
+    fn scaled_f64(&self, value: f64) -> f64 {
511
+        value * self.ui_scale as f64
512
+    }
513
+
514
+    fn timeout_text(&self, remaining_secs: u64) -> String {
515
+        format!("{} {}s", self.strings.timeout_label, remaining_secs)
516
+    }
517
+
354518
     fn refresh_timeout(&mut self) -> bool {
355519
         let mut changed = false;
356520
         let now = Instant::now();
@@ -467,6 +631,16 @@ impl PromptDialog {
467631
         let width = size.width as i32;
468632
         let height = size.height as i32;
469633
         let theme = self.renderer.theme().clone();
634
+        let pad = self.card_padding;
635
+        let body_inset = self.scaled_i32(16);
636
+        let title_top = self.scaled_i32(14);
637
+        let message_top = self.scaled_i32(46);
638
+        let label_top = self.scaled_i32(108);
639
+        let input_top = self.scaled_i32(126);
640
+        let input_height = self.scaled_u32(40);
641
+        let input_inner_x = self.scaled_i32(10);
642
+        let input_inner_y = self.scaled_i32(12);
643
+        let footer_top_offset = self.scaled_i32(26);
470644
         let accent = match self.request.tone {
471645
             PromptTone::Default => self.accent,
472646
             PromptTone::Success => self.success,
@@ -482,74 +656,97 @@ impl PromptDialog {
482656
         self.renderer.clear_color(self.backdrop)?;
483657
 
484658
         let card_rect = Rect::new(
485
-            CARD_PADDING,
486
-            CARD_PADDING,
487
-            (width - CARD_PADDING * 2) as u32,
488
-            (height - CARD_PADDING * 2) as u32,
659
+            pad,
660
+            pad,
661
+            (width - pad * 2) as u32,
662
+            (height - pad * 2) as u32,
489663
         );
490664
         self.renderer
491
-            .fill_rounded_rect(card_rect, 12.0, self.card_background)?;
492
-        self.renderer
493
-            .stroke_rounded_rect(card_rect, 12.0, self.card_border, 2.0)?;
665
+            .fill_rounded_rect(card_rect, self.scaled_f64(12.0), self.card_background)?;
666
+        self.renderer.stroke_rounded_rect(
667
+            card_rect,
668
+            self.scaled_f64(12.0),
669
+            self.card_border,
670
+            self.scaled_f64(2.0),
671
+        )?;
494672
 
495673
         let title_style = TextStyle::new()
496674
             .font_family(theme.font_family.clone())
497
-            .font_size(18.0)
675
+            .font_size(self.scaled_f64(18.0))
498676
             .color(theme.foreground);
499677
         self.renderer.text(
500
-            "Authentication Required",
501
-            (CARD_PADDING + 16) as f64,
502
-            (CARD_PADDING + 14) as f64,
678
+            self.strings.title_auth_required,
679
+            (pad + body_inset) as f64,
680
+            (pad + title_top) as f64,
503681
             &title_style,
504682
         )?;
505683
 
506684
         let message_style = TextStyle::new()
507685
             .font_family(theme.font_family.clone())
508
-            .font_size(13.0)
686
+            .font_size(self.scaled_f64(13.0))
509687
             .color(theme.item_description)
510
-            .max_width(card_rect.width as i32 - 32)
688
+            .max_width(card_rect.width as i32 - body_inset * 2)
511689
             .wrap(true)
512690
             .ellipsize(true);
513691
         self.renderer.text(
514692
             &self.request.message,
515
-            (CARD_PADDING + 16) as f64,
516
-            (CARD_PADDING + 46) as f64,
693
+            (pad + body_inset) as f64,
694
+            (pad + message_top) as f64,
517695
             &message_style,
518696
         )?;
519697
 
520698
         let input_label = match self.request.mode {
521
-            PromptMode::Secret => "Password",
522
-            PromptMode::Plain => "Response",
699
+            PromptMode::Secret => self.strings.label_password,
700
+            PromptMode::Plain => self.strings.label_response,
523701
         };
524702
         let label_style = TextStyle::new()
525703
             .font_family(theme.font_family.clone())
526
-            .font_size(12.0)
704
+            .font_size(self.scaled_f64(12.0))
527705
             .color(theme.input_placeholder);
528706
         self.renderer.text(
529707
             input_label,
530
-            (CARD_PADDING + 16) as f64,
531
-            (CARD_PADDING + 108) as f64,
708
+            (pad + body_inset) as f64,
709
+            (pad + label_top) as f64,
532710
             &label_style,
533711
         )?;
534712
 
535713
         let input_rect = Rect::new(
536
-            CARD_PADDING + 16,
537
-            CARD_PADDING + 126,
538
-            (width - (CARD_PADDING + 16) * 2) as u32,
539
-            40,
714
+            pad + body_inset,
715
+            pad + input_top,
716
+            (width - (pad + body_inset) * 2) as u32,
717
+            input_height,
540718
         );
541
-        self.renderer
542
-            .fill_rounded_rect(input_rect, 8.0, theme.input_background)?;
543
-        self.renderer
544
-            .stroke_rounded_rect(input_rect, 8.0, accent.with_alpha(0.7), 1.5)?;
719
+        let focus_rect = Rect::new(
720
+            input_rect.x - self.scaled_i32(2),
721
+            input_rect.y - self.scaled_i32(2),
722
+            input_rect.width + self.scaled_u32(4),
723
+            input_rect.height + self.scaled_u32(4),
724
+        );
725
+        self.renderer.stroke_rounded_rect(
726
+            focus_rect,
727
+            self.scaled_f64(10.0),
728
+            self.focus_ring.with_alpha(0.75),
729
+            self.scaled_f64(1.2),
730
+        )?;
731
+        self.renderer.fill_rounded_rect(
732
+            input_rect,
733
+            self.scaled_f64(8.0),
734
+            theme.input_background,
735
+        )?;
736
+        self.renderer.stroke_rounded_rect(
737
+            input_rect,
738
+            self.scaled_f64(8.0),
739
+            accent.with_alpha(0.85),
740
+            self.scaled_f64(1.5),
741
+        )?;
545742
 
546743
         let display_input = display_value(&self.input, self.request.mode);
547744
         let input_style = TextStyle::new()
548745
             .font_family(theme.font_family.clone())
549
-            .font_size(14.0)
746
+            .font_size(self.scaled_f64(14.0))
550747
             .color(theme.input_foreground);
551
-        let input_y = input_rect.y + 12;
552
-        let input_x = input_rect.x + 10;
748
+        let input_y = input_rect.y + input_inner_y;
749
+        let input_x = input_rect.x + input_inner_x;
553750
         self.renderer
554751
             .text(&display_input, input_x as f64, input_y as f64, &input_style)?;
555752
 
@@ -558,38 +755,43 @@ impl PromptDialog {
558755
             let cursor_size = self.renderer.measure_text(&cursor_prefix, &input_style)?;
559756
             let cursor_x = input_x + cursor_size.width as i32;
560757
             self.renderer.fill_rect(
561
-                Rect::new(cursor_x, input_rect.y + 8, 2, input_rect.height - 16),
758
+                Rect::new(
759
+                    cursor_x,
760
+                    input_rect.y + self.scaled_i32(8),
761
+                    self.scaled_u32(2),
762
+                    input_rect.height.saturating_sub(self.scaled_u32(16)),
763
+                ),
562764
                 accent,
563765
             )?;
564766
         }
565767
 
566768
         let footer_style = TextStyle::new()
567
-            .font_family(theme.font_family)
568
-            .font_size(12.0)
769
+            .font_family(theme.font_family.clone())
770
+            .font_size(self.scaled_f64(12.0))
569771
             .color(theme.item_description);
570772
         let footer_text = if self.request.feedback_only {
571
-            "Please wait"
773
+            self.strings.footer_wait
572774
         } else {
573
-            "Enter submit   Esc cancel"
775
+            self.strings.footer_controls
574776
         };
575777
         self.renderer.text(
576778
             footer_text,
577
-            (CARD_PADDING + 16) as f64,
578
-            (height - CARD_PADDING - 26) as f64,
779
+            (pad + body_inset) as f64,
780
+            (height - pad - footer_top_offset) as f64,
579781
             &footer_style,
580782
         )?;
581783
 
582784
         if let Some(remaining) = self.remaining_secs {
583
-            let timer_text = format!("timeout {}s", remaining);
785
+            let timer_text = self.timeout_text(remaining);
584786
             let timer_style = TextStyle::new()
585
-                .font_family("Sans")
586
-                .font_size(12.0)
787
+                .font_family(theme.font_family.clone())
788
+                .font_size(self.scaled_f64(12.0))
587789
                 .color(accent);
588790
             let timer_size = self.renderer.measure_text(&timer_text, &timer_style)?;
589791
             self.renderer.text(
590792
                 &timer_text,
591
-                (width - CARD_PADDING - timer_size.width as i32 - 16) as f64,
592
-                (height - CARD_PADDING - 26) as f64,
793
+                (width - pad - timer_size.width as i32 - body_inset) as f64,
794
+                (height - pad - footer_top_offset) as f64,
593795
                 &timer_style,
594796
             )?;
595797
         }
@@ -712,4 +914,39 @@ mod tests {
712914
         assert_eq!(keysym_to_char(0x0100_03B1), Some('α'));
713915
         assert_eq!(keysym_to_char(0), None);
714916
     }
917
+
918
+    #[test]
919
+    fn locale_bucket_supports_spanish_and_defaults_to_english() {
920
+        assert_eq!(locale_bucket("es_ES.UTF-8"), "es");
921
+        assert_eq!(locale_bucket("en_US.UTF-8"), "en");
922
+        assert_eq!(locale_bucket("C"), "en");
923
+    }
924
+
925
+    #[test]
926
+    fn prompt_scale_from_env_clamps_range() {
927
+        assert_eq!(prompt_scale_from_env(Some("0.2")), MIN_UI_SCALE);
928
+        assert_eq!(prompt_scale_from_env(Some("1.5")), 1.5);
929
+        assert_eq!(prompt_scale_from_env(Some("9.9")), MAX_UI_SCALE);
930
+        assert_eq!(prompt_scale_from_env(None), DEFAULT_UI_SCALE);
931
+    }
932
+
933
+    #[test]
934
+    fn parse_bool_env_accepts_common_truthy_values() {
935
+        assert!(parse_bool_env(Some("1")));
936
+        assert!(parse_bool_env(Some("TRUE")));
937
+        assert!(parse_bool_env(Some("enabled")));
938
+        assert!(!parse_bool_env(Some("0")));
939
+        assert!(!parse_bool_env(None));
940
+    }
941
+
942
+    #[test]
943
+    fn prompt_strings_localize_known_labels() {
944
+        let english = PromptStrings::for_locale("en_US.UTF-8");
945
+        assert_eq!(english.title_auth_required, "Authentication Required");
946
+        assert_eq!(english.footer_controls, "Enter submit   Esc cancel");
947
+
948
+        let spanish = PromptStrings::for_locale("es_ES.UTF-8");
949
+        assert_eq!(spanish.label_password, "Contrasena");
950
+        assert_eq!(spanish.footer_wait, "Espere");
951
+    }
715952
 }