gardesk/garcard / 144bd05

Browse files

Add prompt string catalog override path

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
144bd0542dba5b5392766e2f8c59c0df3c70e8cb
Parents
efcb112
Tree
fddb572

1 changed file

StatusFile+-
M garcard/src/prompt_ui.rs 213 31
garcard/src/prompt_ui.rsmodified
@@ -5,6 +5,9 @@ use gartk_x11::{
5
     Connection, EventLoop, EventLoopConfig, Window, WindowConfig, monitor_at_pointer,
5
     Connection, EventLoop, EventLoopConfig, Window, WindowConfig, monitor_at_pointer,
6
     primary_monitor,
6
     primary_monitor,
7
 };
7
 };
8
+use serde::Deserialize;
9
+use std::collections::HashMap;
10
+use std::path::Path;
8
 use std::time::{Duration, Instant};
11
 use std::time::{Duration, Instant};
9
 use x11rb::connection::Connection as X11Connection;
12
 use x11rb::connection::Connection as X11Connection;
10
 use x11rb::protocol::xproto::ConnectionExt;
13
 use x11rb::protocol::xproto::ConnectionExt;
@@ -17,40 +20,109 @@ const RETRY_ERROR_FLASH_DURATION: Duration = Duration::from_millis(900);
17
 const DEFAULT_UI_SCALE: f32 = 1.0;
20
 const DEFAULT_UI_SCALE: f32 = 1.0;
18
 const MIN_UI_SCALE: f32 = 0.8;
21
 const MIN_UI_SCALE: f32 = 0.8;
19
 const MAX_UI_SCALE: f32 = 2.0;
22
 const MAX_UI_SCALE: f32 = 2.0;
23
+const PROMPT_STRINGS_FILE_ENV: &str = "GARCARD_PROMPT_STRINGS_FILE";
20
 
24
 
21
-#[derive(Debug, Clone, Copy)]
25
+#[derive(Debug, Clone)]
22
 struct PromptStrings {
26
 struct PromptStrings {
23
-    title_auth_required: &'static str,
27
+    title_auth_required: String,
24
-    label_password: &'static str,
28
+    label_password: String,
25
-    label_response: &'static str,
29
+    label_response: String,
26
-    footer_wait: &'static str,
30
+    footer_wait: String,
27
-    footer_controls: &'static str,
31
+    footer_controls: String,
28
-    timeout_label: &'static str,
32
+    timeout_label: String,
29
 }
33
 }
30
 
34
 
31
 impl PromptStrings {
35
 impl PromptStrings {
32
     fn for_locale(locale: &str) -> Self {
36
     fn for_locale(locale: &str) -> Self {
33
         match locale_bucket(locale) {
37
         match locale_bucket(locale) {
34
             "es" => Self {
38
             "es" => Self {
35
-                title_auth_required: "Autenticacion requerida",
39
+                title_auth_required: "Autenticacion requerida".to_string(),
36
-                label_password: "Contrasena",
40
+                label_password: "Contrasena".to_string(),
37
-                label_response: "Respuesta",
41
+                label_response: "Respuesta".to_string(),
38
-                footer_wait: "Espere",
42
+                footer_wait: "Espere".to_string(),
39
-                footer_controls: "Enter enviar   Esc cancelar",
43
+                footer_controls: "Enter enviar   Esc cancelar".to_string(),
40
-                timeout_label: "tiempo",
44
+                timeout_label: "tiempo".to_string(),
41
             },
45
             },
42
             _ => Self {
46
             _ => Self {
43
-                title_auth_required: "Authentication Required",
47
+                title_auth_required: "Authentication Required".to_string(),
44
-                label_password: "Password",
48
+                label_password: "Password".to_string(),
45
-                label_response: "Response",
49
+                label_response: "Response".to_string(),
46
-                footer_wait: "Please wait",
50
+                footer_wait: "Please wait".to_string(),
47
-                footer_controls: "Enter submit   Esc cancel",
51
+                footer_controls: "Enter submit   Esc cancel".to_string(),
48
-                timeout_label: "timeout",
52
+                timeout_label: "timeout".to_string(),
49
             },
53
             },
50
         }
54
         }
51
     }
55
     }
52
 }
56
 }
