@@ -5,6 +5,9 @@ use gartk_x11::{ |
| 5 | 5 | Connection, EventLoop, EventLoopConfig, Window, WindowConfig, monitor_at_pointer, |
| 6 | 6 | primary_monitor, |
| 7 | 7 | }; |
| 8 | +use serde::Deserialize; |
| 9 | +use std::collections::HashMap; |
| 10 | +use std::path::Path; |
| 8 | 11 | use std::time::{Duration, Instant}; |
| 9 | 12 | use x11rb::connection::Connection as X11Connection; |
| 10 | 13 | use x11rb::protocol::xproto::ConnectionExt; |
@@ -17,40 +20,109 @@ const RETRY_ERROR_FLASH_DURATION: Duration = Duration::from_millis(900); |
| 17 | 20 | const DEFAULT_UI_SCALE: f32 = 1.0; |
| 18 | 21 | const MIN_UI_SCALE: f32 = 0.8; |
| 19 | 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 | 26 | 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, |
| 29 | 33 | } |
| 30 | 34 | |
| 31 | 35 | impl PromptStrings { |
| 32 | 36 | fn for_locale(locale: &str) -> Self { |
| 33 | 37 | match locale_bucket(locale) { |
| 34 | 38 | "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(), |
| 41 | 45 | }, |
| 42 | 46 | _ => 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(), |
| 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 | 126 | #[derive(Debug, Clone, Copy)] |
| 55 | 127 | struct PromptPalette { |
| 56 | 128 | backdrop: Color, |
@@ -138,6 +210,13 @@ fn prompt_scale_from_env(raw: Option<&str>) -> f32 { |
| 138 | 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 | 220 | fn parse_bool_env(raw: Option<&str>) -> bool { |
| 142 | 221 | match raw.map(|value| value.trim().to_ascii_lowercase()) { |
| 143 | 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 | 294 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 157 | 295 | pub enum PromptMode { |
| 158 | 296 | Secret, |
@@ -300,16 +438,15 @@ impl PromptSession { |
| 300 | 438 | feedback_only: false, |
| 301 | 439 | }; |
| 302 | 440 | 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); |
| 306 | 443 | |
| 307 | 444 | let conn = Connection::connect(None).context("failed to connect to X11 display")?; |
| 308 | 445 | let (x, y) = centered_position(&conn, dialog_width, dialog_height); |
| 309 | 446 | let window = Window::create( |
| 310 | 447 | conn.clone(), |
| 311 | 448 | WindowConfig::dialog() |
| 312 | | - .title(strings.title_auth_required) |
| 449 | + .title(strings.title_auth_required.as_str()) |
| 313 | 450 | .class("garcard") |
| 314 | 451 | .position(x, y) |
| 315 | 452 | .size(dialog_width, dialog_height) |
@@ -319,7 +456,7 @@ impl PromptSession { |
| 319 | 456 | .context("failed to create prompt window")?; |
| 320 | 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 | 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 | 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 | 584 | let mut theme = Theme::dark(); |
| 443 | 585 | theme.font_family = std::env::var("GARCARD_PROMPT_FONT_FAMILY") |
| 444 | 586 | .ok() |
@@ -446,7 +588,6 @@ impl PromptDialog { |
| 446 | 588 | .unwrap_or_else(|| "Noto Sans".to_string()); |
| 447 | 589 | theme.font_size = (14.0_f64 * ui.ui_scale as f64).max(12.0); |
| 448 | 590 | let palette = PromptPalette::from_high_contrast(ui.high_contrast)?; |
| 449 | | - let strings = PromptStrings::for_locale(&ui.locale); |
| 450 | 591 | let size = window.size(); |
| 451 | 592 | let renderer = Renderer::with_theme(size.width, size.height, theme)?; |
| 452 | 593 | |
@@ -627,7 +768,7 @@ impl PromptDialog { |
| 627 | 768 | .font_size(self.scaled_f64(18.0)) |
| 628 | 769 | .color(theme.foreground); |
| 629 | 770 | self.renderer.text( |
| 630 | | - self.strings.title_auth_required, |
| 771 | + self.strings.title_auth_required.as_str(), |
| 631 | 772 | (pad + body_inset) as f64, |
| 632 | 773 | (pad + title_top) as f64, |
| 633 | 774 | &title_style, |
@@ -648,8 +789,8 @@ impl PromptDialog { |
| 648 | 789 | )?; |
| 649 | 790 | |
| 650 | 791 | 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(), |
| 653 | 794 | }; |
| 654 | 795 | let label_style = TextStyle::new() |
| 655 | 796 | .font_family(theme.font_family.clone()) |
@@ -722,9 +863,9 @@ impl PromptDialog { |
| 722 | 863 | .font_size(self.scaled_f64(12.0)) |
| 723 | 864 | .color(theme.item_description); |
| 724 | 865 | let footer_text = if self.request.feedback_only { |
| 725 | | - self.strings.footer_wait |
| 866 | + self.strings.footer_wait.as_str() |
| 726 | 867 | } else { |
| 727 | | - self.strings.footer_controls |
| 868 | + self.strings.footer_controls.as_str() |
| 728 | 869 | }; |
| 729 | 870 | self.renderer.text( |
| 730 | 871 | footer_text, |
@@ -959,6 +1100,21 @@ mod tests { |
| 959 | 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 | 1118 | #[test] |
| 963 | 1119 | fn parse_bool_env_accepts_common_truthy_values() { |
| 964 | 1120 | assert!(parse_bool_env(Some("1"))); |
@@ -979,6 +1135,32 @@ mod tests { |
| 979 | 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 | 1164 | #[test] |
| 983 | 1165 | fn apply_key_event_submits_and_clears_input_on_return() { |
| 984 | 1166 | let mut input = "secret".to_string(); |