gardesk/garshot / f669e50

Browse files

annotate: add color picker dialog with HSV/RGB/palette tabs

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
f669e50264497cd259a90e7e3629de76baf87fa5
Parents
caf0560
Tree
cb59c89

8 changed files

StatusFile+-
A garshot/src/annotate/ui/color_picker/eyedropper.rs 171 0
A garshot/src/annotate/ui/color_picker/hsv_tab.rs 192 0
A garshot/src/annotate/ui/color_picker/mod.rs 356 0
A garshot/src/annotate/ui/color_picker/palette_tab.rs 220 0
A garshot/src/annotate/ui/color_picker/rgb_tab.rs 277 0
A garshot/src/annotate/ui/color_picker/tabs.rs 94 0
M garshot/src/annotate/ui/mod.rs 3 1
M garshot/src/annotate/ui/toolbar.rs 33 11
garshot/src/annotate/ui/color_picker/eyedropper.rsadded
@@ -0,0 +1,171 @@
1
+//! Eyedropper for sampling colors from the screen.
2
+
3
+use anyhow::{Context, Result};
4
+use gartk_core::{Color, Point};
5
+use gartk_x11::{Connection, CursorManager, CursorShape, Window, WindowConfig};
6
+use x11rb::connection::Connection as X11Connection;
7
+use x11rb::protocol::xproto::{self, ConnectionExt, ImageFormat};
8
+use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
9
+
10
+/// Eyedropper result.
11
+#[derive(Debug)]
12
+pub enum EyedropperResult {
13
+    /// User selected a color.
14
+    Color(Color),
15
+    /// User cancelled.
16
+    Cancel,
17
+}
18
+
19
+/// Eyedropper state for color sampling.
20
+pub struct Eyedropper {
21
+    /// X11 connection.
22
+    conn: Connection,
23
+    /// Overlay window (transparent, for capturing input).
24
+    window: Window,
25
+    /// Cursor manager.
26
+    cursor_manager: CursorManager,
27
+    /// Root window ID.
28
+    root: u32,
29
+    /// Current mouse position.
30
+    mouse_pos: Point,
31
+    /// Current sampled color.
32
+    current_color: Color,
33
+}
34
+
35
+impl Eyedropper {
36
+    /// Create a new eyedropper.
37
+    pub fn new() -> Result<Self> {
38
+        let conn = Connection::connect(None).context("Failed to connect to X11")?;
39
+
40
+        let screen_width = conn.screen_width() as u32;
41
+        let screen_height = conn.screen_height() as u32;
42
+        let root = conn.inner().setup().roots[conn.screen_num()].root;
43
+
44
+        // Create fullscreen transparent overlay to grab input
45
+        let config = WindowConfig::new()
46
+            .override_redirect(true)
47
+            .size(screen_width, screen_height)
48
+            .position(0, 0)
49
+            .map_on_create(false);
50
+
51
+        let window = Window::create(conn.clone(), config).context("Failed to create window")?;
52
+
53
+        // Make window transparent (input only)
54
+        // We'll set _NET_WM_WINDOW_OPACITY to 0
55
+        let opacity_atom = conn.inner()
56
+            .intern_atom(false, b"_NET_WM_WINDOW_OPACITY")?
57
+            .reply()
58
+            .context("Failed to intern opacity atom")?
59
+            .atom;
60
+
61
+        conn.inner().change_property32(
62
+            xproto::PropMode::REPLACE,
63
+            window.id(),
64
+            opacity_atom,
65
+            xproto::AtomEnum::CARDINAL,
66
+            &[0], // Fully transparent
67
+        )?;
68
+
69
+        let cursor_manager = CursorManager::new(conn.clone())
70
+            .context("Failed to create cursor manager")?;
71
+
72
+        Ok(Self {
73
+            conn,
74
+            window,
75
+            cursor_manager,
76
+            root,
77
+            mouse_pos: Point::new(0, 0),
78
+            current_color: Color::BLACK,
79
+        })
80
+    }
81
+
82
+    /// Run the eyedropper and return the selected color.
83
+    pub fn run(mut self) -> Result<EyedropperResult> {
84
+        // Map window and grab pointer
85
+        self.window.map()?;
86
+        self.conn.inner().flush()?;
87
+
88
+        // Set crosshair cursor
89
+        self.cursor_manager.set_window_cursor(self.window.id(), CursorShape::Crosshair)?;
90
+
91
+        // Grab pointer
92
+        self.window.grab_pointer()?;
93
+        self.window.grab_keyboard_with_retry(10, 50)?;
94
+
95
+        // Event loop
96
+        loop {
97
+            let event = self.conn.inner().wait_for_event()?;
98
+
99
+            match event {
100
+                x11rb::protocol::Event::ButtonPress(e) => {
101
+                    if e.detail == 1 {
102
+                        // Left click - sample color and confirm
103
+                        let color = self.sample_color(e.root_x as i32, e.root_y as i32)?;
104
+                        self.cleanup()?;
105
+                        return Ok(EyedropperResult::Color(color));
106
+                    } else if e.detail == 3 {
107
+                        // Right click - cancel
108
+                        self.cleanup()?;
109
+                        return Ok(EyedropperResult::Cancel);
110
+                    }
111
+                }
112
+                x11rb::protocol::Event::MotionNotify(e) => {
113
+                    self.mouse_pos = Point::new(e.root_x as i32, e.root_y as i32);
114
+                    // Sample color at current position (for preview if we add one)
115
+                    self.current_color = self.sample_color(e.root_x as i32, e.root_y as i32)?;
116
+                }
117
+                x11rb::protocol::Event::KeyPress(e) => {
118
+                    // Escape to cancel
119
+                    if e.detail == 9 {
120
+                        self.cleanup()?;
121
+                        return Ok(EyedropperResult::Cancel);
122
+                    }
123
+                    // Enter to confirm current color
124
+                    if e.detail == 36 || e.detail == 104 {
125
+                        self.cleanup()?;
126
+                        return Ok(EyedropperResult::Color(self.current_color));
127
+                    }
128
+                }
129
+                _ => {}
130
+            }
131
+        }
132
+    }
133
+
134
+    /// Sample the color at the given screen coordinates.
135
+    fn sample_color(&self, x: i32, y: i32) -> Result<Color> {
136
+        // Get 1x1 pixel from root window
137
+        let reply = self.conn.inner()
138
+            .get_image(
139
+                ImageFormat::Z_PIXMAP,
140
+                self.root,
141
+                x as i16,
142
+                y as i16,
143
+                1,
144
+                1,
145
+                !0, // All planes
146
+            )?
147
+            .reply()
148
+            .context("Failed to get image")?;
149
+
150
+        // Parse the pixel data (format depends on depth, assume 24/32 bit)
151
+        let data = reply.data;
152
+        if data.len() >= 3 {
153
+            // Assume BGRA or BGR format (X11 native)
154
+            let b = data[0];
155
+            let g = data[1];
156
+            let r = data[2];
157
+            let a = if data.len() >= 4 { data[3] } else { 255 };
158
+
159
+            Ok(Color::from_u8(r, g, b, a))
160
+        } else {
161
+            Ok(Color::BLACK)
162
+        }
163
+    }
164
+
165
+    /// Cleanup resources.
166
+    fn cleanup(&mut self) -> Result<()> {
167
+        self.window.ungrab_keyboard()?;
168
+        self.window.ungrab_pointer()?;
169
+        Ok(())
170
+    }
171
+}
garshot/src/annotate/ui/color_picker/hsv_tab.rsadded
@@ -0,0 +1,192 @@
1
+//! HSV color wheel tab.
2
+
3
+use std::f64::consts::PI;
4
+
5
+use gartk_core::{Color, Point, Rect};
6
+use gartk_render::set_color;
7
+
8
+use super::DragTarget;
9
+
10
+/// Outer radius of the hue ring.
11
+const HUE_RING_OUTER: f64 = 100.0;
12
+
13
+/// Inner radius of the hue ring (SV square fits inside).
14
+const HUE_RING_INNER: f64 = 70.0;
15
+
16
+/// SV square size (fits inside the hue ring).
17
+const SV_SQUARE_SIZE: f64 = 90.0;
18
+
19
+/// Handle mouse press in HSV tab.
20
+pub fn handle_press(local_pos: Point, hsv: &mut (f64, f64, f64)) -> Option<DragTarget> {
21
+    let center = content_center();
22
+    let dx = local_pos.x as f64 - center.0;
23
+    let dy = local_pos.y as f64 - center.1;
24
+    let dist = (dx * dx + dy * dy).sqrt();
25
+
26
+    // Check if in hue ring
27
+    if dist >= HUE_RING_INNER && dist <= HUE_RING_OUTER {
28
+        // Update hue based on angle
29
+        let angle = dy.atan2(dx);
30
+        let hue = ((angle * 180.0 / PI) + 90.0 + 360.0) % 360.0;
31
+        hsv.0 = hue;
32
+        return Some(DragTarget::HueRing);
33
+    }
34
+
35
+    // Check if in SV square
36
+    let sv_rect = sv_square_rect();
37
+    if sv_rect.contains_point(local_pos) {
38
+        let s = ((local_pos.x - sv_rect.x) as f64 / sv_rect.width as f64).clamp(0.0, 1.0);
39
+        let v = 1.0 - ((local_pos.y - sv_rect.y) as f64 / sv_rect.height as f64).clamp(0.0, 1.0);
40
+        hsv.1 = s;
41
+        hsv.2 = v;
42
+        return Some(DragTarget::SvSquare);
43
+    }
44
+
45
+    None
46
+}
47
+
48
+/// Handle mouse drag in HSV tab.
49
+pub fn handle_drag(local_pos: Point, target: DragTarget, hsv: &mut (f64, f64, f64)) {
50
+    match target {
51
+        DragTarget::HueRing => {
52
+            let center = content_center();
53
+            let dx = local_pos.x as f64 - center.0;
54
+            let dy = local_pos.y as f64 - center.1;
55
+            let angle = dy.atan2(dx);
56
+            let hue = ((angle * 180.0 / PI) + 90.0 + 360.0) % 360.0;
57
+            hsv.0 = hue;
58
+        }
59
+        DragTarget::SvSquare => {
60
+            let sv_rect = sv_square_rect();
61
+            let s = ((local_pos.x - sv_rect.x) as f64 / sv_rect.width as f64).clamp(0.0, 1.0);
62
+            let v = 1.0 - ((local_pos.y - sv_rect.y) as f64 / sv_rect.height as f64).clamp(0.0, 1.0);
63
+            hsv.1 = s;
64
+            hsv.2 = v;
65
+        }
66
+        _ => {}
67
+    }
68
+}
69
+
70
+/// Get the center point for the color wheel.
71
+fn content_center() -> (f64, f64) {
72
+    // Content area starts at y=48 (TAB_HEIGHT + PADDING) and we center in available space
73
+    let cx = super::PICKER_WIDTH as f64 / 2.0;
74
+    let cy = 48.0 + 110.0; // Approximate center in content area
75
+    (cx, cy)
76
+}
77
+
78
+/// Get the SV square rect.
79
+fn sv_square_rect() -> Rect {
80
+    let (cx, cy) = content_center();
81
+    let half = SV_SQUARE_SIZE / 2.0;
82
+    Rect::new(
83
+        (cx - half) as i32,
84
+        (cy - half) as i32,
85
+        SV_SQUARE_SIZE as u32,
86
+        SV_SQUARE_SIZE as u32,
87
+    )
88
+}
89
+
90
+/// Draw the HSV tab content.
91
+pub fn draw(ctx: &cairo::Context, _content_rect: Rect, hsv: (f64, f64, f64)) -> anyhow::Result<()> {
92
+    let (cx, cy) = content_center();
93
+
94
+    // Draw hue ring
95
+    draw_hue_ring(ctx, cx, cy)?;
96
+
97
+    // Draw SV square
98
+    draw_sv_square(ctx, hsv.0)?;
99
+
100
+    // Draw hue indicator (small circle on ring)
101
+    let hue_angle = (hsv.0 - 90.0) * PI / 180.0;
102
+    let ring_mid = (HUE_RING_OUTER + HUE_RING_INNER) / 2.0;
103
+    let hue_x = cx + ring_mid * hue_angle.cos();
104
+    let hue_y = cy + ring_mid * hue_angle.sin();
105
+
106
+    ctx.set_line_width(2.0);
107
+    set_color(ctx, Color::WHITE);
108
+    ctx.arc(hue_x, hue_y, 6.0, 0.0, 2.0 * PI);
109
+    ctx.stroke()?;
110
+
111
+    set_color(ctx, Color::BLACK);
112
+    ctx.arc(hue_x, hue_y, 4.0, 0.0, 2.0 * PI);
113
+    ctx.stroke()?;
114
+
115
+    // Draw SV indicator (small circle in square)
116
+    let sv_rect = sv_square_rect();
117
+    let sv_x = sv_rect.x as f64 + hsv.1 * sv_rect.width as f64;
118
+    let sv_y = sv_rect.y as f64 + (1.0 - hsv.2) * sv_rect.height as f64;
119
+
120
+    ctx.set_line_width(2.0);
121
+    set_color(ctx, Color::WHITE);
122
+    ctx.arc(sv_x, sv_y, 6.0, 0.0, 2.0 * PI);
123
+    ctx.stroke()?;
124
+
125
+    set_color(ctx, Color::BLACK);
126
+    ctx.arc(sv_x, sv_y, 4.0, 0.0, 2.0 * PI);
127
+    ctx.stroke()?;
128
+
129
+    Ok(())
130
+}
131
+
132
+/// Draw the hue ring.
133
+fn draw_hue_ring(ctx: &cairo::Context, cx: f64, cy: f64) -> anyhow::Result<()> {
134
+    // Draw the hue ring using filled wedges (pie slices)
135
+    let segments = 360;
136
+    let segment_angle = 2.0 * PI / segments as f64;
137
+
138
+    for i in 0..segments {
139
+        let angle_start = (i as f64 * segment_angle) - PI / 2.0;
140
+        let angle_end = angle_start + segment_angle + 0.005; // Tiny overlap to prevent gaps
141
+
142
+        let hue = (i as f64 / segments as f64) * 360.0;
143
+        let color = Color::from_hsv(hue, 1.0, 1.0);
144
+        set_color(ctx, color);
145
+
146
+        // Draw a filled wedge between inner and outer radius
147
+        ctx.new_path();
148
+        ctx.arc(cx, cy, HUE_RING_OUTER, angle_start, angle_end);
149
+        ctx.arc_negative(cx, cy, HUE_RING_INNER, angle_end, angle_start);
150
+        ctx.close_path();
151
+        ctx.fill()?;
152
+    }
153
+
154
+    Ok(())
155
+}
156
+
157
+/// Draw the saturation/value square.
158
+fn draw_sv_square(ctx: &cairo::Context, hue: f64) -> anyhow::Result<()> {
159
+    let sv_rect = sv_square_rect();
160
+    let size = sv_rect.width as i32;
161
+
162
+    // Draw pixel by pixel for accurate gradient
163
+    // This is slow but accurate - could be optimized with gradients
164
+    for y in 0..size {
165
+        for x in 0..size {
166
+            let s = x as f64 / size as f64;
167
+            let v = 1.0 - (y as f64 / size as f64);
168
+            let color = Color::from_hsv(hue, s, v);
169
+            set_color(ctx, color);
170
+            ctx.rectangle(
171
+                (sv_rect.x + x) as f64,
172
+                (sv_rect.y + y) as f64,
173
+                1.0,
174
+                1.0,
175
+            );
176
+            ctx.fill()?;
177
+        }
178
+    }
179
+
180
+    // Draw border
181
+    set_color(ctx, Color::new(0.3, 0.3, 0.3, 1.0));
182
+    ctx.set_line_width(1.0);
183
+    ctx.rectangle(
184
+        sv_rect.x as f64,
185
+        sv_rect.y as f64,
186
+        sv_rect.width as f64,
187
+        sv_rect.height as f64,
188
+    );
189
+    ctx.stroke()?;
190
+
191
+    Ok(())
192
+}
garshot/src/annotate/ui/color_picker/mod.rsadded
@@ -0,0 +1,356 @@
1
+//! Color picker dialog for annotation tool selection.
2
+
3
+mod hsv_tab;
4
+mod palette_tab;
5
+mod rgb_tab;
6
+mod tabs;
7
+
8
+pub mod eyedropper;
9
+
10
+use gartk_core::{Color, InputEvent, Point, Rect};
11
+use gartk_render::Surface;
12
+
13
+pub use tabs::ColorPickerTab;
14
+
15
+/// Color picker dialog dimensions.
16
+pub const PICKER_WIDTH: u32 = 320;
17
+pub const PICKER_HEIGHT: u32 = 380;
18
+
19
+/// Tab bar height.
20
+const TAB_HEIGHT: u32 = 36;
21
+
22
+/// Button height.
23
+const BUTTON_HEIGHT: u32 = 32;
24
+
25
+/// Padding.
26
+const PADDING: u32 = 12;
27
+
28
+/// Result of color picker interaction.
29
+#[derive(Debug, Clone)]
30
+pub enum ColorPickerResult {
31
+    /// User confirmed color selection.
32
+    Confirm(Color),
33
+    /// User cancelled.
34
+    Cancel,
35
+    /// Start eyedropper mode.
36
+    StartEyedropper,
37
+    /// Color changed, needs redraw.
38
+    Changed,
39
+    /// No change, no redraw needed.
40
+    None,
41
+}
42
+
43
+/// What the user is currently dragging.
44
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45
+enum DragTarget {
46
+    HueRing,
47
+    SvSquare,
48
+    RedSlider,
49
+    GreenSlider,
50
+    BlueSlider,
51
+    AlphaSlider,
52
+}
53
+
54
+/// Color picker dialog.
55
+pub struct ColorPicker {
56
+    /// Dialog bounds (position and size).
57
+    rect: Rect,
58
+    /// Current tab.
59
+    tab: ColorPickerTab,
60
+    /// HSV values (source of truth): hue 0-360, saturation 0-1, value 0-1.
61
+    hsv: (f64, f64, f64),
62
+    /// Alpha value 0-1.
63
+    alpha: f64,
64
+    /// Currently dragging target.
65
+    dragging: Option<DragTarget>,
66
+    /// Recent colors (max 8).
67
+    recent: Vec<Color>,
68
+    /// Original color (for cancel).
69
+    original: Color,
70
+    /// OK button rect.
71
+    ok_rect: Rect,
72
+    /// Cancel button rect.
73
+    cancel_rect: Rect,
74
+    /// Eyedropper button rect.
75
+    eyedropper_rect: Rect,
76
+}
77
+
78
+impl ColorPicker {
79
+    /// Create a new color picker dialog.
80
+    ///
81
+    /// # Arguments
82
+    /// * `initial_color` - The initial color to display
83
+    /// * `screen_width` - Screen width for centering
84
+    /// * `screen_height` - Screen height for centering
85
+    pub fn new(initial_color: Color, screen_width: u32, screen_height: u32) -> Self {
86
+        // Center the dialog on screen
87
+        let x = (screen_width as i32 - PICKER_WIDTH as i32) / 2;
88
+        let y = (screen_height as i32 - PICKER_HEIGHT as i32) / 2;
89
+
90
+        let rect = Rect::new(x, y, PICKER_WIDTH, PICKER_HEIGHT);
91
+
92
+        // Convert initial color to HSV
93
+        let hsv = initial_color.to_hsv();
94
+
95
+        // Calculate button positions (at bottom of dialog)
96
+        let button_y = PICKER_HEIGHT as i32 - BUTTON_HEIGHT as i32 - PADDING as i32;
97
+        let button_width = 80u32;
98
+        let cancel_x = PADDING as i32;
99
+        let ok_x = PICKER_WIDTH as i32 - button_width as i32 - PADDING as i32;
100
+
101
+        Self {
102
+            rect,
103
+            tab: ColorPickerTab::default(),
104
+            hsv,
105
+            alpha: initial_color.a,
106
+            dragging: None,
107
+            recent: Vec::new(),
108
+            original: initial_color,
109
+            ok_rect: Rect::new(ok_x, button_y, button_width, BUTTON_HEIGHT),
110
+            cancel_rect: Rect::new(cancel_x, button_y, button_width, BUTTON_HEIGHT),
111
+            eyedropper_rect: Rect::new(
112
+                (PICKER_WIDTH as i32 - 32) / 2,
113
+                button_y - 40,
114
+                32,
115
+                32,
116
+            ),
117
+        }
118
+    }
119
+
120
+    /// Get the current color.
121
+    pub fn current_color(&self) -> Color {
122
+        Color::from_hsva(self.hsv.0, self.hsv.1, self.hsv.2, self.alpha)
123
+    }
124
+
125
+    /// Set color from RGB.
126
+    pub fn set_color(&mut self, color: Color) {
127
+        self.hsv = color.to_hsv();
128
+        self.alpha = color.a;
129
+    }
130
+
131
+    /// Add a color to the recent colors list.
132
+    pub fn add_recent(&mut self, color: Color) {
133
+        // Remove if already present
134
+        self.recent.retain(|c| c != &color);
135
+        // Add to front
136
+        self.recent.insert(0, color);
137
+        // Keep only 8 recent colors
138
+        self.recent.truncate(8);
139
+    }
140
+
141
+    /// Get the dialog bounds.
142
+    pub fn rect(&self) -> Rect {
143
+        self.rect
144
+    }
145
+
146
+    /// Handle input event.
147
+    ///
148
+    /// Returns a result if the interaction is complete.
149
+    pub fn handle_event(&mut self, event: &InputEvent) -> ColorPickerResult {
150
+        match event {
151
+            InputEvent::MousePress(e) => {
152
+                let local = self.to_local(e.position);
153
+
154
+                // Check tab bar
155
+                if let Some(new_tab) = tabs::handle_click(local) {
156
+                    if new_tab != self.tab {
157
+                        self.tab = new_tab;
158
+                        return ColorPickerResult::Changed;
159
+                    }
160
+                    return ColorPickerResult::None;
161
+                }
162
+
163
+                // Check OK button
164
+                if self.ok_rect.contains_point(local) {
165
+                    let color = self.current_color();
166
+                    self.add_recent(color);
167
+                    return ColorPickerResult::Confirm(color);
168
+                }
169
+
170
+                // Check Cancel button
171
+                if self.cancel_rect.contains_point(local) {
172
+                    return ColorPickerResult::Cancel;
173
+                }
174
+
175
+                // Check eyedropper button
176
+                if self.eyedropper_rect.contains_point(local) {
177
+                    return ColorPickerResult::StartEyedropper;
178
+                }
179
+
180
+                // Handle tab-specific interactions
181
+                match self.tab {
182
+                    ColorPickerTab::Hsv => {
183
+                        if let Some(target) = hsv_tab::handle_press(local, &mut self.hsv) {
184
+                            self.dragging = Some(target);
185
+                            return ColorPickerResult::Changed;
186
+                        }
187
+                    }
188
+                    ColorPickerTab::Rgb => {
189
+                        if let Some(target) = rgb_tab::handle_press(local, &mut self.hsv, &mut self.alpha) {
190
+                            self.dragging = Some(target);
191
+                            return ColorPickerResult::Changed;
192
+                        }
193
+                    }
194
+                    ColorPickerTab::Palette => {
195
+                        if let Some(color) = palette_tab::handle_click(local, &self.recent) {
196
+                            self.set_color(color);
197
+                            return ColorPickerResult::Changed;
198
+                        }
199
+                    }
200
+                }
201
+            }
202
+
203
+            InputEvent::MouseRelease(_) => {
204
+                if self.dragging.is_some() {
205
+                    self.dragging = None;
206
+                    // No redraw needed on release
207
+                }
208
+            }
209
+
210
+            InputEvent::MouseMove(e) => {
211
+                if let Some(target) = self.dragging {
212
+                    let local = self.to_local(e.position);
213
+                    match target {
214
+                        DragTarget::HueRing | DragTarget::SvSquare => {
215
+                            hsv_tab::handle_drag(local, target, &mut self.hsv);
216
+                            return ColorPickerResult::Changed;
217
+                        }
218
+                        DragTarget::RedSlider
219
+                        | DragTarget::GreenSlider
220
+                        | DragTarget::BlueSlider
221
+                        | DragTarget::AlphaSlider => {
222
+                            rgb_tab::handle_drag(local, target, &mut self.hsv, &mut self.alpha);
223
+                            return ColorPickerResult::Changed;
224
+                        }
225
+                    }
226
+                }
227
+            }
228
+
229
+            InputEvent::Key(e) if e.pressed => {
230
+                use gartk_core::Key;
231
+                match e.key {
232
+                    Key::Return => {
233
+                        let color = self.current_color();
234
+                        self.add_recent(color);
235
+                        return ColorPickerResult::Confirm(color);
236
+                    }
237
+                    Key::Escape => {
238
+                        return ColorPickerResult::Cancel;
239
+                    }
240
+                    _ => {}
241
+                }
242
+            }
243
+
244
+            _ => {}
245
+        }
246
+
247
+        ColorPickerResult::None
248
+    }
249
+
250
+    /// Draw the color picker to a surface.
251
+    pub fn draw(&self, surface: &Surface) -> anyhow::Result<()> {
252
+        use gartk_render::{fill_rounded_rect, set_color, stroke_rounded_rect};
253
+
254
+        let ctx = surface.context()?;
255
+
256
+        // Draw background
257
+        let bg = Color::new(0.15, 0.15, 0.15, 1.0);
258
+        fill_rounded_rect(&ctx, Rect::new(0, 0, PICKER_WIDTH, PICKER_HEIGHT), 8.0, bg);
259
+
260
+        // Draw tabs
261
+        tabs::draw(&ctx, self.tab)?;
262
+
263
+        // Draw tab content
264
+        let content_rect = Rect::new(
265
+            PADDING as i32,
266
+            (TAB_HEIGHT + PADDING) as i32,
267
+            PICKER_WIDTH - PADDING * 2,
268
+            PICKER_HEIGHT - TAB_HEIGHT - BUTTON_HEIGHT - PADDING * 4 - 40,
269
+        );
270
+
271
+        match self.tab {
272
+            ColorPickerTab::Hsv => hsv_tab::draw(&ctx, content_rect, self.hsv)?,
273
+            ColorPickerTab::Rgb => rgb_tab::draw(&ctx, content_rect, self.hsv, self.alpha)?,
274
+            ColorPickerTab::Palette => palette_tab::draw(&ctx, content_rect, &self.recent)?,
275
+        }
276
+
277
+        // Draw color preview
278
+        let preview_rect = Rect::new(
279
+            PADDING as i32,
280
+            self.eyedropper_rect.y,
281
+            80,
282
+            32,
283
+        );
284
+        fill_rounded_rect(&ctx, preview_rect, 4.0, self.current_color());
285
+        stroke_rounded_rect(&ctx, preview_rect, 4.0, Color::WHITE, 1.0);
286
+
287
+        // Draw hex value
288
+        let hex = self.current_color().to_hex();
289
+        set_color(&ctx, Color::WHITE);
290
+        ctx.select_font_face("monospace", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
291
+        ctx.set_font_size(12.0);
292
+        ctx.move_to(
293
+            (preview_rect.x + preview_rect.width as i32 + 8) as f64,
294
+            (preview_rect.y + 20) as f64,
295
+        );
296
+        ctx.show_text(&hex)?;
297
+
298
+        // Draw eyedropper button
299
+        fill_rounded_rect(&ctx, self.eyedropper_rect, 4.0, Color::new(0.25, 0.25, 0.25, 1.0));
300
+        stroke_rounded_rect(&ctx, self.eyedropper_rect, 4.0, Color::new(0.4, 0.4, 0.4, 1.0), 1.0);
301
+        // Draw eyedropper icon (simple crosshair)
302
+        set_color(&ctx, Color::WHITE);
303
+        let cx = self.eyedropper_rect.x as f64 + self.eyedropper_rect.width as f64 / 2.0;
304
+        let cy = self.eyedropper_rect.y as f64 + self.eyedropper_rect.height as f64 / 2.0;
305
+        ctx.set_line_width(2.0);
306
+        ctx.move_to(cx - 6.0, cy);
307
+        ctx.line_to(cx + 6.0, cy);
308
+        ctx.move_to(cx, cy - 6.0);
309
+        ctx.line_to(cx, cy + 6.0);
310
+        ctx.stroke()?;
311
+
312
+        // Draw buttons
313
+        self.draw_button(&ctx, self.cancel_rect, "Cancel", false)?;
314
+        self.draw_button(&ctx, self.ok_rect, "OK", true)?;
315
+
316
+        Ok(())
317
+    }
318
+
319
+    /// Draw a button.
320
+    fn draw_button(
321
+        &self,
322
+        ctx: &cairo::Context,
323
+        rect: Rect,
324
+        label: &str,
325
+        primary: bool,
326
+    ) -> anyhow::Result<()> {
327
+        use gartk_render::{fill_rounded_rect, set_color, stroke_rounded_rect};
328
+
329
+        let bg = if primary {
330
+            Color::new(0.2, 0.5, 0.9, 1.0)
331
+        } else {
332
+            Color::new(0.3, 0.3, 0.3, 1.0)
333
+        };
334
+
335
+        fill_rounded_rect(ctx, rect, 4.0, bg);
336
+        stroke_rounded_rect(ctx, rect, 4.0, bg.lighten(0.2), 1.0);
337
+
338
+        set_color(ctx, Color::WHITE);
339
+        ctx.select_font_face("sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
340
+        ctx.set_font_size(13.0);
341
+
342
+        let extents = ctx.text_extents(label)?;
343
+        let text_x = rect.x as f64 + (rect.width as f64 - extents.width()) / 2.0;
344
+        let text_y = rect.y as f64 + (rect.height as f64 + extents.height()) / 2.0;
345
+
346
+        ctx.move_to(text_x, text_y);
347
+        ctx.show_text(label)?;
348
+
349
+        Ok(())
350
+    }
351
+
352
+    /// Convert screen position to local position within dialog.
353
+    fn to_local(&self, pos: Point) -> Point {
354
+        Point::new(pos.x - self.rect.x, pos.y - self.rect.y)
355
+    }
356
+}
garshot/src/annotate/ui/color_picker/palette_tab.rsadded
@@ -0,0 +1,220 @@
1
+//! Color palette tab.
2
+
3
+use gartk_core::{Color, Point, Rect};
4
+use gartk_render::{fill_rounded_rect, set_color, stroke_rounded_rect};
5
+
6
+use super::{PADDING, PICKER_WIDTH};
7
+
8
+/// Color swatch size.
9
+const SWATCH_SIZE: u32 = 28;
10
+
11
+/// Swatch spacing.
12
+const SWATCH_SPACING: u32 = 4;
13
+
14
+/// Number of columns in the palette grid.
15
+const COLUMNS: usize = 9;
16
+
17
+/// Preset colors (matching the 1-9 keyboard shortcuts).
18
+const PRESET_COLORS: [Color; 9] = [
19
+    Color::new(1.0, 0.4, 0.0, 1.0),   // 1: Orange (default)
20
+    Color::new(1.0, 0.0, 0.0, 1.0),   // 2: Red
21
+    Color::new(0.0, 1.0, 0.0, 1.0),   // 3: Green
22
+    Color::new(0.0, 0.5, 1.0, 1.0),   // 4: Blue
23
+    Color::new(1.0, 1.0, 0.0, 1.0),   // 5: Yellow
24
+    Color::new(1.0, 0.0, 1.0, 1.0),   // 6: Magenta
25
+    Color::new(0.0, 1.0, 1.0, 1.0),   // 7: Cyan
26
+    Color::new(1.0, 1.0, 1.0, 1.0),   // 8: White
27
+    Color::new(0.0, 0.0, 0.0, 1.0),   // 9: Black
28
+];
29
+
30
+/// Extended palette colors.
31
+const EXTENDED_PALETTE: [Color; 36] = [
32
+    // Row 1: Reds
33
+    Color::new(1.0, 0.8, 0.8, 1.0),
34
+    Color::new(1.0, 0.6, 0.6, 1.0),
35
+    Color::new(1.0, 0.4, 0.4, 1.0),
36
+    Color::new(1.0, 0.2, 0.2, 1.0),
37
+    Color::new(0.8, 0.0, 0.0, 1.0),
38
+    Color::new(0.6, 0.0, 0.0, 1.0),
39
+    Color::new(0.4, 0.0, 0.0, 1.0),
40
+    Color::new(0.3, 0.0, 0.0, 1.0),
41
+    Color::new(0.2, 0.0, 0.0, 1.0),
42
+    // Row 2: Oranges/Yellows
43
+    Color::new(1.0, 0.9, 0.7, 1.0),
44
+    Color::new(1.0, 0.8, 0.4, 1.0),
45
+    Color::new(1.0, 0.6, 0.2, 1.0),
46
+    Color::new(1.0, 0.5, 0.0, 1.0),
47
+    Color::new(0.9, 0.7, 0.0, 1.0),
48
+    Color::new(0.8, 0.8, 0.0, 1.0),
49
+    Color::new(0.6, 0.6, 0.0, 1.0),
50
+    Color::new(0.4, 0.4, 0.0, 1.0),
51
+    Color::new(0.3, 0.3, 0.0, 1.0),
52
+    // Row 3: Greens
53
+    Color::new(0.8, 1.0, 0.8, 1.0),
54
+    Color::new(0.6, 1.0, 0.6, 1.0),
55
+    Color::new(0.4, 1.0, 0.4, 1.0),
56
+    Color::new(0.0, 0.9, 0.0, 1.0),
57
+    Color::new(0.0, 0.7, 0.0, 1.0),
58
+    Color::new(0.0, 0.5, 0.0, 1.0),
59
+    Color::new(0.0, 0.4, 0.0, 1.0),
60
+    Color::new(0.0, 0.3, 0.0, 1.0),
61
+    Color::new(0.0, 0.2, 0.0, 1.0),
62
+    // Row 4: Blues/Purples
63
+    Color::new(0.8, 0.8, 1.0, 1.0),
64
+    Color::new(0.6, 0.6, 1.0, 1.0),
65
+    Color::new(0.4, 0.4, 1.0, 1.0),
66
+    Color::new(0.2, 0.2, 1.0, 1.0),
67
+    Color::new(0.0, 0.0, 0.8, 1.0),
68
+    Color::new(0.4, 0.0, 0.8, 1.0),
69
+    Color::new(0.6, 0.0, 0.8, 1.0),
70
+    Color::new(0.8, 0.0, 0.8, 1.0),
71
+    Color::new(0.4, 0.0, 0.4, 1.0),
72
+];
73
+
74
+/// Get the content rect for the palette tab.
75
+fn content_rect() -> Rect {
76
+    Rect::new(
77
+        PADDING as i32,
78
+        60, // After tab bar
79
+        PICKER_WIDTH - PADDING * 2,
80
+        250,
81
+    )
82
+}
83
+
84
+/// Get rect for a swatch at given row and column.
85
+fn swatch_rect(row: usize, col: usize, content: Rect) -> Rect {
86
+    Rect::new(
87
+        content.x + (col as i32 * (SWATCH_SIZE as i32 + SWATCH_SPACING as i32)),
88
+        content.y + (row as i32 * (SWATCH_SIZE as i32 + SWATCH_SPACING as i32)),
89
+        SWATCH_SIZE,
90
+        SWATCH_SIZE,
91
+    )
92
+}
93
+
94
+/// Handle click in palette tab.
95
+pub fn handle_click(local_pos: Point, recent: &[Color]) -> Option<Color> {
96
+    let content = content_rect();
97
+
98
+    // Check preset colors (row 0)
99
+    for (col, color) in PRESET_COLORS.iter().enumerate() {
100
+        let rect = swatch_rect(0, col, content);
101
+        if rect.contains_point(local_pos) {
102
+            return Some(*color);
103
+        }
104
+    }
105
+
106
+    // Check extended palette (rows 1-4)
107
+    for (i, color) in EXTENDED_PALETTE.iter().enumerate() {
108
+        let row = 2 + (i / COLUMNS);
109
+        let col = i % COLUMNS;
110
+        let rect = swatch_rect(row, col, content);
111
+        if rect.contains_point(local_pos) {
112
+            return Some(*color);
113
+        }
114
+    }
115
+
116
+    // Check recent colors (last row)
117
+    let recent_row = 7;
118
+    for (col, color) in recent.iter().enumerate().take(8) {
119
+        let rect = swatch_rect(recent_row, col, content);
120
+        if rect.contains_point(local_pos) {
121
+            return Some(*color);
122
+        }
123
+    }
124
+
125
+    None
126
+}
127
+
128
+/// Draw the palette tab content.
129
+pub fn draw(ctx: &cairo::Context, _content_rect: Rect, recent: &[Color]) -> anyhow::Result<()> {
130
+    let content = content_rect();
131
+
132
+    // Draw section label for presets
133
+    set_color(ctx, Color::new(0.7, 0.7, 0.7, 1.0));
134
+    ctx.select_font_face("sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
135
+    ctx.set_font_size(11.0);
136
+    ctx.move_to(content.x as f64, (content.y - 4) as f64);
137
+    ctx.show_text("Presets (1-9)")?;
138
+
139
+    // Draw preset colors
140
+    for (col, color) in PRESET_COLORS.iter().enumerate() {
141
+        let rect = swatch_rect(0, col, content);
142
+        draw_swatch(ctx, rect, *color)?;
143
+
144
+        // Draw key number
145
+        set_color(ctx, Color::WHITE);
146
+        ctx.set_font_size(10.0);
147
+        ctx.move_to((rect.x + 2) as f64, (rect.y + rect.height as i32 - 3) as f64);
148
+        ctx.show_text(&format!("{}", col + 1))?;
149
+    }
150
+
151
+    // Draw section label for extended palette
152
+    let extended_y = content.y + (SWATCH_SIZE as i32 + SWATCH_SPACING as i32) + 8;
153
+    set_color(ctx, Color::new(0.7, 0.7, 0.7, 1.0));
154
+    ctx.set_font_size(11.0);
155
+    ctx.move_to(content.x as f64, extended_y as f64);
156
+    ctx.show_text("Extended")?;
157
+
158
+    // Draw extended palette
159
+    for (i, color) in EXTENDED_PALETTE.iter().enumerate() {
160
+        let row = 2 + (i / COLUMNS);
161
+        let col = i % COLUMNS;
162
+        let rect = swatch_rect(row, col, content);
163
+        draw_swatch(ctx, rect, *color)?;
164
+    }
165
+
166
+    // Draw section label for recent colors
167
+    let recent_y = swatch_rect(6, 0, content).y + SWATCH_SIZE as i32 + 8;
168
+    set_color(ctx, Color::new(0.7, 0.7, 0.7, 1.0));
169
+    ctx.set_font_size(11.0);
170
+    ctx.move_to(content.x as f64, recent_y as f64);
171
+    ctx.show_text("Recent")?;
172
+
173
+    // Draw recent colors
174
+    let recent_row = 7;
175
+    for col in 0..8 {
176
+        let rect = swatch_rect(recent_row, col, content);
177
+        if col < recent.len() {
178
+            draw_swatch(ctx, rect, recent[col])?;
179
+        } else {
180
+            // Draw empty swatch placeholder
181
+            fill_rounded_rect(ctx, rect, 4.0, Color::new(0.2, 0.2, 0.2, 1.0));
182
+            stroke_rounded_rect(ctx, rect, 4.0, Color::new(0.3, 0.3, 0.3, 1.0), 1.0);
183
+        }
184
+    }
185
+
186
+    Ok(())
187
+}
188
+
189
+/// Draw a color swatch.
190
+fn draw_swatch(ctx: &cairo::Context, rect: Rect, color: Color) -> anyhow::Result<()> {
191
+    // Draw checkerboard for alpha
192
+    if color.a < 1.0 {
193
+        let check_size = 4.0;
194
+        for y in 0..((rect.height as f64 / check_size) as i32) {
195
+            for x in 0..((rect.width as f64 / check_size) as i32) {
196
+                let is_light = (x + y) % 2 == 0;
197
+                set_color(
198
+                    ctx,
199
+                    if is_light {
200
+                        Color::new(0.6, 0.6, 0.6, 1.0)
201
+                    } else {
202
+                        Color::new(0.4, 0.4, 0.4, 1.0)
203
+                    },
204
+                );
205
+                ctx.rectangle(
206
+                    rect.x as f64 + x as f64 * check_size,
207
+                    rect.y as f64 + y as f64 * check_size,
208
+                    check_size,
209
+                    check_size,
210
+                );
211
+                ctx.fill()?;
212
+            }
213
+        }
214
+    }
215
+
216
+    fill_rounded_rect(ctx, rect, 4.0, color);
217
+    stroke_rounded_rect(ctx, rect, 4.0, Color::new(0.4, 0.4, 0.4, 1.0), 1.0);
218
+
219
+    Ok(())
220
+}
garshot/src/annotate/ui/color_picker/rgb_tab.rsadded
@@ -0,0 +1,277 @@
1
+//! RGB sliders tab.
2
+
3
+use gartk_core::{Color, Point, Rect};
4
+use gartk_render::set_color;
5
+
6
+use super::{DragTarget, PADDING, PICKER_WIDTH};
7
+
8
+/// Slider height.
9
+const SLIDER_HEIGHT: u32 = 24;
10
+
11
+/// Slider spacing.
12
+const SLIDER_SPACING: u32 = 40;
13
+
14
+/// Label width.
15
+const LABEL_WIDTH: u32 = 30;
16
+
17
+/// Get the rect for a slider at given index.
18
+fn slider_rect(index: usize, content_rect: Rect) -> Rect {
19
+    let y = content_rect.y + (index as i32 * SLIDER_SPACING as i32);
20
+    Rect::new(
21
+        content_rect.x + LABEL_WIDTH as i32,
22
+        y,
23
+        content_rect.width - LABEL_WIDTH - 50, // Leave room for value text
24
+        SLIDER_HEIGHT,
25
+    )
26
+}
27
+
28
+/// Get the content rect for the RGB tab.
29
+fn content_rect() -> Rect {
30
+    Rect::new(
31
+        PADDING as i32,
32
+        60, // After tab bar
33
+        PICKER_WIDTH - PADDING * 2,
34
+        200,
35
+    )
36
+}
37
+
38
+/// Handle mouse press in RGB tab.
39
+pub fn handle_press(
40
+    local_pos: Point,
41
+    hsv: &mut (f64, f64, f64),
42
+    alpha: &mut f64,
43
+) -> Option<DragTarget> {
44
+    let content = content_rect();
45
+
46
+    // Check each slider
47
+    let sliders = [
48
+        DragTarget::RedSlider,
49
+        DragTarget::GreenSlider,
50
+        DragTarget::BlueSlider,
51
+        DragTarget::AlphaSlider,
52
+    ];
53
+
54
+    for (i, target) in sliders.iter().enumerate() {
55
+        let rect = slider_rect(i, content);
56
+        if rect.contains_point(local_pos) {
57
+            update_from_slider(local_pos, *target, rect, hsv, alpha);
58
+            return Some(*target);
59
+        }
60
+    }
61
+
62
+    None
63
+}
64
+
65
+/// Handle mouse drag in RGB tab.
66
+pub fn handle_drag(
67
+    local_pos: Point,
68
+    target: DragTarget,
69
+    hsv: &mut (f64, f64, f64),
70
+    alpha: &mut f64,
71
+) {
72
+    let content = content_rect();
73
+    let index = match target {
74
+        DragTarget::RedSlider => 0,
75
+        DragTarget::GreenSlider => 1,
76
+        DragTarget::BlueSlider => 2,
77
+        DragTarget::AlphaSlider => 3,
78
+        _ => return,
79
+    };
80
+    let rect = slider_rect(index, content);
81
+    update_from_slider(local_pos, target, rect, hsv, alpha);
82
+}
83
+
84
+/// Update color from slider position.
85
+fn update_from_slider(
86
+    local_pos: Point,
87
+    target: DragTarget,
88
+    rect: Rect,
89
+    hsv: &mut (f64, f64, f64),
90
+    alpha: &mut f64,
91
+) {
92
+    let value = ((local_pos.x - rect.x) as f64 / rect.width as f64).clamp(0.0, 1.0);
93
+
94
+    // Convert current HSV to RGB
95
+    let current = Color::from_hsva(hsv.0, hsv.1, hsv.2, *alpha);
96
+    let mut r = current.r;
97
+    let mut g = current.g;
98
+    let mut b = current.b;
99
+
100
+    match target {
101
+        DragTarget::RedSlider => r = value,
102
+        DragTarget::GreenSlider => g = value,
103
+        DragTarget::BlueSlider => b = value,
104
+        DragTarget::AlphaSlider => {
105
+            *alpha = value;
106
+            return;
107
+        }
108
+        _ => return,
109
+    }
110
+
111
+    // Convert back to HSV
112
+    let new_color = Color::new(r, g, b, *alpha);
113
+    *hsv = new_color.to_hsv();
114
+}
115
+
116
+/// Draw the RGB tab content.
117
+pub fn draw(
118
+    ctx: &cairo::Context,
119
+    _content_rect: Rect,
120
+    hsv: (f64, f64, f64),
121
+    alpha: f64,
122
+) -> anyhow::Result<()> {
123
+    let content = content_rect();
124
+    let current = Color::from_hsva(hsv.0, hsv.1, hsv.2, alpha);
125
+
126
+    let labels = ["R", "G", "B", "A"];
127
+    let values = [current.r, current.g, current.b, alpha];
128
+    let colors = [
129
+        (Color::RED, Color::new(0.2, 0.0, 0.0, 1.0)),
130
+        (Color::GREEN, Color::new(0.0, 0.2, 0.0, 1.0)),
131
+        (Color::BLUE, Color::new(0.0, 0.0, 0.2, 1.0)),
132
+        (Color::WHITE, Color::new(0.2, 0.2, 0.2, 1.0)),
133
+    ];
134
+
135
+    for (i, (label, value)) in labels.iter().zip(values.iter()).enumerate() {
136
+        let rect = slider_rect(i, content);
137
+        let (high_color, low_color) = colors[i];
138
+
139
+        // Draw label
140
+        set_color(ctx, Color::WHITE);
141
+        ctx.select_font_face("monospace", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
142
+        ctx.set_font_size(14.0);
143
+        ctx.move_to(
144
+            (content.x + 4) as f64,
145
+            (rect.y + rect.height as i32 - 6) as f64,
146
+        );
147
+        ctx.show_text(label)?;
148
+
149
+        // Draw slider track with gradient
150
+        draw_slider_gradient(ctx, rect, low_color, high_color)?;
151
+
152
+        // Draw slider handle
153
+        let handle_x = rect.x as f64 + value * rect.width as f64;
154
+        draw_slider_handle(ctx, handle_x, rect.y as f64 + rect.height as f64 / 2.0)?;
155
+
156
+        // Draw value text
157
+        let value_text = format!("{}", (value * 255.0).round() as u8);
158
+        set_color(ctx, Color::new(0.8, 0.8, 0.8, 1.0));
159
+        ctx.set_font_size(12.0);
160
+        ctx.move_to(
161
+            (rect.x + rect.width as i32 + 8) as f64,
162
+            (rect.y + rect.height as i32 - 6) as f64,
163
+        );
164
+        ctx.show_text(&value_text)?;
165
+    }
166
+
167
+    Ok(())
168
+}
169
+
170
+/// Draw a slider gradient background.
171
+fn draw_slider_gradient(
172
+    ctx: &cairo::Context,
173
+    rect: Rect,
174
+    from: Color,
175
+    to: Color,
176
+) -> anyhow::Result<()> {
177
+    // Create horizontal linear gradient
178
+    let gradient = cairo::LinearGradient::new(
179
+        rect.x as f64,
180
+        rect.y as f64,
181
+        (rect.x + rect.width as i32) as f64,
182
+        rect.y as f64,
183
+    );
184
+    gradient.add_color_stop_rgba(0.0, from.r, from.g, from.b, from.a);
185
+    gradient.add_color_stop_rgba(1.0, to.r, to.g, to.b, to.a);
186
+
187
+    // Draw rounded rect with gradient
188
+    let radius = 4.0;
189
+    ctx.new_sub_path();
190
+    ctx.arc(
191
+        rect.x as f64 + rect.width as f64 - radius,
192
+        rect.y as f64 + radius,
193
+        radius,
194
+        -0.5 * std::f64::consts::PI,
195
+        0.0,
196
+    );
197
+    ctx.arc(
198
+        rect.x as f64 + rect.width as f64 - radius,
199
+        rect.y as f64 + rect.height as f64 - radius,
200
+        radius,
201
+        0.0,
202
+        0.5 * std::f64::consts::PI,
203
+    );
204
+    ctx.arc(
205
+        rect.x as f64 + radius,
206
+        rect.y as f64 + rect.height as f64 - radius,
207
+        radius,
208
+        0.5 * std::f64::consts::PI,
209
+        std::f64::consts::PI,
210
+    );
211
+    ctx.arc(
212
+        rect.x as f64 + radius,
213
+        rect.y as f64 + radius,
214
+        radius,
215
+        std::f64::consts::PI,
216
+        1.5 * std::f64::consts::PI,
217
+    );
218
+    ctx.close_path();
219
+
220
+    ctx.set_source(&gradient)?;
221
+    ctx.fill()?;
222
+
223
+    // Draw border
224
+    set_color(ctx, Color::new(0.4, 0.4, 0.4, 1.0));
225
+    ctx.set_line_width(1.0);
226
+    ctx.new_sub_path();
227
+    ctx.arc(
228
+        rect.x as f64 + rect.width as f64 - radius,
229
+        rect.y as f64 + radius,
230
+        radius,
231
+        -0.5 * std::f64::consts::PI,
232
+        0.0,
233
+    );
234
+    ctx.arc(
235
+        rect.x as f64 + rect.width as f64 - radius,
236
+        rect.y as f64 + rect.height as f64 - radius,
237
+        radius,
238
+        0.0,
239
+        0.5 * std::f64::consts::PI,
240
+    );
241
+    ctx.arc(
242
+        rect.x as f64 + radius,
243
+        rect.y as f64 + rect.height as f64 - radius,
244
+        radius,
245
+        0.5 * std::f64::consts::PI,
246
+        std::f64::consts::PI,
247
+    );
248
+    ctx.arc(
249
+        rect.x as f64 + radius,
250
+        rect.y as f64 + radius,
251
+        radius,
252
+        std::f64::consts::PI,
253
+        1.5 * std::f64::consts::PI,
254
+    );
255
+    ctx.close_path();
256
+    ctx.stroke()?;
257
+
258
+    Ok(())
259
+}
260
+
261
+/// Draw a slider handle.
262
+fn draw_slider_handle(ctx: &cairo::Context, x: f64, y: f64) -> anyhow::Result<()> {
263
+    let radius = 8.0;
264
+
265
+    // White fill
266
+    set_color(ctx, Color::WHITE);
267
+    ctx.arc(x, y, radius, 0.0, 2.0 * std::f64::consts::PI);
268
+    ctx.fill()?;
269
+
270
+    // Dark border
271
+    set_color(ctx, Color::new(0.3, 0.3, 0.3, 1.0));
272
+    ctx.set_line_width(2.0);
273
+    ctx.arc(x, y, radius, 0.0, 2.0 * std::f64::consts::PI);
274
+    ctx.stroke()?;
275
+
276
+    Ok(())
277
+}
garshot/src/annotate/ui/color_picker/tabs.rsadded
@@ -0,0 +1,94 @@
1
+//! Tab bar for color picker dialog.
2
+
3
+use gartk_core::{Color, Point, Rect};
4
+use gartk_render::{fill_rounded_rect, set_color};
5
+
6
+use super::PADDING;
7
+
8
+/// Tab height.
9
+const TAB_HEIGHT: u32 = 28;
10
+
11
+/// Tab width.
12
+const TAB_WIDTH: u32 = 70;
13
+
14
+/// Color picker tab selection.
15
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16
+pub enum ColorPickerTab {
17
+    /// HSV color wheel tab.
18
+    #[default]
19
+    Hsv,
20
+    /// RGB sliders tab.
21
+    Rgb,
22
+    /// Color palette tab.
23
+    Palette,
24
+}
25
+
26
+impl ColorPickerTab {
27
+    /// Get all tabs in order.
28
+    pub const fn all() -> [Self; 3] {
29
+        [Self::Hsv, Self::Rgb, Self::Palette]
30
+    }
31
+
32
+    /// Get tab label.
33
+    pub const fn label(&self) -> &'static str {
34
+        match self {
35
+            Self::Hsv => "HSV",
36
+            Self::Rgb => "RGB",
37
+            Self::Palette => "Palette",
38
+        }
39
+    }
40
+}
41
+
42
+/// Get the rect for a tab at given index.
43
+fn tab_rect(index: usize) -> Rect {
44
+    let x = PADDING as i32 + (index as i32 * (TAB_WIDTH as i32 + 4));
45
+    Rect::new(x, PADDING as i32, TAB_WIDTH, TAB_HEIGHT)
46
+}
47
+
48
+/// Handle click on tab bar, returns new tab if one was clicked.
49
+pub fn handle_click(local_pos: Point) -> Option<ColorPickerTab> {
50
+    for (i, tab) in ColorPickerTab::all().iter().enumerate() {
51
+        if tab_rect(i).contains_point(local_pos) {
52
+            return Some(*tab);
53
+        }
54
+    }
55
+    None
56
+}
57
+
58
+/// Draw the tab bar.
59
+pub fn draw(ctx: &cairo::Context, selected: ColorPickerTab) -> anyhow::Result<()> {
60
+    for (i, tab) in ColorPickerTab::all().iter().enumerate() {
61
+        let rect = tab_rect(i);
62
+        let is_selected = *tab == selected;
63
+
64
+        // Tab background
65
+        let bg = if is_selected {
66
+            Color::new(0.3, 0.3, 0.3, 1.0)
67
+        } else {
68
+            Color::new(0.2, 0.2, 0.2, 1.0)
69
+        };
70
+        fill_rounded_rect(ctx, rect, 4.0, bg);
71
+
72
+        // Tab label
73
+        set_color(
74
+            ctx,
75
+            if is_selected {
76
+                Color::WHITE
77
+            } else {
78
+                Color::new(0.7, 0.7, 0.7, 1.0)
79
+            },
80
+        );
81
+        ctx.select_font_face("sans-serif", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
82
+        ctx.set_font_size(12.0);
83
+
84
+        let label = tab.label();
85
+        let extents = ctx.text_extents(label)?;
86
+        let text_x = rect.x as f64 + (rect.width as f64 - extents.width()) / 2.0;
87
+        let text_y = rect.y as f64 + (rect.height as f64 + extents.height()) / 2.0;
88
+
89
+        ctx.move_to(text_x, text_y);
90
+        ctx.show_text(label)?;
91
+    }
92
+
93
+    Ok(())
94
+}
garshot/src/annotate/ui/mod.rsmodified
@@ -1,5 +1,7 @@
11
 //! Annotation UI components.
22
 
3
+pub mod color_picker;
34
 mod toolbar;
45
 
5
-pub use toolbar::{Toolbar, TOOLBAR_HEIGHT};
6
+pub use color_picker::{ColorPicker, ColorPickerResult, PICKER_HEIGHT, PICKER_WIDTH};
7
+pub use toolbar::{Toolbar, ToolbarClickResult, TOOLBAR_HEIGHT};
garshot/src/annotate/ui/toolbar.rsmodified
@@ -9,6 +9,17 @@ use gartk_render::{
99
 /// Toolbar height in pixels.
1010
 pub const TOOLBAR_HEIGHT: u32 = 48;
1111
 
12
+/// Result of a toolbar click.
13
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
+pub enum ToolbarClickResult {
15
+    /// A tool button was clicked.
16
+    Tool(ToolType),
17
+    /// The color preview was clicked (open color picker).
18
+    ColorPreview,
19
+    /// Click was in toolbar area but not on any button.
20
+    None,
21
+}
22
+
1223
 /// Toolbar for selecting annotation tools.
1324
 pub struct Toolbar {
1425
     /// Toolbar bounds.
@@ -61,6 +72,16 @@ impl Toolbar {
6172
         Rect::new(x, y, Self::BUTTON_WIDTH, TOOLBAR_HEIGHT - Self::PADDING * 2)
6273
     }
6374
 
75
+    /// Calculate the color preview rect.
76
+    pub fn color_preview_rect(&self) -> Rect {
77
+        Rect::new(
78
+            (ToolType::all().len() as i32 + 1) * (Self::BUTTON_WIDTH as i32 + Self::PADDING as i32),
79
+            Self::PADDING as i32 + 4,
80
+            32,
81
+            32,
82
+        )
83
+    }
84
+
6485
     /// Draw the toolbar onto a surface.
6586
     pub fn draw(&self, surface: &Surface) -> anyhow::Result<()> {
6687
         let ctx = surface.context()?;
@@ -102,12 +123,7 @@ impl Toolbar {
102123
         }
103124
 
104125
         // Draw color preview
105
-        let color_rect = Rect::new(
106
-            (ToolType::all().len() as i32 + 1) * (Self::BUTTON_WIDTH as i32 + Self::PADDING as i32),
107
-            Self::PADDING as i32 + 4,
108
-            32,
109
-            32,
110
-        );
126
+        let color_rect = self.color_preview_rect();
111127
         fill_rounded_rect(&ctx, color_rect, 4.0, self.current_color);
112128
         stroke_rounded_rect(
113129
             &ctx,
@@ -136,19 +152,25 @@ impl Toolbar {
136152
         Ok(())
137153
     }
138154
 
139
-    /// Handle click on toolbar, returns selected tool if a button was clicked.
140
-    pub fn handle_click(&self, pos: Point) -> Option<ToolType> {
155
+    /// Handle click on toolbar, returns the click result.
156
+    pub fn handle_click(&self, pos: Point) -> ToolbarClickResult {
141157
         if !self.rect.contains_point(pos) {
142
-            return None;
158
+            return ToolbarClickResult::None;
143159
         }
144160
 
161
+        // Check tool buttons
145162
         for (i, tool) in ToolType::all().iter().enumerate() {
146163
             let btn_rect = self.button_rect(i);
147164
             if btn_rect.contains_point(pos) {
148
-                return Some(*tool);
165
+                return ToolbarClickResult::Tool(*tool);
149166
             }
150167
         }
151168
 
152
-        None
169
+        // Check color preview
170
+        if self.color_preview_rect().contains_point(pos) {
171
+            return ToolbarClickResult::ColorPreview;
172
+        }
173
+
174
+        ToolbarClickResult::None
153175
     }
154176
 }