53
 
57
 
58
+#[derive(Debug, Clone, Default, Deserialize)]
59
+struct PromptStringsPatch {
60
+    #[serde(default)]
61
+    title_auth_required: Option<String>,
62
+    #[serde(default)]
63
+    label_password: Option<String>,
64
+    #[serde(default)]
65
+    label_response: Option<String>,
66
+    #[serde(default)]
67
+    footer_wait: Option<String>,
68
+    #[serde(default)]
69
+    footer_controls: Option<String>,
70
+    #[serde(default)]
71
+    timeout_label: Option<String>,
72
+}
73
+
74
+impl PromptStringsPatch {
75
+    fn overlay_from(&mut self, other: &Self) {
76
+        if other.title_auth_required.is_some() {
77
+            self.title_auth_required = other.title_auth_required.clone();
78
+        }
79
+        if other.label_password.is_some() {
80
+            self.label_password = other.label_password.clone();
81
+        }
82
+        if other.label_response.is_some() {
83
+            self.label_response = other.label_response.clone();
84
+        }
85
+        if other.footer_wait.is_some() {
86
+            self.footer_wait = other.footer_wait.clone();
87
+        }
88
+        if other.footer_controls.is_some() {
89
+            self.footer_controls = other.footer_controls.clone();
90
+        }
91
+        if other.timeout_label.is_some() {
92
+            self.timeout_label = other.timeout_label.clone();
93
+        }
94
+    }
95
+
96
+    fn apply_to(&self, strings: &mut PromptStrings) {
97
+        if let Some(value) = &self.title_auth_required {
98
+            strings.title_auth_required = value.clone();
99
+        }
100
+        if let Some(value) = &self.label_password {
101
+            strings.label_password = value.clone();
102
+        }
103
+        if let Some(value) = &self.label_response {
104
+            strings.label_response = value.clone();
105
+        }
106
+        if let Some(value) = &self.footer_wait {
107
+            strings.footer_wait = value.clone();
108
+        }
109
+        if let Some(value) = &self.footer_controls {
110
+            strings.footer_controls = value.clone();
111
+        }
112
+        if let Some(value) = &self.timeout_label {
113
+            strings.timeout_label = value.clone();
114
+        }
115
+    }
116
+}
117
+
118
+#[derive(Debug, Clone, Default, Deserialize)]
119
+struct PromptStringsCatalog {
120
+    #[serde(default)]
121
+    default: PromptStringsPatch,
122
+    #[serde(default)]
123
+    locales: HashMap<String, PromptStringsPatch>,
124
+}
125
+
54
 #[derive(Debug, Clone, Copy)]
126
 #[derive(Debug, Clone, Copy)]
