max / audiofiles
12 files changed,
+531 insertions,
-567 deletions
| @@ -2970,9 +2970,7 @@ | |||
| 2970 | 2970 | ||
| 2971 | 2971 | [[package]] | |
| 2972 | 2972 | name = "makeover" | |
| 2973 | - | version = "2.0.0" | |
| 2974 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2975 | - | checksum = "1cae0ab5923b75a7cc18e08d1dc6f116252e76e74a5b317942aff38edd1d0b4f" | |
| 2973 | + | version = "2.1.0" | |
| 2976 | 2974 | dependencies = [ | |
| 2977 | 2975 | "include_dir", | |
| 2978 | 2976 | "serde", |
| @@ -57,7 +57,7 @@ | |||
| 57 | 57 | libc = "0.2" | |
| 58 | 58 | midir = "0.11" | |
| 59 | 59 | tagtree = { path = "../../MNW/shared/tagtree" } | |
| 60 | - | makeover = "2.0.0" | |
| 60 | + | makeover = { path = "../../Libraries/makeover" } | |
| 61 | 61 | ||
| 62 | 62 | [workspace.lints.rust] | |
| 63 | 63 | unused = "warn" |
| @@ -1,4 +1,10 @@ | |||
| 1 | 1 | //! License activation screen, trial mode, and deactivation logic. | |
| 2 | + | //! | |
| 3 | + | //! The activation screen is a soft pitch, not a gate. The license is optional: | |
| 4 | + | //! entering a valid key unlocks "audiofiles Pro" (see the benefits table on the | |
| 5 | + | //! screen itself), but a user can also click Continue and skip the screen | |
| 6 | + | //! entirely, optionally ticking "don't show again" to skip it on every future | |
| 7 | + | //! launch too. | |
| 2 | 8 | ||
| 3 | 9 | use audiofiles_browser::ui::theme; | |
| 4 | 10 | use eframe::egui; | |
| @@ -43,71 +49,69 @@ | |||
| 43 | 49 | egui::CentralPanel::default().show(ui, |ui| { | |
| 44 | 50 | let available = ui.available_size(); | |
| 45 | 51 | ||
| 46 | - | ui.add_space((available.y * 0.35).max(40.0)); | |
| 52 | + | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 53 | + | ui.add_space((available.y * 0.08).max(24.0)); | |
| 47 | 54 | ||
| 48 | - | ui.vertical_centered(|ui| { | |
| 49 | - | ui.heading("audiofiles"); | |
| 50 | - | ui.add_space(theme::space::MD); | |
| 51 | - | ui.label("Start a free trial, or activate a license key."); | |
| 52 | - | ui.add_space(theme::space::SECTION); | |
| 53 | - | ||
| 54 | - | // Trial entry (primary path for first-time users) | |
| 55 | - | let trial_expired = trial_is_expired(self.trial_state.as_ref()); | |
| 56 | - | let trial_label = trial_button_label(self.trial_state.as_ref()); | |
| 57 | - | let trial_btn = egui::Button::new(egui::RichText::new(trial_label).strong()); | |
| 58 | - | if ui.add_enabled(!trial_expired, trial_btn).clicked() { | |
| 59 | - | self.start_trial(); | |
| 60 | - | } | |
| 61 | - | if trial_expired { | |
| 55 | + | ui.vertical_centered(|ui| { | |
| 56 | + | ui.heading("audiofiles Pro"); | |
| 62 | 57 | ui.add_space(theme::space::SM); | |
| 63 | 58 | ui.label( | |
| 64 | - | egui::RichText::new("Activate a license below to continue.") | |
| 65 | - | .small() | |
| 59 | + | egui::RichText::new( | |
| 60 | + | "Pay what you want for a lifetime license. Or don't. \ | |
| 61 | + | audiofiles works the same either way.", | |
| 62 | + | ) | |
| 63 | + | .color(theme::content_secondary()), | |
| 64 | + | ); | |
| 65 | + | ui.add_space(theme::space::SECTION); | |
| 66 | + | ||
| 67 | + | // ── Benefits table ── | |
| 68 | + | let table_width = 460.0_f32.min(available.x - 40.0); | |
| 69 | + | ui.allocate_ui(egui::vec2(table_width, 0.0), |ui| { | |
| 70 | + | draw_pro_benefits_table(ui); | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | ui.add_space(theme::space::SECTION); | |
| 74 | + | ||
| 75 | + | // ── License key entry (optional) ── | |
| 76 | + | ui.label( | |
| 77 | + | egui::RichText::new("Have a key? Paste it here.") | |
| 66 | 78 | .color(theme::content_secondary()), | |
| 67 | 79 | ); | |
| 68 | - | } | |
| 80 | + | ui.add_space(theme::space::SM); | |
| 69 | 81 | ||
| 70 | - | ui.add_space(theme::space::XL); | |
| 71 | - | ui.separator(); | |
| 72 | - | ui.add_space(theme::space::MD); | |
| 82 | + | let input_width = 360.0_f32.min(available.x - 40.0); | |
| 83 | + | ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| { | |
| 84 | + | let response = ui.add_sized( | |
| 85 | + | ui.available_size(), | |
| 86 | + | egui::TextEdit::singleline(&mut self.license_key_input) | |
| 87 | + | .hint_text("five-word-license-key-example"), | |
| 88 | + | ); | |
| 89 | + | // Clear stale activation error as soon as the user edits the field. | |
| 90 | + | if response.changed() { | |
| 91 | + | self.activation_error = None; | |
| 92 | + | } | |
| 93 | + | // Submit on Enter. | |
| 94 | + | if response.lost_focus() | |
| 95 | + | && ui.input(|i| i.key_pressed(egui::Key::Enter)) | |
| 96 | + | && !self.activating | |
| 97 | + | && !self.license_key_input.trim().is_empty() | |
| 98 | + | { | |
| 99 | + | self.start_activation(); | |
| 100 | + | } | |
| 101 | + | }); | |
| 73 | 102 | ||
| 74 | - | // License key entry | |
| 75 | - | ui.label( | |
| 76 | - | egui::RichText::new("Already have a license?") | |
| 77 | - | .color(theme::content_secondary()), | |
| 78 | - | ); | |
| 79 | - | ui.add_space(theme::space::SM); | |
| 103 | + | ui.add_space(theme::space::MD); | |
| 80 | 104 | ||
| 81 | - | let input_width = 360.0_f32.min(available.x - 40.0); | |
| 82 | - | ui.allocate_ui(egui::vec2(input_width, 28.0), |ui| { | |
| 83 | - | let response = ui.add_sized( | |
| 84 | - | ui.available_size(), | |
| 85 | - | egui::TextEdit::singleline(&mut self.license_key_input) | |
| 86 | - | .hint_text("five-word-license-key-example"), | |
| 87 | - | ); | |
| 88 | - | // Clear stale activation error as soon as the user edits the field. | |
| 89 | - | if response.changed() { | |
| 90 | - | self.activation_error = None; | |
| 91 | - | } | |
| 92 | - | // Submit on Enter | |
| 93 | - | if response.lost_focus() | |
| 94 | - | && ui.input(|i| i.key_pressed(egui::Key::Enter)) | |
| 95 | - | && !self.activating | |
| 96 | - | && !self.license_key_input.trim().is_empty() | |
| 97 | - | { | |
| 98 | - | self.start_activation(); | |
| 99 | - | } | |
| 100 | - | }); | |
| 101 | - | ||
| 102 | - | ui.add_space(theme::space::MD); | |
| 103 | - | ||
| 104 | - | let can_activate = !self.activating && !self.license_key_input.trim().is_empty(); | |
| 105 | - | ui.horizontal(|ui| { | |
| 105 | + | let can_activate = | |
| 106 | + | !self.activating && !self.license_key_input.trim().is_empty(); | |
| 106 | 107 | let button_text = if self.activating { | |
| 107 | 108 | "Activating\u{2026}" | |
| 108 | 109 | } else { | |
| 109 | 110 | "Activate" | |
| 110 | 111 | }; | |
| 112 | + | // Add the button directly to the vertical_centered scope so | |
| 113 | + | // it centers with the rest of the column; a horizontal here | |
| 114 | + | // would fill the width and left-align its contents. | |
| 111 | 115 | if ui | |
| 112 | 116 | .add_enabled(can_activate, egui::Button::new(button_text)) | |
| 113 | 117 | .clicked() | |
| @@ -115,42 +119,66 @@ | |||
| 115 | 119 | self.start_activation(); | |
| 116 | 120 | } | |
| 117 | 121 | if self.activating { | |
| 122 | + | ui.add_space(theme::space::SM); | |
| 118 | 123 | ui.spinner(); | |
| 119 | 124 | } | |
| 120 | - | }); | |
| 121 | 125 | ||
| 122 | - | if let Some(err) = self.activation_error.clone() { | |
| 123 | - | ui.add_space(theme::space::MD); | |
| 124 | - | ui.colored_label(theme::danger(), err.to_string()); | |
| 125 | - | ui.add_space(theme::space::SM); | |
| 126 | - | // Per-class recovery affordance. | |
| 127 | - | match recovery_affordance(&err) { | |
| 128 | - | RecoveryAffordance::Retry => { | |
| 129 | - | if ui.button("Try again").clicked() && !self.activating { | |
| 130 | - | self.start_activation(); | |
| 126 | + | if let Some(err) = self.activation_error.clone() { | |
| 127 | + | ui.add_space(theme::space::MD); | |
| 128 | + | ui.colored_label(theme::danger(), err.to_string()); | |
| 129 | + | ui.add_space(theme::space::SM); | |
| 130 | + | match recovery_affordance(&err) { | |
| 131 | + | RecoveryAffordance::Retry => { | |
| 132 | + | if ui.button("Try again").clicked() && !self.activating { | |
| 133 | + | self.start_activation(); | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | RecoveryAffordance::GetNewKey => { | |
| 137 | + | ui.hyperlink_to( | |
| 138 | + | "Get a new license key", | |
| 139 | + | "https://makenot.work/store/audiofiles", | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | RecoveryAffordance::ContactSupport => { | |
| 143 | + | ui.hyperlink_to( | |
| 144 | + | "Contact support", | |
| 145 | + | "mailto:info@makenot.work?subject=License%20activation%20issue", | |
| 146 | + | ); | |
| 131 | 147 | } | |
| 132 | 148 | } | |
| 133 | - | RecoveryAffordance::GetNewKey => { | |
| 134 | - | ui.hyperlink_to( | |
| 135 | - | "Get a new license key", | |
| 136 | - | "https://makenot.work/store/audiofiles", | |
| 137 | - | ); | |
| 138 | - | } | |
| 139 | - | RecoveryAffordance::ContactSupport => { | |
| 140 | - | ui.hyperlink_to( | |
| 141 | - | "Contact support", | |
| 142 | - | "mailto:info@makenot.work?subject=License%20activation%20issue", | |
| 143 | - | ); | |
| 144 | - | } | |
| 145 | 149 | } | |
| 146 | - | } | |
| 147 | 150 | ||
| 148 | - | ui.add_space(theme::space::MD); | |
| 149 | - | ui.hyperlink_to("Get a license key", "https://makenot.work/store/audiofiles"); | |
| 151 | + | ui.add_space(theme::space::SM); | |
| 152 | + | ui.hyperlink_to( | |
| 153 | + | "Get a license (pay what you want, $5 minimum)", | |
| 154 | + | "https://makenot.work/store/audiofiles", | |
| 155 | + | ); | |
| 150 | 156 | ||
| 151 | - | ui.add_space(theme::space::XL); | |
| 152 | - | ui.horizontal(|ui| { | |
| 153 | - | ui.add_space((ui.available_width() / 2.0 - 60.0).max(0.0)); | |
| 157 | + | ui.add_space(theme::space::SECTION); | |
| 158 | + | ui.separator(); | |
| 159 | + | ui.add_space(theme::space::MD); | |
| 160 | + | ||
| 161 | + | // ── Continue without a key ── | |
| 162 | + | let mut dismissed = self.prefs.activation_prompt_dismissed; | |
| 163 | + | if ui | |
| 164 | + | .checkbox(&mut dismissed, "Don't show this again") | |
| 165 | + | .changed() | |
| 166 | + | { | |
| 167 | + | self.prefs.activation_prompt_dismissed = dismissed; | |
| 168 | + | self.prefs.save(&self.config_dir); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | ui.add_space(theme::space::SM); | |
| 172 | + | ||
| 173 | + | if ui | |
| 174 | + | .button(egui::RichText::new("Continue without a key").strong()) | |
| 175 | + | .clicked() | |
| 176 | + | { | |
| 177 | + | self.continue_without_license(); | |
| 178 | + | return; | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | ui.add_space(theme::space::XL); | |
| 154 | 182 | if ui.small_button("About audiofiles").clicked() { | |
| 155 | 183 | self.show_about = true; | |
| 156 | 184 | } | |
| @@ -159,19 +187,10 @@ | |||
| 159 | 187 | }); | |
| 160 | 188 | } | |
| 161 | 189 | ||
| 162 | - | /// Start or continue trial mode: create trial state if needed, then proceed. | |
| 163 | - | pub(crate) fn start_trial(&mut self) { | |
| 164 | - | if self.trial_state.is_none() { | |
| 165 | - | let now = chrono::Utc::now().to_rfc3339(); | |
| 166 | - | let trial = super::license::TrialState { | |
| 167 | - | first_launch_date: now.clone(), | |
| 168 | - | last_seen_date: Some(now), | |
| 169 | - | }; | |
| 170 | - | if let Err(e) = super::license::save_trial(&self.config_dir, &trial) { | |
| 171 | - | tracing::error!("Failed to save trial state: {e}"); | |
| 172 | - | } | |
| 173 | - | self.trial_state = Some(trial); | |
| 174 | - | } | |
| 190 | + | /// Skip the activation screen and proceed to vault setup or the browser. | |
| 191 | + | /// Called from the "Continue without a key" button; honors whatever state | |
| 192 | + | /// the "don't show again" checkbox is in (it saves independently on click). | |
| 193 | + | pub(crate) fn continue_without_license(&mut self) { | |
| 175 | 194 | if self.vault_registry.is_some() { | |
| 176 | 195 | self.activate_browser(); | |
| 177 | 196 | } else { | |
| @@ -195,21 +214,19 @@ | |||
| 195 | 214 | ||
| 196 | 215 | /// Push license info into the browser settings state. | |
| 197 | 216 | pub(crate) fn sync_license_to_browser(&mut self) { | |
| 198 | - | if let Some(ref mut browser) = self.browser { | |
| 199 | - | if let Some(ref cache) = self.license_cache { | |
| 200 | - | browser.settings.license_key_masked = Some(mask_key(&cache.key_code)); | |
| 201 | - | browser.settings.trial_days_remaining = None; | |
| 202 | - | } else if let Some(ref trial) = self.trial_state { | |
| 203 | - | browser.settings.trial_days_remaining = | |
| 204 | - | Some(super::license::trial_days_remaining(trial)); | |
| 205 | - | } | |
| 206 | - | let mid = &self.machine_id; | |
| 207 | - | browser.settings.machine_id = Some(if mid.len() > 12 { | |
| 208 | - | format!("{}...{}", &mid[..8], &mid[mid.len() - 4..]) | |
| 209 | - | } else { | |
| 210 | - | mid.clone() | |
| 211 | - | }); | |
| 212 | - | } | |
| 217 | + | let Some(ref mut browser) = self.browser else { | |
| 218 | + | return; | |
| 219 | + | }; | |
| 220 | + | browser.settings.license_key_masked = self | |
| 221 | + | .license_cache | |
| 222 | + | .as_ref() | |
| 223 | + | .map(|cache| mask_key(&cache.key_code)); | |
| 224 | + | let mid = &self.machine_id; | |
| 225 | + | browser.settings.machine_id = Some(if mid.len() > 12 { | |
| 226 | + | format!("{}...{}", &mid[..8], &mid[mid.len() - 4..]) | |
| 227 | + | } else { | |
| 228 | + | mid.clone() | |
| 229 | + | }); | |
| 213 | 230 | } | |
| 214 | 231 | ||
| 215 | 232 | /// Deactivate the license: notify the server (best-effort), delete the | |
| @@ -268,41 +285,53 @@ | |||
| 268 | 285 | } | |
| 269 | 286 | } | |
| 270 | 287 | ||
| 271 | - | /// Whether the "Continue trial" button should be disabled: a trial exists and | |
| 272 | - | /// has no days left. Absence of a trial is *not* expired (fresh start is offered). | |
| 273 | - | pub(crate) fn trial_is_expired(trial: Option<&super::license::TrialState>) -> bool { | |
| 274 | - | matches!(trial, Some(t) if super::license::trial_days_remaining(t) <= 0) | |
| 275 | - | } | |
| 288 | + | /// The audiofiles Pro benefit list, in display order. Kept as a plain constant | |
| 289 | + | /// so the tongue-in-cheek copy is easy to spot and edit in one place. | |
| 290 | + | const PRO_BENEFITS: &[(&str, &str)] = &[ | |
| 291 | + | ( | |
| 292 | + | "More coffee for the developer", | |
| 293 | + | "Directly funds the caffeine that keeps this project going.", | |
| 294 | + | ), | |
| 295 | + | ( | |
| 296 | + | "A sense of pride and luxury", | |
| 297 | + | "You are now the sort of person who buys the pro version of things.", | |
| 298 | + | ), | |
| 299 | + | ( | |
| 300 | + | "Helps me test the license key system", | |
| 301 | + | "Genuinely useful; the code path barely runs otherwise.", | |
| 302 | + | ), | |
| 303 | + | ]; | |
| 276 | 304 | ||
| 277 | - | /// The label for the primary trial button, reflecting current trial state. | |
| 278 | - | pub(crate) fn trial_button_label(trial: Option<&super::license::TrialState>) -> String { | |
| 279 | - | match trial { | |
| 280 | - | Some(trial) => { | |
| 281 | - | let days = super::license::trial_days_remaining(trial); | |
| 282 | - | if days > 0 { | |
| 283 | - | let unit = if days == 1 { "day" } else { "days" }; | |
| 284 | - | format!("Continue trial ({days} {unit} left)") | |
| 285 | - | } else { | |
| 286 | - | "Trial expired".to_string() | |
| 305 | + | /// Draw the tongue-in-cheek benefits grid shown on the Pro pitch screen. | |
| 306 | + | /// Two columns: benefit title (strong) and a short one-liner underneath. | |
| 307 | + | fn draw_pro_benefits_table(ui: &mut egui::Ui) { | |
| 308 | + | egui::Frame::group(ui.style()) | |
| 309 | + | .inner_margin(theme::space::MD) | |
| 310 | + | .show(ui, |ui| { | |
| 311 | + | for (i, (title, blurb)) in PRO_BENEFITS.iter().enumerate() { | |
| 312 | + | if i > 0 { | |
| 313 | + | ui.add_space(theme::space::SM); | |
| 314 | + | ui.separator(); | |
| 315 | + | ui.add_space(theme::space::SM); | |
| 316 | + | } | |
| 317 | + | ui.horizontal(|ui| { | |
| 318 | + | ui.label(egui::RichText::new(format!("{}.", i + 1)).weak()); | |
| 319 | + | ui.vertical(|ui| { | |
| 320 | + | ui.label(egui::RichText::new(*title).strong()); | |
| 321 | + | ui.label( | |
| 322 | + | egui::RichText::new(*blurb).color(theme::content_secondary()), | |
| 323 | + | ); | |
| 324 | + | }); | |
| 325 | + | }); | |
| 287 | 326 | } | |
| 288 | - | } | |
| 289 | - | None => "Start free trial: 30 days, no card".to_string(), | |
| 290 | - | } | |
| 327 | + | }); | |
| 291 | 328 | } | |
| 292 | 329 | ||
| 293 | 330 | #[cfg(test)] | |
| 294 | 331 | mod tests { | |
| 295 | - | use super::super::license::{ActivationError, TrialState}; | |
| 332 | + | use super::super::license::ActivationError; | |
| 296 | 333 | use super::*; | |
| 297 | 334 | ||
| 298 | - | fn trial_starting_days_ago(days: i64) -> TrialState { | |
| 299 | - | let start = chrono::Utc::now() - chrono::Duration::days(days); | |
| 300 | - | TrialState { | |
| 301 | - | first_launch_date: start.to_rfc3339(), | |
| 302 | - | last_seen_date: None, | |
| 303 | - | } | |
| 304 | - | } | |
| 305 | - | ||
| 306 | 335 | // ── recovery_affordance: error-class -> recovery routing ── | |
| 307 | 336 | ||
| 308 | 337 | #[test] | |
| @@ -345,73 +374,15 @@ | |||
| 345 | 374 | ); | |
| 346 | 375 | } | |
| 347 | 376 | ||
| 348 | - | // ── trial_is_expired ── | |
| 377 | + | // ── Pro benefits copy ── | |
| 349 | 378 | ||
| 350 | 379 | #[test] | |
| 351 | - | fn no_trial_is_not_expired() { | |
| 352 | - | // None means "never started" -> a fresh trial is offered, not expired. | |
| 353 | - | assert!(!trial_is_expired(None)); | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | #[test] | |
| 357 | - | fn fresh_trial_is_not_expired() { | |
| 358 | - | assert!(!trial_is_expired(Some(&trial_starting_days_ago(1)))); | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | #[test] | |
| 362 | - | fn old_trial_is_expired() { | |
| 363 | - | assert!(trial_is_expired(Some(&trial_starting_days_ago(31)))); | |
| 364 | - | } | |
| 365 | - | ||
| 366 | - | #[test] | |
| 367 | - | fn trial_exactly_at_zero_is_expired() { | |
| 368 | - | // day 30 -> 0 remaining, and the guard is `<= 0`, so it counts as expired. | |
| 369 | - | assert!(trial_is_expired(Some(&trial_starting_days_ago(30)))); | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | // ── trial_button_label ── | |
| 373 | - | ||
| 374 | - | #[test] | |
| 375 | - | fn label_no_trial_offers_start() { | |
| 376 | - | assert_eq!( | |
| 377 | - | trial_button_label(None), | |
| 378 | - | "Start free trial: 30 days, no card" | |
| 379 | - | ); | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | #[test] | |
| 383 | - | fn label_fresh_trial_shows_days_plural() { | |
| 384 | - | let label = trial_button_label(Some(&trial_starting_days_ago(0))); | |
| 385 | - | assert_eq!(label, "Continue trial (30 days left)"); | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | #[test] | |
| 389 | - | fn label_one_day_left_is_singular() { | |
| 390 | - | // 29 days elapsed -> 1 day remaining -> singular "day". | |
| 391 | - | let label = trial_button_label(Some(&trial_starting_days_ago(29))); | |
| 392 | - | assert_eq!(label, "Continue trial (1 day left)"); | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | #[test] | |
| 396 | - | fn label_expired_trial() { | |
| 397 | - | assert_eq!( | |
| 398 | - | trial_button_label(Some(&trial_starting_days_ago(45))), | |
| 399 | - | "Trial expired" | |
| 400 | - | ); | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | #[test] | |
| 404 | - | fn label_and_expiry_agree() { | |
| 405 | - | // The two helpers must never disagree about a given trial. | |
| 406 | - | for days in [0, 1, 15, 29, 30, 31, 60] { | |
| 407 | - | let t = trial_starting_days_ago(days); | |
| 408 | - | let expired = trial_is_expired(Some(&t)); | |
| 409 | - | let label = trial_button_label(Some(&t)); | |
| 410 | - | if expired { | |
| 411 | - | assert_eq!(label, "Trial expired", "day {days}"); | |
| 412 | - | } else { | |
| 413 | - | assert!(label.starts_with("Continue trial ("), "day {days}: {label}"); | |
| 414 | - | } | |
| 415 | - | } | |
| 380 | + | fn pro_benefits_are_exactly_the_three_promised() { | |
| 381 | + | // The three-item list is a contract with the user (see the Pro pitch | |
| 382 | + | // in the app description). Guard against silent re-ordering / drops. | |
| 383 | + | assert_eq!(PRO_BENEFITS.len(), 3); | |
| 384 | + | assert!(PRO_BENEFITS[0].0.contains("coffee")); | |
| 385 | + | assert!(PRO_BENEFITS[1].0.contains("pride")); | |
| 386 | + | assert!(PRO_BENEFITS[2].0.contains("license key system")); | |
| 416 | 387 | } | |
| 417 | 388 | } |
| @@ -270,75 +270,6 @@ | |||
| 270 | 270 | } | |
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | - | // ── Trial state ── | |
| 274 | - | ||
| 275 | - | /// Persisted trial state: tracks when the user first launched the app. | |
| 276 | - | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 277 | - | pub(crate) struct TrialState { | |
| 278 | - | pub first_launch_date: String, | |
| 279 | - | /// Last time the app was launched, used to detect system clock rollback. | |
| 280 | - | #[serde(default)] | |
| 281 | - | pub last_seen_date: Option<String>, | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | /// Load the trial state from `trial.json` in the config directory. | |
| 285 | - | pub(crate) fn load_trial(config_dir: &Path) -> Option<TrialState> { | |
| 286 | - | let path = config_dir.join("trial.json"); | |
| 287 | - | let bytes = std::fs::read(&path).ok()?; | |
| 288 | - | let state: TrialState = serde_json::from_slice(&bytes).ok()?; | |
| 289 | - | // Fail-open: a present-but-corrupt trial (unparseable first_launch_date) is | |
| 290 | - | // treated as no trial, so the caller starts a fresh one rather than leaving | |
| 291 | - | // the user with a disabled "Continue trial" button and no recovery | |
| 292 | - | // (trial-mode.md: deleting/breaking the config just grants another 30 days). | |
| 293 | - | chrono::DateTime::parse_from_rfc3339(&state.first_launch_date).ok()?; | |
| 294 | - | Some(state) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | /// Save the trial state to `trial.json` in the config directory. | |
| 298 | - | pub(crate) fn save_trial(config_dir: &Path, state: &TrialState) -> io::Result<()> { | |
| 299 | - | let path = config_dir.join("trial.json"); | |
| 300 | - | let tmp = config_dir.join("trial.json.tmp"); | |
| 301 | - | let json = serde_json::to_string_pretty(state).map_err(io::Error::other)?; | |
| 302 | - | std::fs::write(&tmp, &json)?; | |
| 303 | - | std::fs::rename(&tmp, &path) | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | /// Calculate days remaining in the trial (goes negative after day 30). | |
| 307 | - | /// | |
| 308 | - | /// Uses the furthest-seen elapsed time (max of now-since-first and | |
| 309 | - | /// last_seen-since-first). A clock rolled backward therefore cannot *inflate* the | |
| 310 | - | /// remaining days (the cheat the monotonic floor blocks), but it also never | |
| 311 | - | /// permanently zeros a genuine trial on a legitimate NTP/DST correction, the old | |
| 312 | - | /// behavior, which contradicted trial-mode.md ("not DRM… does not lock the user | |
| 313 | - | /// out"). A corrupt `first_launch_date` is treated as expired here, but | |
| 314 | - | /// [`load_trial`] regenerates such a trial before this is reached. | |
| 315 | - | pub(crate) fn trial_days_remaining(trial: &TrialState) -> i64 { | |
| 316 | - | let Ok(first) = chrono::DateTime::parse_from_rfc3339(&trial.first_launch_date) else { | |
| 317 | - | return 0; | |
| 318 | - | }; | |
| 319 | - | let now = chrono::Utc::now(); | |
| 320 | - | ||
| 321 | - | let mut elapsed = now.signed_duration_since(first); | |
| 322 | - | if let Some(ref last) = trial.last_seen_date | |
| 323 | - | && let Ok(last_seen) = chrono::DateTime::parse_from_rfc3339(last) | |
| 324 | - | { | |
| 325 | - | let seen_elapsed = last_seen.signed_duration_since(first); | |
| 326 | - | if seen_elapsed > elapsed { | |
| 327 | - | elapsed = seen_elapsed; | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | 30 - elapsed.num_days() | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | /// Update the last_seen_date to now. Call on each app launch. | |
| 335 | - | pub(crate) fn touch_trial(config_dir: &std::path::Path) { | |
| 336 | - | if let Some(mut trial) = load_trial(config_dir) { | |
| 337 | - | trial.last_seen_date = Some(chrono::Utc::now().to_rfc3339()); | |
| 338 | - | let _ = save_trial(config_dir, &trial); | |
| 339 | - | } | |
| 340 | - | } | |
| 341 | - | ||
| 342 | 273 | #[cfg(test)] | |
| 343 | 274 | mod tests { | |
| 344 | 275 | use super::*; | |
| @@ -451,68 +382,4 @@ | |||
| 451 | 382 | assert!(resp.success); | |
| 452 | 383 | assert_eq!(resp.message, "deactivated"); | |
| 453 | 384 | } | |
| 454 | - | ||
| 455 | - | #[test] | |
| 456 | - | fn save_load_trial_roundtrip() { | |
| 457 | - | let dir = tempfile::tempdir().unwrap(); | |
| 458 | - | let state = TrialState { | |
| 459 | - | first_launch_date: "2026-04-01T00:00:00Z".to_string(), | |
| 460 | - | last_seen_date: None, | |
| 461 | - | }; | |
| 462 | - | save_trial(dir.path(), &state).unwrap(); | |
| 463 | - | let loaded = load_trial(dir.path()).unwrap(); | |
| 464 | - | assert_eq!(loaded.first_launch_date, state.first_launch_date); | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | #[test] | |
| 468 | - | fn load_trial_missing_returns_none() { | |
| 469 | - | let dir = tempfile::tempdir().unwrap(); | |
| 470 | - | assert!(load_trial(dir.path()).is_none()); | |
| 471 | - | } | |
| 472 | - | ||
| 473 | - | #[test] | |
| 474 | - | fn trial_days_remaining_fresh() { | |
| 475 | - | let state = TrialState { | |
| 476 | - | first_launch_date: chrono::Utc::now().to_rfc3339(), | |
| 477 | - | last_seen_date: None, | |
| 478 | - | }; | |
| 479 | - | assert_eq!(trial_days_remaining(&state), 30); | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | #[test] | |
| 483 | - | fn trial_days_remaining_expired() { | |
| 484 | - | let past = chrono::Utc::now() - chrono::Duration::days(35); | |
| 485 | - | let state = TrialState { | |
| 486 | - | first_launch_date: past.to_rfc3339(), | |
| 487 | - | last_seen_date: None, | |
| 488 | - | }; | |
| 489 | - | assert_eq!(trial_days_remaining(&state), -5); | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | #[test] | |
| 493 | - | fn trial_clock_rollback_does_not_inflate_or_lock_out() { | |
| 494 | - | let now = chrono::Utc::now(); | |
| 495 | - | let seen = now + chrono::Duration::days(10); | |
| 496 | - | let state = TrialState { | |
| 497 | - | first_launch_date: now.to_rfc3339(), | |
| 498 | - | last_seen_date: Some(seen.to_rfc3339()), | |
| 499 | - | }; | |
| 500 | - | // Clock rolled back 10 days: the monotonic floor uses the furthest-seen | |
| 501 | - | // elapsed (10 days), so remaining is 20, not inflated to a fresh 30 (the | |
| 502 | - | // cheat) and not permanently zeroed (the lock-out trial-mode.md forbids). | |
| 503 | - | assert_eq!(trial_days_remaining(&state), 20); | |
| 504 | - | } | |
| 505 | - | ||
| 506 | - | #[test] | |
| 507 | - | fn load_trial_corrupt_date_returns_none() { | |
| 508 | - | let dir = tempfile::tempdir().unwrap(); | |
| 509 | - | // Valid JSON, garbage date: fail-open treats it as no trial so a fresh one | |
| 510 | - | // is started rather than locking the user out with a disabled button. | |
| 511 | - | std::fs::write( | |
| 512 | - | dir.path().join("trial.json"), | |
| 513 | - | r#"{"first_launch_date":"not-a-date","last_seen_date":null}"#, | |
| 514 | - | ) | |
| 515 | - | .unwrap(); | |
| 516 | - | assert!(load_trial(dir.path()).is_none()); | |
| 517 | - | } | |
| 518 | 385 | } |
| @@ -209,22 +209,27 @@ | |||
| 209 | 209 | Browser, | |
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | - | /// Determine the initial screen based on vault registry, license status, and trial. | |
| 212 | + | /// Determine the initial screen based on vault registry, license status, and | |
| 213 | + | /// whether the user has dismissed the (now-optional) Pro pitch. | |
| 214 | + | /// | |
| 215 | + | /// The license is optional: an unlicensed user still reaches the browser. The | |
| 216 | + | /// activation screen appears once as a friendly pitch and can be permanently | |
| 217 | + | /// dismissed from a checkbox on the screen itself. Licensed users skip it | |
| 218 | + | /// implicitly (having a key is dismissal in the strongest form). | |
| 213 | 219 | /// | |
| 214 | 220 | /// This is the pure decision logic extracted from `AudioFilesApp::new()` so it | |
| 215 | 221 | /// can be tested without constructing the full app. | |
| 216 | 222 | fn resolve_initial_screen( | |
| 217 | 223 | vault_registry: Option<&VaultRegistry>, | |
| 218 | 224 | license_status: &license::LicenseStatus, | |
| 219 | - | has_trial: bool, | |
| 225 | + | activation_prompt_dismissed: bool, | |
| 220 | 226 | ) -> AppScreen { | |
| 221 | - | let licensed_or_trial = | |
| 222 | - | matches!(license_status, license::LicenseStatus::Licensed(_)) || has_trial; | |
| 223 | - | match (vault_registry, licensed_or_trial) { | |
| 227 | + | let licensed = matches!(license_status, license::LicenseStatus::Licensed(_)); | |
| 228 | + | let bypass_activation = licensed || activation_prompt_dismissed; | |
| 229 | + | match (vault_registry, bypass_activation) { | |
| 224 | 230 | (Some(_), true) => AppScreen::Browser, | |
| 225 | - | (Some(_), false) => AppScreen::Activation, | |
| 226 | 231 | (None, true) => AppScreen::VaultSetup, | |
| 227 | - | (None, false) => AppScreen::Activation, | |
| 232 | + | (_, false) => AppScreen::Activation, | |
| 228 | 233 | } | |
| 229 | 234 | } | |
| 230 | 235 | ||
| @@ -269,7 +274,6 @@ | |||
| 269 | 274 | activation_error: Option<license::ActivationError>, | |
| 270 | 275 | activating: bool, | |
| 271 | 276 | license_cache: Option<license::LicenseCache>, | |
| 272 | - | trial_state: Option<license::TrialState>, | |
| 273 | 277 | } | |
| 274 | 278 | ||
| 275 | 279 | impl AudioFilesApp { | |
| @@ -292,8 +296,6 @@ | |||
| 292 | 296 | ||
| 293 | 297 | let machine_id = license::get_or_create_machine_id(&config_dir); | |
| 294 | 298 | let license_status = license::load_license(&config_dir); | |
| 295 | - | let trial_state = license::load_trial(&config_dir); | |
| 296 | - | license::touch_trial(&config_dir); | |
| 297 | 299 | ||
| 298 | 300 | // Load (or create) the vault registry | |
| 299 | 301 | let vault_registry = match vault::load_registry() { | |
| @@ -304,19 +306,23 @@ | |||
| 304 | 306 | } | |
| 305 | 307 | }; | |
| 306 | 308 | ||
| 307 | - | let has_active_trial = trial_state | |
| 308 | - | .as_ref() | |
| 309 | - | .is_some_and(|t| license::trial_days_remaining(t) > 0); | |
| 310 | - | let screen = | |
| 311 | - | resolve_initial_screen(vault_registry.as_ref(), &license_status, has_active_trial); | |
| 309 | + | let screen = resolve_initial_screen( | |
| 310 | + | vault_registry.as_ref(), | |
| 311 | + | &license_status, | |
| 312 | + | prefs.activation_prompt_dismissed, | |
| 313 | + | ); | |
| 312 | 314 | ||
| 313 | - | let licensed_or_trial = | |
| 314 | - | matches!(&license_status, license::LicenseStatus::Licensed(_)) || has_active_trial; | |
| 315 | + | // Whether we should open the browser on launch (registry exists AND the | |
| 316 | + | // user has cleared the pitch, one way or another). The license is | |
| 317 | + | // optional now, so an unlicensed-but-dismissed user still gets a | |
| 318 | + | // browser rather than sitting on an intermediate screen. | |
| 319 | + | let bypass_activation = matches!(&license_status, license::LicenseStatus::Licensed(_)) | |
| 320 | + | || prefs.activation_prompt_dismissed; | |
| 315 | 321 | ||
| 316 | 322 | let (data_dir, browser, error, sync_manager, license_cache) = | |
| 317 | - | match (&vault_registry, &license_status) { | |
| 318 | - | // Registry exists and user is licensed → open the active vault | |
| 319 | - | (Some(reg), license::LicenseStatus::Licensed(cache)) => { | |
| 323 | + | match (&vault_registry, &license_status, bypass_activation) { | |
| 324 | + | // Registry exists and the user has cleared the pitch → open vault. | |
| 325 | + | (Some(reg), status, true) => { | |
| 320 | 326 | let data_dir = reg.active.clone(); | |
| 321 | 327 | let _ = std::fs::create_dir_all(&data_dir); | |
| 322 | 328 | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| @@ -325,37 +331,31 @@ | |||
| 325 | 331 | shared.clone(), | |
| 326 | 332 | &vault_setup::vault_name_for_path(reg, &data_dir), | |
| 327 | 333 | ); | |
| 328 | - | (data_dir, browser, error, sync_manager, Some(cache.clone())) | |
| 334 | + | let cache = if let license::LicenseStatus::Licensed(c) = status { | |
| 335 | + | Some(c.clone()) | |
| 336 | + | } else { | |
| 337 | + | None | |
| 338 | + | }; | |
| 339 | + | (data_dir, browser, error, sync_manager, cache) | |
| 329 | 340 | } | |
| 330 | - | // Registry exists, unlicensed but in trial → open the active vault | |
| 331 | - | (Some(reg), license::LicenseStatus::Unlicensed) if has_active_trial => { | |
| 332 | - | let data_dir = reg.active.clone(); | |
| 333 | - | let _ = std::fs::create_dir_all(&data_dir); | |
| 334 | - | let sync_manager = create_sync_manager(&data_dir, runtime.handle()); | |
| 335 | - | let (browser, error) = init_browser( | |
| 336 | - | &data_dir, | |
| 337 | - | shared.clone(), | |
| 338 | - | &vault_setup::vault_name_for_path(reg, &data_dir), | |
| 339 | - | ); | |
| 340 | - | (data_dir, browser, error, sync_manager, None) | |
| 341 | - | } | |
| 342 | - | // Registry exists but unlicensed (deactivated and reactivated) | |
| 343 | - | (Some(reg), license::LicenseStatus::Unlicensed) => { | |
| 344 | - | tracing::info!("No valid license, showing activation screen"); | |
| 341 | + | // Registry exists but pitch not yet dismissed → show it, keep the vault path. | |
| 342 | + | (Some(reg), _, false) => { | |
| 343 | + | tracing::info!("Showing Pro pitch screen (registry present)"); | |
| 345 | 344 | (reg.active.clone(), None, None, None, None) | |
| 346 | 345 | } | |
| 347 | - | // No registry + licensed → vault setup (existing user upgrading) | |
| 348 | - | (None, license::LicenseStatus::Licensed(cache)) => { | |
| 349 | - | tracing::info!("Licensed but no vault registry, showing vault setup"); | |
| 350 | - | (default_vault.clone(), None, None, None, Some(cache.clone())) | |
| 351 | - | } | |
| 352 | - | // No registry + unlicensed → activation first (or vault setup if trial) | |
| 353 | - | (None, license::LicenseStatus::Unlicensed) => { | |
| 354 | - | if licensed_or_trial { | |
| 355 | - | tracing::info!("Trial mode, showing vault setup"); | |
| 346 | + | // No registry + cleared → vault setup. | |
| 347 | + | (None, status, true) => { | |
| 348 | + | tracing::info!("Cleared pitch without registry, showing vault setup"); | |
| 349 | + | let cache = if let license::LicenseStatus::Licensed(c) = status { | |
| 350 | + | Some(c.clone()) | |
| 356 | 351 | } else { | |
| 357 | - | tracing::info!("No license, showing activation screen"); | |
| 358 | - | } | |
| 352 | + | None | |
| 353 | + | }; | |
| 354 | + | (default_vault.clone(), None, None, None, cache) | |
| 355 | + | } | |
| 356 | + | // No registry + first-time user → show the pitch. | |
| 357 | + | (None, _, false) => { | |
| 358 | + | tracing::info!("First launch, showing Pro pitch screen"); | |
| 359 | 359 | (default_vault.clone(), None, None, None, None) | |
| 360 | 360 | } | |
| 361 | 361 | }; | |
| @@ -386,7 +386,6 @@ | |||
| 386 | 386 | activation_error: None, | |
| 387 | 387 | activating: false, | |
| 388 | 388 | license_cache, | |
| 389 | - | trial_state, | |
| 390 | 389 | }; | |
| 391 | 390 | app.sync_vault_list_to_browser(); | |
| 392 | 391 | app.sync_license_to_browser(); | |
| @@ -1099,7 +1098,10 @@ | |||
| 1099 | 1098 | } | |
| 1100 | 1099 | ||
| 1101 | 1100 | #[test] | |
| 1102 | - | fn initial_screen_unlicensed_with_registry() { | |
| 1101 | + | fn initial_screen_unlicensed_with_registry_shows_pitch() { | |
| 1102 | + | // First-time launch (dismissed=false) still shows the Pro pitch even | |
| 1103 | + | // though the registry exists — the pitch is a one-time onboarding | |
| 1104 | + | // surface, not a per-vault gate. | |
| 1103 | 1105 | let dir = tempfile::tempdir().unwrap(); | |
| 1104 | 1106 | let reg = Some(make_registry(dir.path())); | |
| 1105 | 1107 | let status = license::LicenseStatus::Unlicensed; | |
| @@ -1110,7 +1112,7 @@ | |||
| 1110 | 1112 | } | |
| 1111 | 1113 | ||
| 1112 | 1114 | #[test] | |
| 1113 | - | fn initial_screen_unlicensed_without_registry() { | |
| 1115 | + | fn initial_screen_unlicensed_without_registry_shows_pitch() { | |
| 1114 | 1116 | let status = license::LicenseStatus::Unlicensed; | |
| 1115 | 1117 | assert_eq!( | |
| 1116 | 1118 | resolve_initial_screen(None, &status, false), | |
| @@ -1119,7 +1121,9 @@ | |||
| 1119 | 1121 | } | |
| 1120 | 1122 | ||
| 1121 | 1123 | #[test] | |
| 1122 | - | fn initial_screen_trial_with_registry() { | |
| 1124 | + | fn initial_screen_dismissed_skips_pitch_with_registry() { | |
| 1125 | + | // "Don't show again" was ticked: unlicensed user drops straight into | |
| 1126 | + | // the browser on subsequent launches. | |
| 1123 | 1127 | let dir = tempfile::tempdir().unwrap(); | |
| 1124 | 1128 | let reg = Some(make_registry(dir.path())); | |
| 1125 | 1129 | let status = license::LicenseStatus::Unlicensed; | |
| @@ -1130,7 +1134,7 @@ | |||
| 1130 | 1134 | } | |
| 1131 | 1135 | ||
| 1132 | 1136 | #[test] | |
| 1133 | - | fn initial_screen_trial_without_registry() { | |
| 1137 | + | fn initial_screen_dismissed_skips_pitch_without_registry() { | |
| 1134 | 1138 | let status = license::LicenseStatus::Unlicensed; | |
| 1135 | 1139 | assert_eq!( | |
| 1136 | 1140 | resolve_initial_screen(None, &status, true), |
| @@ -21,12 +21,21 @@ | |||
| 21 | 21 | /// environments can disable from the About box. | |
| 22 | 22 | #[serde(default = "default_check_for_updates")] | |
| 23 | 23 | pub check_for_updates: bool, | |
| 24 | + | ||
| 25 | + | /// Whether the user has ticked "don't show this again" on the Pro pitch. | |
| 26 | + | /// When `true`, launch skips the activation screen and drops straight into | |
| 27 | + | /// vault setup or the browser. Set only via the checkbox on that screen; | |
| 28 | + | /// entering a key doesn't flip it (a licensed user has already dismissed | |
| 29 | + | /// the pitch by virtue of being licensed). | |
| 30 | + | #[serde(default)] | |
| 31 | + | pub activation_prompt_dismissed: bool, | |
| 24 | 32 | } | |
| 25 | 33 | ||
| 26 | 34 | impl Default for Preferences { | |
| 27 | 35 | fn default() -> Self { | |
| 28 | 36 | Self { | |
| 29 | 37 | check_for_updates: default_check_for_updates(), | |
| 38 | + | activation_prompt_dismissed: false, | |
| 30 | 39 | } | |
| 31 | 40 | } | |
| 32 | 41 | } | |
| @@ -82,6 +91,7 @@ | |||
| 82 | 91 | let dir = tempfile::tempdir().unwrap(); | |
| 83 | 92 | let p = Preferences { | |
| 84 | 93 | check_for_updates: false, | |
| 94 | + | activation_prompt_dismissed: false, | |
| 85 | 95 | }; | |
| 86 | 96 | p.save(dir.path()); | |
| 87 | 97 | let loaded = Preferences::load(dir.path()); |
| @@ -301,8 +301,6 @@ | |||
| 301 | 301 | pub license_key_masked: Option<String>, | |
| 302 | 302 | /// Machine ID for display. | |
| 303 | 303 | pub machine_id: Option<String>, | |
| 304 | - | /// Trial days remaining (None if not in trial mode). | |
| 305 | - | pub trial_days_remaining: Option<i64>, | |
| 306 | 304 | ||
| 307 | 305 | /// Cached list of tombstoned samples shown in the Trash section, most | |
| 308 | 306 | /// recently deleted first. Refreshed when the section opens and after an |
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | //! UI submodules: each panel and widget type in its own file. | |
| 2 | 2 | ||
| 3 | 3 | pub mod classifier; | |
| 4 | + | pub mod color; | |
| 4 | 5 | pub mod detail; | |
| 5 | 6 | pub mod dialog; | |
| 6 | 7 | pub mod edit_panel; |
| @@ -728,7 +728,12 @@ | |||
| 728 | 728 | // ── License section ── | |
| 729 | 729 | ||
| 730 | 730 | fn draw_license_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 731 | - | egui::CollapsingHeader::new(egui::RichText::new("License").strong()) | |
| 731 | + | let header = if state.settings.license_key_masked.is_some() { | |
| 732 | + | "audiofiles Pro" | |
| 733 | + | } else { | |
| 734 | + | "License" | |
| 735 | + | }; | |
| 736 | + | egui::CollapsingHeader::new(egui::RichText::new(header).strong()) | |
| 732 | 737 | .default_open(false) | |
| 733 | 738 | .show(ui, |ui| { | |
| 734 | 739 | if let Some(ref masked) = state.settings.license_key_masked { | |
| @@ -736,24 +741,13 @@ | |||
| 736 | 741 | ui.label("Key:"); | |
| 737 | 742 | ui.label(egui::RichText::new(masked).color(theme::content_secondary())); | |
| 738 | 743 | }); | |
| 739 | - | } else if let Some(days) = state.settings.trial_days_remaining { | |
| 740 | - | // "Trial: 0 days" was technically correct but uncomfortably | |
| 741 | - | // terse at the expired state; rephrase so the dead-end reads | |
| 742 | - | // as a status, not a counter (m-13). A Purchase button would | |
| 743 | - | // belong here but the buy flow is not yet wired. | |
| 744 | - | let text = if days > 0 { | |
| 745 | - | format!("Trial: {days} days left") | |
| 746 | - | } else { | |
| 747 | - | "Trial expired".to_string() | |
| 748 | - | }; | |
| 749 | - | let color = if days > 7 { | |
| 750 | - | theme::content_secondary() | |
| 751 | - | } else if days > 0 { | |
| 752 | - | theme::warning() | |
| 753 | - | } else { | |
| 754 | - | theme::content_muted() | |
| 755 | - | }; | |
| 756 | - | ui.label(egui::RichText::new(text).color(color)); | |
| 744 | + | } else { | |
| 745 | + | ui.label( | |
| 746 | + | egui::RichText::new( | |
| 747 | + | "No license key. audiofiles is fully functional without one.", | |
| 748 | + | ) | |
| 749 | + | .color(theme::content_secondary()), | |
| 750 | + | ); | |
| 757 | 751 | } | |
| 758 | 752 | if let Some(ref mid) = state.settings.machine_id { | |
| 759 | 753 | ui.horizontal(|ui| { |
| @@ -316,7 +316,7 @@ | |||
| 316 | 316 | ui.add_space(theme::space::MD); | |
| 317 | 317 | ui.label( | |
| 318 | 318 | egui::RichText::new( | |
| 319 | - | "Open a vault and ensure your license or trial is active to enable sync.", | |
| 319 | + | "Open a vault to enable sync.", | |
| 320 | 320 | ) | |
| 321 | 321 | .small() | |
| 322 | 322 | .weak(), |
| @@ -100,23 +100,23 @@ | |||
| 100 | 100 | ||
| 101 | 101 | impl Default for ThemeColors { | |
| 102 | 102 | fn default() -> Self { | |
| 103 | - | // audiofiles default: muted sage & mocha (keep in sync with | |
| 103 | + | // audiofiles default: Mac OS 8 Platinum (keep in sync with | |
| 104 | 104 | // themes/audiofiles.toml, this is the pre-load Rust fallback). | |
| 105 | 105 | Self { | |
| 106 | - | surface_page: Color32::from_rgb(0xCC, 0xDA, 0xD1), | |
| 107 | - | surface_overlay: Color32::from_rgb(0xB4, 0xC5, 0xBB), | |
| 108 | - | surface_sunken: Color32::from_rgb(0x9C, 0xAE, 0xA9), | |
| 109 | - | surface_raised: Color32::from_rgb(0xDA, 0xE3, 0xDC), | |
| 110 | - | content: Color32::from_rgb(0x38, 0x30, 0x2E), | |
| 111 | - | content_secondary: Color32::from_rgb(0x6F, 0x68, 0x66), | |
| 112 | - | content_muted: Color32::from_rgb(0x78, 0x85, 0x85), | |
| 113 | - | danger: Color32::from_rgb(0xB0, 0x5F, 0x4E), | |
| 114 | - | success: Color32::from_rgb(0x6F, 0x8A, 0x5C), | |
| 115 | - | action: Color32::from_rgb(0x5E, 0x7C, 0x8E), | |
| 116 | - | warning: Color32::from_rgb(0xC1, 0x9A, 0x53), | |
| 117 | - | category_five: Color32::from_rgb(0x83, 0x6A, 0x80), | |
| 118 | - | category_six: Color32::from_rgb(0x5F, 0x8C, 0x82), | |
| 119 | - | border: Color32::from_rgb(0x9C, 0xAE, 0xA9), | |
| 106 | + | surface_page: Color32::from_rgb(0xDD, 0xDD, 0xDD), | |
| 107 | + | surface_overlay: Color32::from_rgb(0xEE, 0xEE, 0xEE), | |
| 108 | + | surface_sunken: Color32::from_rgb(0xB0, 0xB0, 0xB0), | |
| 109 | + | surface_raised: Color32::from_rgb(0xFF, 0xFF, 0xFF), | |
| 110 | + | content: Color32::from_rgb(0x00, 0x00, 0x00), | |
| 111 | + | content_secondary: Color32::from_rgb(0x33, 0x33, 0x33), | |
| 112 | + | content_muted: Color32::from_rgb(0x80, 0x80, 0x80), | |
| 113 | + | danger: Color32::from_rgb(0xC2, 0x2F, 0x2F), | |
| 114 | + | success: Color32::from_rgb(0x2E, 0x7D, 0x32), | |
| 115 | + | action: Color32::from_rgb(0x3B, 0x5A, 0x9F), | |
| 116 | + | warning: Color32::from_rgb(0xE0, 0xA0, 0x30), | |
| 117 | + | category_five: Color32::from_rgb(0x00, 0x9C, 0xDF), | |
| 118 | + | category_six: Color32::from_rgb(0x97, 0x39, 0x99), | |
| 119 | + | border: Color32::from_rgb(0x80, 0x80, 0x80), | |
| 120 | 120 | rounding: 4.0, | |
| 121 | 121 | item_spacing_x: 8.0, | |
| 122 | 122 | item_spacing_y: 5.0, | |
| @@ -184,27 +184,10 @@ | |||
| 184 | 184 | // egui's `Color32` to that shared implementation; the derivation *choices* | |
| 185 | 185 | // (which colors, what ratio) stay here as audiofiles' egui styling. | |
| 186 | 186 | ||
| 187 | - | fn to_rgb(c: Color32) -> makeover::Rgb { | |
| 188 | - | makeover::Rgb { | |
| 189 | - | r: c.r(), | |
| 190 | - | g: c.g(), | |
| 191 | - | b: c.b(), | |
| 192 | - | } | |
| 193 | - | } | |
| 194 | - | fn from_rgb(c: makeover::Rgb) -> Color32 { | |
| 195 | - | Color32::from_rgb(c.r, c.g, c.b) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | /// Pick white or black text for legibility on `bg`, by WCAG contrast ratio. | |
| 199 | - | fn contrast_color(bg: Color32) -> Color32 { | |
| 200 | - | from_rgb(makeover::readable_on(to_rgb(bg))) | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | /// Perceptual (OKLab) blend from `a` to `b` by `t` in [0,1]. Used to derive row | |
| 204 | - | /// stripes, selection highlights, and hover states from the base palette. | |
| 205 | - | fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { | |
| 206 | - | from_rgb(makeover::mix(to_rgb(a), to_rgb(b), t)) | |
| 207 | - | } | |
| 187 | + | // Color-math primitives live in `super::color`; the theme calls them through | |
| 188 | + | // that seam so the makeover↔egui adapter can be pulled into its own crate if | |
| 189 | + | // another project ever needs it. | |
| 190 | + | use super::color::{contrast_ratio, is_light, lerp_color}; | |
| 208 | 191 | ||
| 209 | 192 | // --- Public accessors --- | |
| 210 | 193 | ||
| @@ -343,23 +326,6 @@ | |||
| 343 | 326 | } | |
| 344 | 327 | } | |
| 345 | 328 | ||
| 346 | - | fn relative_luminance(c: Color32) -> f64 { | |
| 347 | - | fn lin(ch: u8) -> f64 { | |
| 348 | - | let c = ch as f64 / 255.0; | |
| 349 | - | if c <= 0.03928 { | |
| 350 | - | c / 12.92 | |
| 351 | - | } else { | |
| 352 | - | ((c + 0.055) / 1.055).powf(2.4) | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) | |
| 356 | - | } | |
| 357 | - | ||
| 358 | - | fn contrast_ratio(a: Color32, b: Color32) -> f64 { | |
| 359 | - | let (la, lb) = (relative_luminance(a), relative_luminance(b)); | |
| 360 | - | let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; | |
| 361 | - | (hi + 0.05) / (lo + 0.05) | |
| 362 | - | } | |
| 363 | 329 | ||
| 364 | 330 | /// Load a theme's full color set by id (bundled or custom on disk). | |
| 365 | 331 | fn theme_colors_for(id: &str) -> Option<ThemeColors> { | |
| @@ -670,7 +636,15 @@ | |||
| 670 | 636 | /// Apply the current theme's visuals to the egui context. | |
| 671 | 637 | pub fn apply_theme(ctx: &egui::Context) { | |
| 672 | 638 | let t = THEME.read(); | |
| 673 | - | let mut visuals = egui::Visuals::dark(); | |
| 639 | + | // Pick the base preset by luminance of the page background: a light theme | |
| 640 | + | // needs `dark_mode = false` so `RichText::strong()` renders BLACK (readable | |
| 641 | + | // on a light surface) rather than the dark-mode WHITE (invisible on it). | |
| 642 | + | // Widget colors are all overridden below regardless. | |
| 643 | + | let mut visuals = if is_light(t.surface_page) { | |
| 644 | + | egui::Visuals::light() | |
| 645 | + | } else { | |
| 646 | + | egui::Visuals::dark() | |
| 647 | + | }; | |
| 674 | 648 | ||
| 675 | 649 | visuals.panel_fill = t.surface_overlay; | |
| 676 | 650 | visuals.window_fill = t.surface_overlay; | |
| @@ -686,12 +660,34 @@ | |||
| 686 | 660 | visuals.widgets.noninteractive.bg_fill = t.surface_overlay; | |
| 687 | 661 | visuals.widgets.inactive.bg_fill = lerp_color(t.surface_overlay, t.surface_sunken, 0.3); | |
| 688 | 662 | visuals.widgets.hovered.bg_fill = t.surface_sunken; | |
| 689 | - | visuals.widgets.active.bg_fill = t.action; | |
| 663 | + | // Pressed/active fill stays in the neutral-gray family (darker than | |
| 664 | + | // hovered, for a "pressed" feel) rather than adopting the accent color. | |
| 665 | + | // egui derives `strong_text_color()` from `widgets.active.fg_stroke`, so | |
| 666 | + | // if this bg were the accent blue we'd be forced to set fg to | |
| 667 | + | // `contrast_color(action)` = WHITE, which then poisons every `.strong()` | |
| 668 | + | // label in the app (white text on light panels = unreadable). The | |
| 669 | + | // accent still shows up as the focus/selection stroke below. | |
| 670 | + | visuals.widgets.active.bg_fill = lerp_color(t.surface_sunken, t.content, 0.15); | |
| 671 | + | ||
| 672 | + | // egui's Button widget paints its background from `weak_bg_fill`, not | |
| 673 | + | // `bg_fill`; the latter only shows once the button is hovered/pressed. | |
| 674 | + | // Without this mirror the buttons picked up the base-preset default | |
| 675 | + | // (near-black in dark, near-white in light), which had them either | |
| 676 | + | // invisibly dark on a light theme or blowing out any surrounding chrome. | |
| 677 | + | visuals.widgets.noninteractive.weak_bg_fill = visuals.widgets.noninteractive.bg_fill; | |
| 678 | + | visuals.widgets.inactive.weak_bg_fill = visuals.widgets.inactive.bg_fill; | |
| 679 | + | visuals.widgets.hovered.weak_bg_fill = visuals.widgets.hovered.bg_fill; | |
| 680 | + | visuals.widgets.active.weak_bg_fill = visuals.widgets.active.bg_fill; | |
| 681 | + | visuals.widgets.open.weak_bg_fill = visuals.widgets.inactive.bg_fill; | |
| 690 | 682 | ||
| 691 | 683 | visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, t.content_secondary); | |
| 692 | 684 | visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, t.content); | |
| 693 | 685 | visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, t.content); | |
| 694 | - | visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, contrast_color(t.action)); | |
| 686 | + | // Active fg is the *strong-text* color across egui, not just pressed-button | |
| 687 | + | // text. Keep it at `t.content` so `RichText::strong()` reads as the | |
| 688 | + | // theme's primary text on light panels (BLACK), not the previous | |
| 689 | + | // accent-inverted WHITE that vanished into the background. | |
| 690 | + | visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, t.content); | |
| 695 | 691 | visuals.widgets.open.fg_stroke = egui::Stroke::new(1.0, t.content); | |
| 696 | 692 | ||
| 697 | 693 | visuals.window_stroke = egui::Stroke::new(1.0, t.border); | |
| @@ -742,100 +738,9 @@ | |||
| 742 | 738 | use super::*; | |
| 743 | 739 | use std::collections::HashMap; | |
| 744 | 740 | ||
| 745 | - | // lerp_color | |
| 746 | - | ||
| 747 | - | #[test] | |
| 748 | - | fn contrast_color_black_bg_returns_white() { | |
| 749 | - | assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE); | |
| 750 | - | } | |
| 751 | - | ||
| 752 | - | #[test] | |
| 753 | - | fn contrast_color_white_bg_returns_black() { | |
| 754 | - | assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK); | |
| 755 | - | } | |
| 756 | - | ||
| 757 | - | #[test] | |
| 758 | - | fn contrast_color_medium_blue_returns_black() { | |
| 759 | - | // #0a84ff is a medium blue; by WCAG contrast ratio black reads better | |
| 760 | - | // than white on it (~5.8:1 vs ~3.6:1), so readable_on picks black. | |
| 761 | - | assert_eq!( | |
| 762 | - | contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)), | |
| 763 | - | Color32::BLACK | |
| 764 | - | ); | |
| 765 | - | } | |
| 766 | - | ||
| 767 | - | #[test] | |
| 768 | - | fn contrast_color_bright_yellow_returns_black() { | |
| 769 | - | assert_eq!( | |
| 770 | - | contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)), | |
| 771 | - | Color32::BLACK | |
| 772 | - | ); | |
| 773 | - | } | |
| 774 | - | ||
| 775 | - | // lerp_color | |
| 776 | - | ||
| 777 | - | #[test] | |
| 778 | - | fn lerp_color_t0_returns_a() { | |
| 779 | - | let a = Color32::from_rgb(100, 150, 200); | |
| 780 | - | let b = Color32::from_rgb(200, 50, 0); | |
| 781 | - | assert_eq!(lerp_color(a, b, 0.0), a); | |
| 782 | - | } | |
| 783 | - | ||
| 784 | - | #[test] | |
| 785 | - | fn lerp_color_t1_returns_b() { | |
| 786 | - | let a = Color32::from_rgb(100, 150, 200); | |
| 787 | - | let b = Color32::from_rgb(200, 50, 0); | |
| 788 | - | assert_eq!(lerp_color(a, b, 1.0), b); | |
| 789 | - | } | |
| 790 | - | ||
| 791 | - | #[test] | |
| 792 | - | fn lerp_color_midpoint_is_between() { | |
| 793 | - | // OKLab blend: not the sRGB arithmetic mean, but each channel lies | |
| 794 | - | // between the endpoints. | |
| 795 | - | let a = Color32::from_rgb(0, 0, 0); | |
| 796 | - | let b = Color32::from_rgb(100, 200, 50); | |
| 797 | - | let mid = lerp_color(a, b, 0.5); | |
| 798 | - | assert!(mid.r() > 0 && mid.r() < 100); | |
| 799 | - | assert!(mid.g() > 0 && mid.g() < 200); | |
| 800 | - | assert!(mid.b() > 0 && mid.b() < 50); | |
| 801 | - | } | |
| 802 | - | ||
| 803 | - | #[test] | |
| 804 | - | fn lerp_color_quarter_is_closer_to_a() { | |
| 805 | - | let a = Color32::from_rgb(0, 0, 0); | |
| 806 | - | let b = Color32::from_rgb(100, 200, 40); | |
| 807 | - | let quarter = lerp_color(a, b, 0.25); | |
| 808 | - | let half = lerp_color(a, b, 0.5); | |
| 809 | - | // 0.25 sits between a and the midpoint along lightness. | |
| 810 | - | assert!(quarter.g() > 0 && quarter.g() < half.g()); | |
| 811 | - | } | |
| 812 | - | ||
| 813 | - | #[test] | |
| 814 | - | fn lerp_color_identical_returns_same() { | |
| 815 | - | let c = Color32::from_rgb(42, 42, 42); | |
| 816 | - | assert_eq!(lerp_color(c, c, 0.5), c); | |
| 817 | - | } | |
| 818 | - | ||
| 819 | - | #[test] | |
| 820 | - | fn lerp_color_black_to_white() { | |
| 821 | - | let black = Color32::from_rgb(0, 0, 0); | |
| 822 | - | let white = Color32::from_rgb(255, 255, 255); | |
| 823 | - | let mid = lerp_color(black, white, 0.5); | |
| 824 | - | // Perceptual mid-gray (r==g==b, strictly between the endpoints). | |
| 825 | - | assert_eq!(mid.r(), mid.g()); | |
| 826 | - | assert_eq!(mid.g(), mid.b()); | |
| 827 | - | assert!(mid.r() > 0 && mid.r() < 255); | |
| 828 | - | } | |
| 829 | - | ||
| 830 | - | #[test] | |
| 831 | - | fn lerp_color_white_to_black() { | |
| 832 | - | let black = Color32::from_rgb(0, 0, 0); | |
| 833 | - | let white = Color32::from_rgb(255, 255, 255); | |
| 834 | - | let mid = lerp_color(white, black, 0.5); | |
| 835 | - | assert_eq!(mid.r(), mid.g()); | |
| 836 | - | assert_eq!(mid.g(), mid.b()); | |
| 837 | - | assert!(mid.r() > 0 && mid.r() < 255); | |
| 838 | - | } | |
| 741 | + | // Color-math primitives (contrast_color, is_light, lerp_color, | |
| 742 | + | // relative_luminance, contrast_ratio) have their own tests in | |
| 743 | + | // `super::color::tests` now that the makeover↔egui adapter lives there. | |
| 839 | 744 | ||
| 840 | 745 | // parse_hex | |
| 841 | 746 | ||
| @@ -1158,11 +1063,12 @@ | |||
| 1158 | 1063 | #[test] | |
| 1159 | 1064 | fn theme_colors_default_is_audiofiles() { | |
| 1160 | 1065 | let d = ThemeColors::default(); | |
| 1161 | - | // The muted sage-and-mocha default skin. | |
| 1162 | - | assert_eq!(d.surface_page, Color32::from_rgb(0xCC, 0xDA, 0xD1)); | |
| 1163 | - | assert_eq!(d.content, Color32::from_rgb(0x38, 0x30, 0x2E)); | |
| 1164 | - | assert_eq!(d.action, Color32::from_rgb(0x5E, 0x7C, 0x8E)); | |
| 1165 | - | assert_eq!(d.border, Color32::from_rgb(0x9C, 0xAE, 0xA9)); | |
| 1066 | + | // Mac OS 8 Platinum: warm-neutral chrome, white wells, black text, | |
| 1067 | + | // Appearance-Manager navy for the action ring. | |
| 1068 | + | assert_eq!(d.surface_page, Color32::from_rgb(0xDD, 0xDD, 0xDD)); | |
| 1069 | + | assert_eq!(d.content, Color32::from_rgb(0x00, 0x00, 0x00)); | |
| 1070 | + | assert_eq!(d.action, Color32::from_rgb(0x3B, 0x5A, 0x9F)); | |
| 1071 | + | assert_eq!(d.border, Color32::from_rgb(0x80, 0x80, 0x80)); | |
| 1166 | 1072 | } | |
| 1167 | 1073 | ||
| 1168 | 1074 | // Bundled theme parsing (round-trip all embedded themes) |
| @@ -1,0 +1,215 @@ | |||
| 1 | + | //! Color-math adapter between [`makeover`] (OKLab-based perceptual color | |
| 2 | + | //! utilities) and egui's [`Color32`]. | |
| 3 | + | //! | |
| 4 | + | //! `makeover` speaks `Rgb { r, g, b }`; egui speaks `Color32`. Every place the | |
| 5 | + | //! theme wants a perceptual blend, a WCAG-driven readable text pick, or a | |
| 6 | + | //! contrast ratio, it does so through this module — so the seam stays in one | |
| 7 | + | //! place and the rest of the theme code stays egui-native. | |
| 8 | + | //! | |
| 9 | + | //! Kept as a plain module for now. If a second project ever needs the same | |
| 10 | + | //! shim (Alloy's egui surfaces are the most likely candidate), promote to | |
| 11 | + | //! `Libraries/makeover-egui` with the same public API and swap the `use` | |
| 12 | + | //! sites; nothing here depends on audiofiles-specific state. | |
| 13 | + | ||
| 14 | + | use egui::Color32; | |
| 15 | + | ||
| 16 | + | /// Convert egui `Color32` → `makeover::Rgb`, dropping the alpha channel. | |
| 17 | + | /// `makeover`'s color math is defined on opaque sRGB triples; the caller is | |
| 18 | + | /// responsible for compositing anything translucent before this hop. | |
| 19 | + | pub fn to_rgb(c: Color32) -> makeover::Rgb { | |
| 20 | + | makeover::Rgb { | |
| 21 | + | r: c.r(), | |
| 22 | + | g: c.g(), | |
| 23 | + | b: c.b(), | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Convert `makeover::Rgb` → opaque egui `Color32`. | |
| 28 | + | pub fn from_rgb(c: makeover::Rgb) -> Color32 { | |
| 29 | + | Color32::from_rgb(c.r, c.g, c.b) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Pick white or black text for legibility on `bg`, by WCAG contrast ratio. | |
| 33 | + | /// Thin wrapper over [`makeover::readable_on`] that stays in egui types. | |
| 34 | + | pub fn contrast_color(bg: Color32) -> Color32 { | |
| 35 | + | from_rgb(makeover::readable_on(to_rgb(bg))) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// Whether `bg` reads as a light color, using the same WCAG-driven test as | |
| 39 | + | /// [`contrast_color`]. A "light" surface is one where BLACK is the more | |
| 40 | + | /// readable text choice; useful for picking egui's light-vs-dark base preset | |
| 41 | + | /// so `RichText::strong()` and `.weak()` resolve to the right end of the | |
| 42 | + | /// spectrum. | |
| 43 | + | pub fn is_light(bg: Color32) -> bool { | |
| 44 | + | contrast_color(bg) == Color32::BLACK | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Perceptual (OKLab) blend from `a` to `b` by `t` in `[0, 1]`. Used to | |
| 48 | + | /// derive row stripes, selection highlights, and hover states from a base | |
| 49 | + | /// palette without the muddy midpoints an sRGB lerp would give. | |
| 50 | + | pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { | |
| 51 | + | from_rgb(makeover::mix(to_rgb(a), to_rgb(b), t)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Relative luminance per WCAG 2.x, in `[0, 1]`. Pure sRGB math — no | |
| 55 | + | /// `makeover` dependency — but lives here so all contrast/readability | |
| 56 | + | /// primitives sit in one module. | |
| 57 | + | pub fn relative_luminance(c: Color32) -> f64 { | |
| 58 | + | fn lin(ch: u8) -> f64 { | |
| 59 | + | let c = ch as f64 / 255.0; | |
| 60 | + | if c <= 0.03928 { | |
| 61 | + | c / 12.92 | |
| 62 | + | } else { | |
| 63 | + | ((c + 0.055) / 1.055).powf(2.4) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | 0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b()) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// WCAG 2.x contrast ratio between two colors, in `[1, 21]`. Higher is | |
| 70 | + | /// more legible; 4.5:1 is the AA threshold for normal text. | |
| 71 | + | pub fn contrast_ratio(a: Color32, b: Color32) -> f64 { | |
| 72 | + | let (la, lb) = (relative_luminance(a), relative_luminance(b)); | |
| 73 | + | let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; | |
| 74 | + | (hi + 0.05) / (lo + 0.05) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | #[cfg(test)] | |
| 78 | + | mod tests { | |
| 79 | + | use super::*; | |
| 80 | + | ||
| 81 | + | // ── contrast_color ── | |
| 82 | + | ||
| 83 | + | #[test] | |
| 84 | + | fn contrast_color_black_bg_returns_white() { | |
| 85 | + | assert_eq!(contrast_color(Color32::BLACK), Color32::WHITE); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[test] | |
| 89 | + | fn contrast_color_white_bg_returns_black() { | |
| 90 | + | assert_eq!(contrast_color(Color32::WHITE), Color32::BLACK); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | #[test] | |
| 94 | + | fn contrast_color_medium_blue_returns_black() { | |
| 95 | + | // #0a84ff is a medium blue; by WCAG contrast ratio black reads better | |
| 96 | + | // than white on it (~5.8:1 vs ~3.6:1), so readable_on picks black. | |
| 97 | + | assert_eq!( | |
| 98 | + | contrast_color(Color32::from_rgb(0x0a, 0x84, 0xff)), | |
| 99 | + | Color32::BLACK | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[test] | |
| 104 | + | fn contrast_color_bright_yellow_returns_black() { | |
| 105 | + | assert_eq!( | |
| 106 | + | contrast_color(Color32::from_rgb(0xff, 0xd6, 0x0a)), | |
| 107 | + | Color32::BLACK | |
| 108 | + | ); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // ── is_light ── | |
| 112 | + | ||
| 113 | + | #[test] | |
| 114 | + | fn is_light_agrees_with_contrast_color() { | |
| 115 | + | // is_light is a strict alias for "black text is the readable pick". | |
| 116 | + | for (r, g, b) in [ | |
| 117 | + | (0, 0, 0), | |
| 118 | + | (255, 255, 255), | |
| 119 | + | (0xEE, 0xEE, 0xEE), | |
| 120 | + | (0x3B, 0x5A, 0x9F), | |
| 121 | + | (0xff, 0xd6, 0x0a), | |
| 122 | + | ] { | |
| 123 | + | let c = Color32::from_rgb(r, g, b); | |
| 124 | + | assert_eq!(is_light(c), contrast_color(c) == Color32::BLACK); | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ── lerp_color ── | |
| 129 | + | ||
| 130 | + | #[test] | |
| 131 | + | fn lerp_color_t0_returns_a() { | |
| 132 | + | let a = Color32::from_rgb(100, 150, 200); | |
| 133 | + | let b = Color32::from_rgb(200, 50, 0); | |
| 134 | + | assert_eq!(lerp_color(a, b, 0.0), a); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn lerp_color_t1_returns_b() { | |
| 139 | + | let a = Color32::from_rgb(100, 150, 200); | |
| 140 | + | let b = Color32::from_rgb(200, 50, 0); | |
| 141 | + | assert_eq!(lerp_color(a, b, 1.0), b); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[test] | |
| 145 | + | fn lerp_color_midpoint_is_between() { | |
| 146 | + | // OKLab blend: not the sRGB arithmetic mean, but each channel lies | |
| 147 | + | // between the endpoints. | |
| 148 | + | let a = Color32::from_rgb(0, 0, 0); | |
| 149 | + | let b = Color32::from_rgb(100, 200, 50); | |
| 150 | + | let mid = lerp_color(a, b, 0.5); | |
| 151 | + | assert!(mid.r() > 0 && mid.r() < 100); | |
| 152 | + | assert!(mid.g() > 0 && mid.g() < 200); | |
| 153 | + | assert!(mid.b() > 0 && mid.b() < 50); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | #[test] | |
| 157 | + | fn lerp_color_quarter_is_closer_to_a() { | |
| 158 | + | let a = Color32::from_rgb(0, 0, 0); | |
| 159 | + | let b = Color32::from_rgb(100, 200, 40); | |
| 160 | + | let quarter = lerp_color(a, b, 0.25); | |
| 161 | + | let half = lerp_color(a, b, 0.5); | |
| 162 | + | // 0.25 sits between a and the midpoint along lightness. | |
| 163 | + | assert!(quarter.g() > 0 && quarter.g() < half.g()); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn lerp_color_identical_returns_same() { | |
| 168 | + | let c = Color32::from_rgb(42, 42, 42); | |
| 169 | + | assert_eq!(lerp_color(c, c, 0.5), c); | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | #[test] | |
| 173 | + | fn lerp_color_black_to_white() { | |
| 174 | + | let black = Color32::from_rgb(0, 0, 0); | |
| 175 | + | let white = Color32::from_rgb(255, 255, 255); | |
| 176 | + | let mid = lerp_color(black, white, 0.5); | |
| 177 | + | // Perceptual mid-gray (r==g==b, strictly between the endpoints). | |
| 178 | + | assert_eq!(mid.r(), mid.g()); | |
| 179 | + | assert_eq!(mid.g(), mid.b()); | |
| 180 | + | assert!(mid.r() > 0 && mid.r() < 255); | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | #[test] | |
| 184 | + | fn lerp_color_white_to_black() { | |
| 185 | + | let black = Color32::from_rgb(0, 0, 0); | |
| 186 | + | let white = Color32::from_rgb(255, 255, 255); | |
| 187 | + | let mid = lerp_color(white, black, 0.5); | |
| 188 | + | assert_eq!(mid.r(), mid.g()); | |
| 189 | + | assert_eq!(mid.g(), mid.b()); | |
| 190 | + | assert!(mid.r() > 0 && mid.r() < 255); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // ── relative_luminance / contrast_ratio ── | |
| 194 | + | ||
| 195 | + | #[test] | |
| 196 | + | fn relative_luminance_endpoints() { | |
| 197 | + | assert!((relative_luminance(Color32::BLACK) - 0.0).abs() < 1e-9); | |
| 198 | + | assert!((relative_luminance(Color32::WHITE) - 1.0).abs() < 1e-9); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn contrast_ratio_black_on_white_is_21() { | |
| 203 | + | let r = contrast_ratio(Color32::BLACK, Color32::WHITE); | |
| 204 | + | assert!((r - 21.0).abs() < 1e-6); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn contrast_ratio_is_symmetric() { | |
| 209 | + | let a = Color32::from_rgb(0x3B, 0x5A, 0x9F); | |
| 210 | + | let b = Color32::from_rgb(0xEE, 0xEE, 0xEE); | |
| 211 | + | let r1 = contrast_ratio(a, b); | |
| 212 | + | let r2 = contrast_ratio(b, a); | |
| 213 | + | assert!((r1 - r2).abs() < 1e-12); | |
| 214 | + | } | |
| 215 | + | } |