@@ -0,0 +1,280 @@ |
| 1 | +//! Ring indicator renderer using Cairo |
| 2 | +//! |
| 3 | +//! Renders the circular ring indicator with state-based colors and |
| 4 | +//! segment highlighting for keystroke feedback. |
| 5 | + |
| 6 | +use std::f64::consts::PI; |
| 7 | + |
| 8 | +use anyhow::{Context, Result}; |
| 9 | +use cairo::{Context as CairoContext, Format, ImageSurface, Operator}; |
| 10 | + |
| 11 | +use super::{Color, RingState}; |
| 12 | +use crate::config::RingConfig; |
| 13 | + |
| 14 | +/// Number of segments around the ring for keystroke feedback |
| 15 | +const NUM_SEGMENTS: usize = 12; |
| 16 | + |
| 17 | +/// Ring indicator renderer |
| 18 | +pub struct RingRenderer { |
| 19 | + /// Outer radius of the ring |
| 20 | + outer_radius: f64, |
| 21 | + /// Inner radius of the ring (dark center) |
| 22 | + inner_radius: f64, |
| 23 | + /// Line width for ring stroke |
| 24 | + line_width: f64, |
| 25 | + /// Colors for each state |
| 26 | + color_idle: Color, |
| 27 | + color_typing: Color, |
| 28 | + color_verifying: Color, |
| 29 | + color_wrong: Color, |
| 30 | + color_clear: Color, |
| 31 | + color_inside: Color, |
| 32 | + color_ring_bg: Color, |
| 33 | + /// Current state |
| 34 | + state: RingState, |
| 35 | + /// Current highlighted segment (0-11, or None) |
| 36 | + highlight_segment: Option<usize>, |
| 37 | +} |
| 38 | + |
| 39 | +impl RingRenderer { |
| 40 | + /// Create a new ring renderer from config |
| 41 | + pub fn from_config(config: &RingConfig) -> Self { |
| 42 | + Self { |
| 43 | + outer_radius: config.radius_outer, |
| 44 | + inner_radius: config.radius_inner, |
| 45 | + line_width: config.line_width, |
| 46 | + color_idle: Color::from_hex(&config.color_idle).unwrap_or_else(Color::idle), |
| 47 | + color_typing: Color::from_hex(&config.color_typing).unwrap_or_else(Color::typing), |
| 48 | + color_verifying: Color::from_hex(&config.color_verifying) |
| 49 | + .unwrap_or_else(Color::verifying), |
| 50 | + color_wrong: Color::from_hex(&config.color_wrong).unwrap_or_else(Color::wrong), |
| 51 | + color_clear: Color::from_hex(&config.color_clear).unwrap_or_else(Color::clear), |
| 52 | + color_inside: Color::from_hex(&config.color_inside).unwrap_or_else(Color::inside), |
| 53 | + color_ring_bg: Color::from_hex(&config.color_ring_bg).unwrap_or_else(Color::ring_bg), |
| 54 | + state: RingState::Idle, |
| 55 | + highlight_segment: None, |
| 56 | + } |
| 57 | + } |
| 58 | + |
| 59 | + /// Set the current ring state |
| 60 | + pub fn set_state(&mut self, state: RingState) { |
| 61 | + self.state = state; |
| 62 | + } |
| 63 | + |
| 64 | + /// Get the current ring state |
| 65 | + pub fn state(&self) -> RingState { |
| 66 | + self.state |
| 67 | + } |
| 68 | + |
| 69 | + /// Set the highlighted segment (for keystroke feedback) |
| 70 | + pub fn set_highlight_segment(&mut self, segment: Option<usize>) { |
| 71 | + self.highlight_segment = segment.map(|s| s % NUM_SEGMENTS); |
| 72 | + } |
| 73 | + |
| 74 | + /// Advance highlight to next segment |
| 75 | + pub fn advance_highlight(&mut self) { |
| 76 | + self.highlight_segment = Some( |
| 77 | + self.highlight_segment |
| 78 | + .map(|s| (s + 1) % NUM_SEGMENTS) |
| 79 | + .unwrap_or(0), |
| 80 | + ); |
| 81 | + } |
| 82 | + |
| 83 | + /// Retreat highlight to previous segment (for backspace) |
| 84 | + /// |
| 85 | + /// If at segment 0, clears the highlight entirely. |
| 86 | + pub fn retreat_highlight(&mut self) { |
| 87 | + self.highlight_segment = self.highlight_segment.and_then(|s| { |
| 88 | + if s == 0 { |
| 89 | + None // At start, clear highlight |
| 90 | + } else { |
| 91 | + Some(s - 1) |
| 92 | + } |
| 93 | + }); |
| 94 | + } |
| 95 | + |
| 96 | + /// Clear the highlight |
| 97 | + pub fn clear_highlight(&mut self) { |
| 98 | + self.highlight_segment = None; |
| 99 | + } |
| 100 | + |
| 101 | + /// Get the color for the current state |
| 102 | + fn state_color(&self) -> Color { |
| 103 | + match self.state { |
| 104 | + RingState::Idle => self.color_idle, |
| 105 | + RingState::Typing => self.color_typing, |
| 106 | + RingState::Verifying => self.color_verifying, |
| 107 | + RingState::Wrong => self.color_wrong, |
| 108 | + RingState::Clear => self.color_clear, |
| 109 | + } |
| 110 | + } |
| 111 | + |
| 112 | + /// Render the ring to a new Cairo surface |
| 113 | + /// |
| 114 | + /// Returns a surface sized to fit the ring with some padding. |
| 115 | + pub fn render(&self) -> Result<ImageSurface> { |
| 116 | + let padding = 10.0; |
| 117 | + let size = (self.outer_radius * 2.0 + padding * 2.0).ceil() as i32; |
| 118 | + |
| 119 | + let surface = ImageSurface::create(Format::ARgb32, size, size) |
| 120 | + .context("Failed to create ring surface")?; |
| 121 | + |
| 122 | + let ctx = CairoContext::new(&surface).context("Failed to create Cairo context")?; |
| 123 | + |
| 124 | + // Center of the surface |
| 125 | + let cx = size as f64 / 2.0; |
| 126 | + let cy = size as f64 / 2.0; |
| 127 | + |
| 128 | + self.draw(&ctx, cx, cy)?; |
| 129 | + |
| 130 | + surface.flush(); |
| 131 | + Ok(surface) |
| 132 | + } |
| 133 | + |
| 134 | + /// Draw the ring at the specified center coordinates |
| 135 | + pub fn draw(&self, ctx: &CairoContext, cx: f64, cy: f64) -> Result<()> { |
| 136 | + // Draw inner dark circle (background) |
| 137 | + ctx.arc(cx, cy, self.inner_radius, 0.0, 2.0 * PI); |
| 138 | + ctx.set_source_rgba( |
| 139 | + self.color_inside.r, |
| 140 | + self.color_inside.g, |
| 141 | + self.color_inside.b, |
| 142 | + self.color_inside.a, |
| 143 | + ); |
| 144 | + ctx.fill()?; |
| 145 | + |
| 146 | + // Draw ring background track |
| 147 | + let ring_center_radius = (self.outer_radius + self.inner_radius) / 2.0; |
| 148 | + ctx.set_line_width(self.line_width); |
| 149 | + ctx.arc(cx, cy, ring_center_radius, 0.0, 2.0 * PI); |
| 150 | + ctx.set_source_rgba( |
| 151 | + self.color_ring_bg.r, |
| 152 | + self.color_ring_bg.g, |
| 153 | + self.color_ring_bg.b, |
| 154 | + self.color_ring_bg.a, |
| 155 | + ); |
| 156 | + ctx.stroke()?; |
| 157 | + |
| 158 | + // Draw ring with state color |
| 159 | + let color = self.state_color(); |
| 160 | + ctx.set_line_width(self.line_width); |
| 161 | + ctx.arc(cx, cy, ring_center_radius, 0.0, 2.0 * PI); |
| 162 | + ctx.set_source_rgba(color.r, color.g, color.b, color.a); |
| 163 | + ctx.stroke()?; |
| 164 | + |
| 165 | + // Draw segment highlight if active |
| 166 | + if let Some(segment) = self.highlight_segment { |
| 167 | + self.draw_segment_highlight(ctx, cx, cy, segment)?; |
| 168 | + } |
| 169 | + |
| 170 | + Ok(()) |
| 171 | + } |
| 172 | + |
| 173 | + /// Draw a highlighted segment |
| 174 | + fn draw_segment_highlight( |
| 175 | + &self, |
| 176 | + ctx: &CairoContext, |
| 177 | + cx: f64, |
| 178 | + cy: f64, |
| 179 | + segment: usize, |
| 180 | + ) -> Result<()> { |
| 181 | + let segment_angle = 2.0 * PI / NUM_SEGMENTS as f64; |
| 182 | + // Start from top (-PI/2) and go clockwise |
| 183 | + let start_angle = -PI / 2.0 + (segment as f64 * segment_angle); |
| 184 | + let end_angle = start_angle + segment_angle; |
| 185 | + |
| 186 | + let ring_center_radius = (self.outer_radius + self.inner_radius) / 2.0; |
| 187 | + |
| 188 | + ctx.set_line_width(self.line_width + 2.0); |
| 189 | + ctx.arc(cx, cy, ring_center_radius, start_angle, end_angle); |
| 190 | + // Brighter version of current state color |
| 191 | + let color = self.state_color(); |
| 192 | + ctx.set_source_rgba( |
| 193 | + (color.r + 0.3).min(1.0), |
| 194 | + (color.g + 0.3).min(1.0), |
| 195 | + (color.b + 0.3).min(1.0), |
| 196 | + color.a, |
| 197 | + ); |
| 198 | + ctx.stroke()?; |
| 199 | + |
| 200 | + Ok(()) |
| 201 | + } |
| 202 | + |
| 203 | + /// Get the size needed for the ring (width and height) |
| 204 | + pub fn size(&self) -> (i32, i32) { |
| 205 | + let padding = 10.0; |
| 206 | + let size = (self.outer_radius * 2.0 + padding * 2.0).ceil() as i32; |
| 207 | + (size, size) |
| 208 | + } |
| 209 | + |
| 210 | + /// Get the raw pixel data from a surface (BGRA format for X11) |
| 211 | + pub fn surface_to_bgra(surface: &mut ImageSurface) -> Result<Vec<u8>> { |
| 212 | + surface.flush(); |
| 213 | + let data = surface.data().context("Failed to get surface data")?; |
| 214 | + Ok(data.to_vec()) |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +/// Composite the ring onto a background buffer at the specified position |
| 219 | +/// |
| 220 | +/// Both buffers are in BGRA format. The ring is alpha-blended onto the background. |
| 221 | +pub fn composite_ring( |
| 222 | + background: &mut [u8], |
| 223 | + bg_width: u32, |
| 224 | + bg_height: u32, |
| 225 | + ring_data: &[u8], |
| 226 | + ring_width: u32, |
| 227 | + ring_height: u32, |
| 228 | + dest_x: i32, |
| 229 | + dest_y: i32, |
| 230 | +) { |
| 231 | + let bg_stride = bg_width as usize * 4; |
| 232 | + let ring_stride = ring_width as usize * 4; |
| 233 | + |
| 234 | + for ry in 0..ring_height as i32 { |
| 235 | + let by = dest_y + ry; |
| 236 | + if by < 0 || by >= bg_height as i32 { |
| 237 | + continue; |
| 238 | + } |
| 239 | + |
| 240 | + for rx in 0..ring_width as i32 { |
| 241 | + let bx = dest_x + rx; |
| 242 | + if bx < 0 || bx >= bg_width as i32 { |
| 243 | + continue; |
| 244 | + } |
| 245 | + |
| 246 | + let ring_offset = (ry as usize * ring_stride) + (rx as usize * 4); |
| 247 | + let bg_offset = (by as usize * bg_stride) + (bx as usize * 4); |
| 248 | + |
| 249 | + // Ring pixel (BGRA) |
| 250 | + let rb = ring_data[ring_offset] as f64 / 255.0; |
| 251 | + let rg = ring_data[ring_offset + 1] as f64 / 255.0; |
| 252 | + let rr = ring_data[ring_offset + 2] as f64 / 255.0; |
| 253 | + let ra = ring_data[ring_offset + 3] as f64 / 255.0; |
| 254 | + |
| 255 | + if ra < 0.001 { |
| 256 | + // Fully transparent, skip |
| 257 | + continue; |
| 258 | + } |
| 259 | + |
| 260 | + // Background pixel (BGRA) |
| 261 | + let bb = background[bg_offset] as f64 / 255.0; |
| 262 | + let bg = background[bg_offset + 1] as f64 / 255.0; |
| 263 | + let br = background[bg_offset + 2] as f64 / 255.0; |
| 264 | + let ba = background[bg_offset + 3] as f64 / 255.0; |
| 265 | + |
| 266 | + // Alpha compositing (Porter-Duff "over" operator) |
| 267 | + let out_a = ra + ba * (1.0 - ra); |
| 268 | + if out_a > 0.001 { |
| 269 | + let out_r = (rr * ra + br * ba * (1.0 - ra)) / out_a; |
| 270 | + let out_g = (rg * ra + bg * ba * (1.0 - ra)) / out_a; |
| 271 | + let out_b = (rb * ra + bb * ba * (1.0 - ra)) / out_a; |
| 272 | + |
| 273 | + background[bg_offset] = (out_b * 255.0) as u8; |
| 274 | + background[bg_offset + 1] = (out_g * 255.0) as u8; |
| 275 | + background[bg_offset + 2] = (out_r * 255.0) as u8; |
| 276 | + background[bg_offset + 3] = (out_a * 255.0) as u8; |
| 277 | + } |
| 278 | + } |
| 279 | + } |
| 280 | +} |