55
 struct PromptPalette {
127
 struct PromptPalette {
56
     backdrop: Color,
128
     backdrop: Color,
@@ -138,6 +210,13 @@ fn prompt_scale_from_env(raw: Option<&str>) -> f32 {
138
     parsed.clamp(MIN_UI_SCALE, MAX_UI_SCALE)
210
     parsed.clamp(MIN_UI_SCALE, MAX_UI_SCALE)
139
 }
211
 }
140
 
212
 
213
+fn scaled_dialog_dimensions(scale: f32) -> (u32, u32) {
214
+    let clamped = scale.clamp(MIN_UI_SCALE, MAX_UI_SCALE);
215
+    let width = ((BASE_DIALOG_WIDTH as f32) * clamped).round().max(320.0) as u32;
216
+    let height = ((BASE_DIALOG_HEIGHT as f32) * clamped).round().max(180.0) as u32;
217
+    (width, height)
218
+}
219
+
141
 fn parse_bool_env(raw: Option<&str>) -> bool {
220
 fn parse_bool_env(raw: Option<&str>) -> bool {
142
     match raw.map(|value| value.trim().to_ascii_lowercase()) {
221
     match raw.map(|value| value.trim().to_ascii_lowercase()) {
143
         Some(value)
222
         Some(value)
@@ -153,6 +232,65 @@ fn parse_bool_env(raw: Option<&str>) -> bool {
153
     }
232
     }
154
 }
233
 }
155
 
234
 
235
+fn parse_prompt_strings_catalog(raw: &str) -> Result<PromptStringsCatalog> {
236
+    serde_json::from_str(raw)
237
+        .or_else(|json_err| {
238
+            toml::from_str(raw).map_err(|toml_err| {
239
+                anyhow::anyhow!(
240
+                    "failed to parse prompt strings catalog as json ({}) or toml ({})",
241
+                    json_err,
242
+                    toml_err
243
+                )
244
+            })
245
+        })
246
+        .context("failed to parse prompt strings catalog")
247
+}
248
+
249
+fn load_prompt_strings_catalog(path: &Path) -> Result<PromptStringsCatalog> {
250
+    let raw = std::fs::read_to_string(path)
251
+        .with_context(|| format!("failed to read prompt strings catalog {}", path.display()))?;
252
+    parse_prompt_strings_catalog(&raw)
253
+}
254
+
255
+fn prompt_strings_with_catalog(
256
+    locale: &str,
257
+    catalog: Option<&PromptStringsCatalog>,
258
+) -> PromptStrings {
259
+    let mut strings = PromptStrings::for_locale(locale);
260
+    let Some(catalog) = catalog else {
261
+        return strings;
262
+    };
263
+
264
+    let mut patch = catalog.default.clone();
265
+    if let Some(locale_patch) = catalog.locales.get(locale_bucket(locale)) {
266
+        patch.overlay_from(locale_patch);
267
+    }
268
+    if let Some(locale_patch) = catalog.locales.get(locale) {
269
+        patch.overlay_from(locale_patch);
270
+    }
271
+    patch.apply_to(&mut strings);
272
+    strings
273
+}
274
+
275
+fn prompt_strings_for_locale(locale: &str) -> PromptStrings {
276
+    let catalog = std::env::var_os(PROMPT_STRINGS_FILE_ENV)
277
+        .map(std::path::PathBuf::from)
278
+        .and_then(|path| match load_prompt_strings_catalog(&path) {
279
+            Ok(catalog) => Some(catalog),
280
+            Err(err) => {
281
+                tracing::warn!(
282
+                    path = %path.display(),
283
+                    error = %err,
284
+                    env_key = PROMPT_STRINGS_FILE_ENV,
285
+                    "Failed to load prompt string catalog; using built-in strings"
286
+                );
287
+                None
288
+            }
289
+        });
290
+
291
+    prompt_strings_with_catalog(locale, catalog.as_ref())
292
+}
293
+
156
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
294
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
157
 pub enum PromptMode {
295
 pub enum PromptMode {
158
     Secret,
296
     Secret,
@@ -300,16 +438,15 @@ impl PromptSession {
300
             feedback_only: false,
438
             feedback_only: false,
301
         };
439
         };
302
         let ui = PromptUiConfig::from_env();
440
         let ui = PromptUiConfig::from_env();
303
-        let strings = PromptStrings::for_locale(&ui.locale);
441
+        let strings = prompt_strings_for_locale(&ui.locale);
304
-        let dialog_width = ((BASE_DIALOG_WIDTH as f32) * ui.ui_scale).round() as u32;
442
+        let (dialog_width, dialog_height) = scaled_dialog_dimensions(ui.ui_scale);
305
-        let dialog_height = ((BASE_DIALOG_HEIGHT as f32) * ui.ui_scale).round() as u32;
306
 
443
 
307
         let conn = Connection::connect(None).context("failed to connect to X11 display")?;
444
         let conn = Connection::connect(None).context("failed to connect to X11 display")?;
308
         let (x, y) = centered_position(&conn, dialog_width, dialog_height);
445
         let (x, y) = centered_position(&conn, dialog_width, dialog_height);
309
         let window = Window::create(
446
         let window = Window::create(
310
             conn.clone(),
447
             conn.clone(),
311
             WindowConfig::dialog()
448
             WindowConfig::dialog()
312
-                .title(strings.title_auth_required)
449
+                .title(strings.title_auth_required.as_str())
313
                 .class("garcard")
450
                 .class("garcard")
314
                 .position(x, y)
451
                 .position(x, y)
315
                 .size(dialog_width, dialog_height)
452
                 .size(dialog_width, dialog_height)
@@ -319,7 +456,7 @@ impl PromptSession {
319
         .context("failed to create prompt window")?;
456
         .context("failed to create prompt window")?;
320
         window.focus().context("failed to focus prompt window")?;
457
         window.focus().context("failed to focus prompt window")?;
321
 
458
 
322
-        let dialog = PromptDialog::new(window, request, &ui)?;
459
+        let dialog = PromptDialog::new(window, request, &ui, strings)?;
323
         Ok(Self { dialog })
460
         Ok(Self { dialog })
324
     }
461
     }
325
 
462
 
@@ -438,7 +575,12 @@ fn centered_position(conn: &Connection, width: u32, height: u32) -> (i32, i32) {
438
 }
575
 }
439
 
576
 
440
 impl PromptDialog {
577
 impl PromptDialog {
441
-    fn new(window: Window, request: PromptRequest, ui: &PromptUiConfig) -> Result<Self> {
578
+    fn new(
579
+        window: Window,
580
+        request: PromptRequest,
581
+        ui: &PromptUiConfig,
582
+        strings: PromptStrings,
583
+    ) -> Result<Self> {
442
         let mut theme = Theme::dark();
584
         let mut theme = Theme::dark();
443
         theme.font_family = std::env::var("GARCARD_PROMPT_FONT_FAMILY")
585
         theme.font_family = std::env::var("GARCARD_PROMPT_FONT_FAMILY")
444
             .ok()
586
             .ok()
@@ -446,7 +588,6 @@ impl PromptDialog {
446
             .unwrap_or_else(|| "Noto Sans".to_string());
588
             .unwrap_or_else(|| "Noto Sans".to_string());
447
         theme.font_size = (14.0_f64 * ui.ui_scale as f64).max(12.0);
589
         theme.font_size = (14.0_f64 * ui.ui_scale as f64).max(12.0);
448
         let palette = PromptPalette::from_high_contrast(ui.high_contrast)?;
590
         let palette = PromptPalette::from_high_contrast(ui.high_contrast)?;
449
-        let strings = PromptStrings::for_locale(&ui.locale);
450
         let size = window.size();
591
         let size = window.size();
451
         let renderer = Renderer::with_theme(size.width, size.height, theme)?;
592
         let renderer = Renderer::with_theme(size.width, size.height, theme)?;
452
 
593
 
@@ -627,7 +768,7 @@ impl PromptDialog {
627
             .font_size(self.scaled_f64(18.0))
768
             .font_size(self.scaled_f64(18.0))
628
             .color(theme.foreground);
769
             .color(theme.foreground);
629
         self.renderer.text(
770
         self.renderer.text(
630
-            self.strings.title_auth_required,
771
+            self.strings.title_auth_required.as_str(),
631
             (pad + body_inset) as f64,
772
             (pad + body_inset) as f64,
632
             (pad + title_top) as f64,
773
             (pad + title_top) as f64,
633
             &title_style,
774
             &title_style,
@@ -648,8 +789,8 @@ impl PromptDialog {
648
         )?;
789
         )?;
649
 
790
 
650
         let input_label = match self.request.mode {
791
         let input_label = match self.request.mode {
651
-            PromptMode::Secret => self.strings.label_password,
792
+            PromptMode::Secret => self.strings.label_password.as_str(),
652
-            PromptMode::Plain => self.strings.label_response,
793
+            PromptMode::Plain => self.strings.label_response.as_str(),
653
         };
794
         };
654
         let label_style = TextStyle::new()
795
         let label_style = TextStyle::new()
655
             .font_family(theme.font_family.clone())
796
             .font_family(theme.font_family.clone())
@@ -722,9 +863,9 @@ impl PromptDialog {
722
             .font_size(self.scaled_f64(12.0))
863
             .font_size(self.scaled_f64(12.0))
723
             .color(theme.item_description);
864
             .color(theme.item_description);
724
         let footer_text = if self.request.feedback_only {
865
         let footer_text = if self.request.feedback_only {
725
-            self.strings.footer_wait
866
+            self.strings.footer_wait.as_str()
726
         } else {
867
         } else {
727
-            self.strings.footer_controls
868
+            self.strings.footer_controls.as_str()
728
         };
869
         };
729
         self.renderer.text(
870
         self.renderer.text(
730
             footer_text,
871
             footer_text,
@@ -959,6 +1100,21 @@ mod tests {
959
         assert_eq!(prompt_scale_from_env(None), DEFAULT_UI_SCALE);
1100
         assert_eq!(prompt_scale_from_env(None), DEFAULT_UI_SCALE);
960
     }
1101
     }
961
 
1102
 
1103
+    #[test]
1104
+    fn scaled_dialog_dimensions_track_scale_bounds() {
1105
+        let (small_w, small_h) = scaled_dialog_dimensions(0.1);
1106
+        assert!(small_w >= 320);
1107
+        assert!(small_h >= 180);
1108
+
1109
+        let (normal_w, normal_h) = scaled_dialog_dimensions(1.0);
1110
+        assert_eq!(normal_w, BASE_DIALOG_WIDTH);
1111
+        assert_eq!(normal_h, BASE_DIALOG_HEIGHT);
1112
+
1113
+        let (large_w, large_h) = scaled_dialog_dimensions(2.5);
1114
+        assert!(large_w > normal_w);
1115
+        assert!(large_h > normal_h);
1116
+    }
1117
+
962
     #[test]
1118
     #[test]
963
     fn parse_bool_env_accepts_common_truthy_values() {
1119
     fn parse_bool_env_accepts_common_truthy_values() {
964
         assert!(parse_bool_env(Some("1")));
1120
         assert!(parse_bool_env(Some("1")));
@@ -979,6 +1135,32 @@ mod tests {
979
         assert_eq!(spanish.footer_wait, "Espere");
1135
         assert_eq!(spanish.footer_wait, "Espere");
980
     }
1136
     }
981
 
1137
 
1138
+    #[test]
1139
+    fn parse_prompt_strings_catalog_reads_json_and_applies_locale_overrides() {
1140
+        let raw = r#"{
1141
+          "default": { "footer_controls": "Enter send   Esc dismiss" },
1142
+          "locales": {
1143
+            "es": { "label_password": "Clave" },
1144
+            "es_MX.UTF-8": { "footer_wait": "Aguarde" }
1145
+          }
1146
+        }"#;
1147
+        let catalog = parse_prompt_strings_catalog(raw).expect("parse catalog");
1148
+        let strings = prompt_strings_with_catalog("es_MX.UTF-8", Some(&catalog));
1149
+        assert_eq!(strings.label_password, "Clave");
1150
+        assert_eq!(strings.footer_wait, "Aguarde");
1151
+        assert_eq!(strings.footer_controls, "Enter send   Esc dismiss");
1152
+    }
1153
+
1154
+    #[test]
1155
+    fn high_contrast_palette_uses_strong_focus_ring() {
1156
+        let normal = PromptPalette::from_high_contrast(false).expect("normal palette");
1157
+        let high = PromptPalette::from_high_contrast(true).expect("high contrast palette");
1158
+        assert!(high.focus_ring.r >= 0.99);
1159
+        assert!(high.focus_ring.g >= 0.99);
1160
+        assert!(high.focus_ring.b >= 0.99);
1161
+        assert!(high.card_border.r > normal.card_border.r);
1162
+    }
1163
+
982
     #[test]
1164
     #[test]
983
     fn apply_key_event_submits_and_clears_input_on_return() {
1165
     fn apply_key_event_submits_and_clears_input_on_return() {
984
         let mut input = "secret".to_string();
1166
         let mut input = "secret".to_string();