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::{
55
     Connection, EventLoop, EventLoopConfig, Window, WindowConfig, monitor_at_pointer,
66
     primary_monitor,
77
 };
8
+use serde::Deserialize;
9
+use std::collections::HashMap;
10
+use std::path::Path;
811
 use std::time::{Duration, Instant};
912
 use x11rb::connection::Connection as X11Connection;
1013
 use x11rb::protocol::xproto::ConnectionExt;
@@ -17,40 +20,109 @@ const RETRY_ERROR_FLASH_DURATION: Duration = Duration::from_millis(900);
1720
 const DEFAULT_UI_SCALE: f32 = 1.0;
1821
 const MIN_UI_SCALE: f32 = 0.8;
1922
 const MAX_UI_SCALE: f32 = 2.0;
23
+const PROMPT_STRINGS_FILE_ENV: &str = "GARCARD_PROMPT_STRINGS_FILE";
2024
 
21
-#[derive(Debug, Clone, Copy)]
25
+#[derive(Debug, Clone)]
2226
 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,
27
+    title_auth_required: String,
28
+    label_password: String,
29
+    label_response: String,
30
+    footer_wait: String,
31
+    footer_controls: String,
32
+    timeout_label: String,
2933
 }
3034
 
3135
 impl PromptStrings {
3236
     fn for_locale(locale: &str) -> Self {
3337
         match locale_bucket(locale) {
3438
             "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",
39
+                title_auth_required: "Autenticacion requerida".to_string(),
40
+                label_password: "Contrasena".to_string(),
41
+                label_response: "Respuesta".to_string(),
42
+                footer_wait: "Espere".to_string(),
43
+                footer_controls: "Enter enviar   Esc cancelar".to_string(),
44
+                timeout_label: "tiempo".to_string(),
4145
             },
4246
             _ => 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",
47
+                title_auth_required: "Authentication Required".to_string(),
48
+                label_password: "Password".to_string(),
49
+                label_response: "Response".to_string(),
50
+                footer_wait: "Please wait".to_string(),
51
+                footer_controls: "Enter submit   Esc cancel".to_string(),
52
+                timeout_label: "timeout".to_string(),
4953
             },
5054
         }
5155
     }
5256
 }
5357
 
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
+
54126
 #[derive(Debug, Clone, Copy)]
55127
 struct PromptPalette {
56128
     backdrop: Color,
@@ -138,6 +210,13 @@ fn prompt_scale_from_env(raw: Option<&str>) -> f32 {
138210
     parsed.clamp(MIN_UI_SCALE, MAX_UI_SCALE)
139211
 }
140212
 
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
+
141220
 fn parse_bool_env(raw: Option<&str>) -> bool {
142221
     match raw.map(|value| value.trim().to_ascii_lowercase()) {
143222
         Some(value)
@@ -153,6 +232,65 @@ fn parse_bool_env(raw: Option<&str>) -> bool {
153232
     }
154233
 }
155234
 
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
+
156294
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
157295
 pub enum PromptMode {
158296
     Secret,
@@ -300,16 +438,15 @@ impl PromptSession {
300438
             feedback_only: false,
301439
         };
302440
         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;
441
+        let strings = prompt_strings_for_locale(&ui.locale);
442
+        let (dialog_width, dialog_height) = scaled_dialog_dimensions(ui.ui_scale);
306443
 
307444
         let conn = Connection::connect(None).context("failed to connect to X11 display")?;
308445
         let (x, y) = centered_position(&conn, dialog_width, dialog_height);
309446
         let window = Window::create(
310447
             conn.clone(),
311448
             WindowConfig::dialog()
312
-                .title(strings.title_auth_required)
449
+                .title(strings.title_auth_required.as_str())
313450
                 .class("garcard")
314451
                 .position(x, y)
315452
                 .size(dialog_width, dialog_height)
@@ -319,7 +456,7 @@ impl PromptSession {
319456
         .context("failed to create prompt window")?;
320457
         window.focus().context("failed to focus prompt window")?;
321458
 
322
-        let dialog = PromptDialog::new(window, request, &ui)?;
459
+        let dialog = PromptDialog::new(window, request, &ui, strings)?;
323460
         Ok(Self { dialog })
324461
     }
325462
 
@@ -438,7 +575,12 @@ fn centered_position(conn: &Connection, width: u32, height: u32) -> (i32, i32) {
438575
 }
439576
 
440577
 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> {
442584
         let mut theme = Theme::dark();
443585
         theme.font_family = std::env::var("GARCARD_PROMPT_FONT_FAMILY")
444586
             .ok()
@@ -446,7 +588,6 @@ impl PromptDialog {
446588
             .unwrap_or_else(|| "Noto Sans".to_string());
447589
         theme.font_size = (14.0_f64 * ui.ui_scale as f64).max(12.0);
448590
         let palette = PromptPalette::from_high_contrast(ui.high_contrast)?;
449
-        let strings = PromptStrings::for_locale(&ui.locale);
450591
         let size = window.size();
451592
         let renderer = Renderer::with_theme(size.width, size.height, theme)?;
452593
 
@@ -627,7 +768,7 @@ impl PromptDialog {
627768
             .font_size(self.scaled_f64(18.0))
628769
             .color(theme.foreground);
629770
         self.renderer.text(
630
-            self.strings.title_auth_required,
771
+            self.strings.title_auth_required.as_str(),
631772
             (pad + body_inset) as f64,
632773
             (pad + title_top) as f64,
633774
             &title_style,
@@ -648,8 +789,8 @@ impl PromptDialog {
648789
         )?;
649790
 
650791
         let input_label = match self.request.mode {
651
-            PromptMode::Secret => self.strings.label_password,
652
-            PromptMode::Plain => self.strings.label_response,
792
+            PromptMode::Secret => self.strings.label_password.as_str(),
793
+            PromptMode::Plain => self.strings.label_response.as_str(),
653794
         };
654795
         let label_style = TextStyle::new()
655796
             .font_family(theme.font_family.clone())
@@ -722,9 +863,9 @@ impl PromptDialog {
722863
             .font_size(self.scaled_f64(12.0))
723864
             .color(theme.item_description);
724865
         let footer_text = if self.request.feedback_only {
725
-            self.strings.footer_wait
866
+            self.strings.footer_wait.as_str()
726867
         } else {
727
-            self.strings.footer_controls
868
+            self.strings.footer_controls.as_str()
728869
         };
729870
         self.renderer.text(
730871
             footer_text,
@@ -959,6 +1100,21 @@ mod tests {
9591100
         assert_eq!(prompt_scale_from_env(None), DEFAULT_UI_SCALE);
9601101
     }
9611102
 
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
+
9621118
     #[test]
9631119
     fn parse_bool_env_accepts_common_truthy_values() {
9641120
         assert!(parse_bool_env(Some("1")));
@@ -979,6 +1135,32 @@ mod tests {
9791135
         assert_eq!(spanish.footer_wait, "Espere");
9801136
     }
9811137
 
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
+
9821164
     #[test]
9831165
     fn apply_key_event_submits_and_clears_input_on_return() {
9841166
         let mut input = "secret".to_string();