@@ -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(); |