Skip to content

Commit 19793f9

Browse files
committed
d
1 parent 3d2ecc7 commit 19793f9

File tree

6 files changed

+426
-6
lines changed

6 files changed

+426
-6
lines changed

Cargo.lock

Lines changed: 34 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ wb_sqlite = "0.2.1"
4949
rusqlite = { version = "0.36.0", features = ["bundled"] }
5050
num-format = "0.4.4"
5151
fasteval = "0.2.4"
52+
png = "0.17"

src/color_quiz.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use png::{BitDepth, ColorType, Encoder};
2+
use rand::Rng;
3+
use std::io::Cursor;
4+
5+
#[derive(Debug, Clone)]
6+
pub struct ColorQuiz {
7+
pub r: u8,
8+
pub g: u8,
9+
pub b: u8,
10+
}
11+
12+
impl ColorQuiz {
13+
/// Generate a random color quiz
14+
pub fn generate<R: Rng>(rng: &mut R) -> Self {
15+
Self {
16+
r: rng.gen_range(0..=255),
17+
g: rng.gen_range(0..=255),
18+
b: rng.gen_range(0..=255),
19+
}
20+
}
21+
22+
/// Generate a 16:9 image (640x360) with the color
23+
pub fn generate_image(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
24+
let width = 640u32;
25+
let height = 360u32;
26+
27+
let mut buffer = Cursor::new(Vec::new());
28+
29+
{
30+
let mut encoder = Encoder::new(&mut buffer, width, height);
31+
encoder.set_color(ColorType::Rgb);
32+
encoder.set_depth(BitDepth::Eight);
33+
34+
let mut writer = encoder.write_header()?;
35+
36+
// Create image data - each pixel is RGB (3 bytes)
37+
let pixel_count = (width * height) as usize;
38+
let mut data = Vec::with_capacity(pixel_count * 3);
39+
40+
for _ in 0..pixel_count {
41+
data.push(self.r);
42+
data.push(self.g);
43+
data.push(self.b);
44+
}
45+
46+
writer.write_image_data(&data)?;
47+
}
48+
49+
Ok(buffer.into_inner())
50+
}
51+
52+
/// Validate user's answer in hex or oklch format
53+
pub fn validate_answer(&self, user_answer: &str) -> bool {
54+
let answer = user_answer.trim();
55+
56+
// Try hex format first
57+
if answer.starts_with('#') {
58+
return self.validate_hex(answer);
59+
}
60+
61+
// Try oklch format
62+
self.validate_oklch(answer)
63+
}
64+
65+
/// Validate hex color format: #RRGGBB
66+
/// Tolerance: total difference of 20 across all channels
67+
fn validate_hex(&self, hex: &str) -> bool {
68+
// Remove the # and parse
69+
let hex = hex.trim_start_matches('#');
70+
71+
if hex.len() != 6 {
72+
return false;
73+
}
74+
75+
let r = u8::from_str_radix(&hex[0..2], 16).ok();
76+
let g = u8::from_str_radix(&hex[2..4], 16).ok();
77+
let b = u8::from_str_radix(&hex[4..6], 16).ok();
78+
79+
if let (Some(r), Some(g), Some(b)) = (r, g, b) {
80+
let diff = (self.r as i32 - r as i32).abs()
81+
+ (self.g as i32 - g as i32).abs()
82+
+ (self.b as i32 - b as i32).abs();
83+
84+
return diff <= 20;
85+
}
86+
87+
false
88+
}
89+
90+
/// Validate oklch color format
91+
/// Supports: oklch(45.0% 0.306 65.4), 45.0% 0.306 65.4, 45.0%, 0.306, 65.4
92+
/// Converts RGB to OKLCH and checks similarity
93+
fn validate_oklch(&self, input: &str) -> bool {
94+
// Parse the input
95+
let parsed = self.parse_oklch(input);
96+
if parsed.is_none() {
97+
return false;
98+
}
99+
100+
let (l_user, c_user, h_user) = parsed.unwrap();
101+
102+
// Convert our RGB to OKLCH
103+
let (l_actual, c_actual, h_actual) = rgb_to_oklch(self.r, self.g, self.b);
104+
105+
// Check if close enough
106+
// L: within 5% (0-100 scale)
107+
// C: within 0.05
108+
// H: within 10 degrees (handle wrap-around at 360)
109+
let l_diff = (l_actual - l_user).abs();
110+
let c_diff = (c_actual - c_user).abs();
111+
let h_diff = {
112+
let diff = (h_actual - h_user).abs();
113+
diff.min(360.0 - diff) // Handle wrap-around
114+
};
115+
116+
l_diff <= 5.0 && c_diff <= 0.05 && h_diff <= 10.0
117+
}
118+
119+
/// Parse OKLCH from various formats
120+
fn parse_oklch(&self, input: &str) -> Option<(f64, f64, f64)> {
121+
let input = input.trim();
122+
123+
// Remove oklch( and ) if present
124+
let inner = input
125+
.strip_prefix("oklch(")
126+
.and_then(|s| s.strip_suffix(')'))
127+
.unwrap_or(input);
128+
129+
// Split by whitespace or comma
130+
let parts: Vec<&str> = inner
131+
.split(|c: char| c.is_whitespace() || c == ',')
132+
.filter(|s| !s.is_empty())
133+
.collect();
134+
135+
if parts.len() != 3 {
136+
return None;
137+
}
138+
139+
// Parse L (can have % suffix)
140+
let l = parts[0].trim_end_matches('%').parse::<f64>().ok()?;
141+
142+
// Parse C
143+
let c = parts[1].parse::<f64>().ok()?;
144+
145+
// Parse H
146+
let h = parts[2].parse::<f64>().ok()?;
147+
148+
Some((l, c, h))
149+
}
150+
}
151+
152+
/// Convert RGB to OKLCH color space
153+
/// This is a simplified approximation
154+
fn rgb_to_oklch(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
155+
// First convert to linear RGB
156+
let r_linear = srgb_to_linear(r as f64 / 255.0);
157+
let g_linear = srgb_to_linear(g as f64 / 255.0);
158+
let b_linear = srgb_to_linear(b as f64 / 255.0);
159+
160+
// Convert to OKLab using the matrix transformation
161+
let l = 0.4122214708 * r_linear + 0.5363325363 * g_linear + 0.0514459929 * b_linear;
162+
let m = 0.2119034982 * r_linear + 0.6806995451 * g_linear + 0.1073969566 * b_linear;
163+
let s = 0.0883024619 * r_linear + 0.2817188376 * g_linear + 0.6299787005 * b_linear;
164+
165+
let l_ = l.cbrt();
166+
let m_ = m.cbrt();
167+
let s_ = s.cbrt();
168+
169+
let l_oklab = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
170+
let a_oklab = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
171+
let b_oklab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
172+
173+
// Convert OKLab to OKLCH
174+
let l_oklch = l_oklab * 100.0; // Convert to percentage
175+
let c_oklch = (a_oklab * a_oklab + b_oklab * b_oklab).sqrt();
176+
let h_oklch = b_oklab.atan2(a_oklab).to_degrees();
177+
let h_oklch = if h_oklch < 0.0 {
178+
h_oklch + 360.0
179+
} else {
180+
h_oklch
181+
};
182+
183+
(l_oklch, c_oklch, h_oklch)
184+
}
185+
186+
/// Convert sRGB to linear RGB
187+
fn srgb_to_linear(c: f64) -> f64 {
188+
if c <= 0.04045 {
189+
c / 12.92
190+
} else {
191+
((c + 0.055) / 1.055).powf(2.4)
192+
}
193+
}
194+
195+
#[cfg(test)]
196+
mod tests {
197+
use super::*;
198+
199+
#[test]
200+
fn test_hex_validation() {
201+
let quiz = ColorQuiz {
202+
r: 200,
203+
g: 200,
204+
b: 200,
205+
};
206+
207+
// Exact match
208+
assert!(quiz.validate_answer("#c8c8c8"));
209+
210+
// Within tolerance (diff = 10)
211+
assert!(quiz.validate_answer("#bec8ca"));
212+
213+
// At boundary (diff = 20)
214+
assert!(quiz.validate_answer("#b4c8c8"));
215+
216+
// Outside tolerance (diff = 21)
217+
assert!(!quiz.validate_answer("#b3c8c8"));
218+
}
219+
220+
#[test]
221+
fn test_oklch_parsing() {
222+
let quiz = ColorQuiz {
223+
r: 200,
224+
g: 200,
225+
b: 200,
226+
};
227+
228+
// Test various formats
229+
assert!(quiz.parse_oklch("oklch(45.0% 0.306 65.4)").is_some());
230+
assert!(quiz.parse_oklch("45.0% 0.306 65.4").is_some());
231+
assert!(quiz.parse_oklch("45.0%, 0.306, 65.4").is_some());
232+
assert!(quiz.parse_oklch("45 0.306 65.4").is_some());
233+
}
234+
235+
#[test]
236+
fn test_rgb_to_oklch() {
237+
// Test with a known color (approximate values)
238+
let (l, c, h) = rgb_to_oklch(255, 0, 0); // Red
239+
assert!(l > 60.0 && l < 70.0); // Lightness around 62-63%
240+
assert!(c > 0.25); // High chroma
241+
assert!(h > 20.0 && h < 40.0); // Hue around 29 degrees
242+
}
243+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use std::{collections::HashMap, env, sync::Arc};
3737

3838
pub mod ai_message;
3939
pub mod brave;
40+
mod color_quiz;
4041
mod commands;
4142
mod config;
4243
mod database;

0 commit comments

Comments
 (0)