gardesk/garshot / 5a045e6

Browse files

annotate: add floating dialog overlay

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5a045e6c1546727eed72d5ac073e83bdfbe607e6
Parents
d141b9d
Tree
a2e7a44

1 changed file

StatusFile+-
A garshot/src/annotate/overlay.rs 570 0
garshot/src/annotate/overlay.rsadded
@@ -0,0 +1,570 @@
1
+//! Annotation overlay window using gartk-x11.
2
+
3
+use anyhow::{Context, Result};
4
+
5
+use crate::annotate::canvas::AnnotationCanvas;
6
+use crate::annotate::history::{History, Snapshot};
7
+use crate::annotate::state::{AnnotationResult, AnnotationState, ToolType};
8
+use crate::annotate::tools::{self, Tool};
9
+use crate::annotate::ui::{Toolbar, TOOLBAR_HEIGHT};
10
+
11
+use gartk_core::{InputEvent, Key, KeyEvent, Modifiers, MouseButton, MouseEvent, Point};
12
+use gartk_x11::{Connection, CursorManager, Window, WindowConfig};
13
+use x11rb::connection::Connection as X11Connection;
14
+use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt, PropMode};
15
+use x11rb::wrapper::ConnectionExt as WrapperConnectionExt;
16
+
17
+/// Annotation overlay for editing screenshots.
18
+pub struct AnnotationOverlay {
19
+    /// X11 connection.
20
+    conn: Connection,
21
+    /// Overlay window.
22
+    window: Window,
23
+    /// Graphics context for blitting.
24
+    gc: u32,
25
+    /// Annotation canvas.
26
+    canvas: AnnotationCanvas,
27
+    /// Annotation state.
28
+    state: AnnotationState,
29
+    /// Current tool instance.
30
+    tool: Box<dyn Tool>,
31
+    /// Undo/redo history.
32
+    history: History,
33
+    /// Toolbar UI.
34
+    toolbar: Toolbar,
35
+    /// Cursor manager.
36
+    cursor_manager: CursorManager,
37
+    /// Whether a redraw is needed.
38
+    needs_redraw: bool,
39
+    /// Image offset from top (for toolbar).
40
+    image_offset_y: i32,
41
+}
42
+
43
+impl AnnotationOverlay {
44
+    /// Create a new annotation overlay.
45
+    ///
46
+    /// # Arguments
47
+    /// * `image_data` - RGBA pixel data of the screenshot
48
+    /// * `width` - Image width
49
+    /// * `height` - Image height
50
+    pub fn new(image_data: &[u8], width: u32, height: u32) -> Result<Self> {
51
+        // Connect to X11
52
+        let conn = Connection::connect(None).context("Failed to connect to X11")?;
53
+
54
+        // Window size = image size + toolbar height
55
+        let window_width = width;
56
+        let window_height = height + TOOLBAR_HEIGHT;
57
+
58
+        // Center window on screen
59
+        let screen_width = conn.screen_width();
60
+        let screen_height = conn.screen_height();
61
+        let pos_x = (screen_width as i32 - window_width as i32) / 2;
62
+        let pos_y = (screen_height as i32 - window_height as i32) / 2;
63
+
64
+        // Create floating window (no override_redirect so gar can manage it)
65
+        let config = WindowConfig::new()
66
+            .title("garshot annotation")
67
+            .class("garshot-annotate")
68
+            .size(window_width, window_height)
69
+            .position(pos_x.max(0), pos_y.max(0))
70
+            .map_on_create(false);
71
+
72
+        let window = Window::create(conn.clone(), config).context("Failed to create window")?;
73
+
74
+        // Set window type to DIALOG so gar floats it automatically
75
+        let window_type_atom = conn.inner()
76
+            .intern_atom(false, b"_NET_WM_WINDOW_TYPE")?
77
+            .reply()
78
+            .context("Failed to intern window type atom")?
79
+            .atom;
80
+        let dialog_type_atom = conn.inner()
81
+            .intern_atom(false, b"_NET_WM_WINDOW_TYPE_DIALOG")?
82
+            .reply()
83
+            .context("Failed to intern dialog type atom")?
84
+            .atom;
85
+        conn.inner().change_property32(
86
+            PropMode::REPLACE,
87
+            window.id(),
88
+            window_type_atom,
89
+            AtomEnum::ATOM,
90
+            &[dialog_type_atom],
91
+        )?;
92
+
93
+        // Create GC for blitting
94
+        let gc = conn.generate_id()?;
95
+        conn.inner()
96
+            .create_gc(gc, window.id(), &Default::default())?;
97
+
98
+        // Create canvas
99
+        let canvas =
100
+            AnnotationCanvas::new(image_data, width, height).context("Failed to create canvas")?;
101
+
102
+        // Create toolbar (sized to image width)
103
+        let toolbar = Toolbar::new(width);
104
+
105
+        // Create cursor manager
106
+        let cursor_manager =
107
+            CursorManager::new(conn.clone()).context("Failed to create cursor manager")?;
108
+
109
+        // Create initial tool
110
+        let tool = tools::create_tool(ToolType::Arrow);
111
+
112
+        Ok(Self {
113
+            conn,
114
+            window,
115
+            gc,
116
+            canvas,
117
+            state: AnnotationState::new(),
118
+            tool,
119
+            history: History::new(),
120
+            toolbar,
121
+            cursor_manager,
122
+            needs_redraw: true,
123
+            image_offset_y: TOOLBAR_HEIGHT as i32,
124
+        })
125
+    }
126
+
127
+    /// Run the annotation overlay event loop.
128
+    pub fn run(mut self) -> Result<AnnotationResult> {
129
+        // Set full opacity to prevent picom from making window transparent
130
+        self.set_full_opacity()?;
131
+
132
+        // Map window (gar will manage it as a floating dialog)
133
+        self.window.map()?;
134
+
135
+        // Grab keyboard for our shortcuts (Escape, Ctrl+Enter, tool keys, etc.)
136
+        // Don't grab pointer - let gar handle mod+drag for window movement
137
+        self.window.grab_keyboard_with_retry(10, 50)?;
138
+
139
+        // Set initial cursor
140
+        self.update_cursor()?;
141
+
142
+        // Initial draw
143
+        self.redraw()?;
144
+
145
+        // Event loop
146
+        loop {
147
+            // Wait for event
148
+            let event = self.conn.inner().wait_for_event()?;
149
+
150
+            // Translate to InputEvent
151
+            if let Some(input_event) = self.translate_event(&event) {
152
+                let needs_redraw = self.handle_event(input_event)?;
153
+
154
+                if needs_redraw {
155
+                    self.redraw()?;
156
+                }
157
+            }
158
+
159
+            // Check if finished
160
+            if self.state.is_finished() {
161
+                break;
162
+            }
163
+        }
164
+
165
+        // Cleanup
166
+        self.window.ungrab_keyboard()?;
167
+
168
+        // Return result
169
+        Ok(self.state.take_result().unwrap_or(AnnotationResult::Cancel))
170
+    }
171
+
172
+    /// Translate X11 event to InputEvent.
173
+    fn translate_event(&self, event: &x11rb::protocol::Event) -> Option<InputEvent> {
174
+        use x11rb::protocol::Event;
175
+
176
+        match event {
177
+            Event::ButtonPress(e) => Some(InputEvent::MousePress(MouseEvent {
178
+                position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y),
179
+                button: match e.detail {
180
+                    1 => Some(MouseButton::Left),
181
+                    2 => Some(MouseButton::Middle),
182
+                    3 => Some(MouseButton::Right),
183
+                    _ => None,
184
+                },
185
+                modifiers: self.translate_modifiers(e.state),
186
+            })),
187
+            Event::ButtonRelease(e) => Some(InputEvent::MouseRelease(MouseEvent {
188
+                position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y),
189
+                button: match e.detail {
190
+                    1 => Some(MouseButton::Left),
191
+                    2 => Some(MouseButton::Middle),
192
+                    3 => Some(MouseButton::Right),
193
+                    _ => None,
194
+                },
195
+                modifiers: self.translate_modifiers(e.state),
196
+            })),
197
+            Event::MotionNotify(e) => Some(InputEvent::MouseMove(MouseEvent {
198
+                position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y),
199
+                button: None,
200
+                modifiers: self.translate_modifiers(e.state),
201
+            })),
202
+            Event::KeyPress(e) => {
203
+                let key = self.translate_keycode(e.detail);
204
+                Some(InputEvent::Key(KeyEvent {
205
+                    key,
206
+                    keycode: e.detail,
207
+                    pressed: true,
208
+                    modifiers: self.translate_modifiers(e.state),
209
+                }))
210
+            }
211
+            Event::KeyRelease(e) => {
212
+                let key = self.translate_keycode(e.detail);
213
+                Some(InputEvent::Key(KeyEvent {
214
+                    key,
215
+                    keycode: e.detail,
216
+                    pressed: false,
217
+                    modifiers: self.translate_modifiers(e.state),
218
+                }))
219
+            }
220
+            Event::Expose(_) => Some(InputEvent::Expose),
221
+            _ => None,
222
+        }
223
+    }
224
+
225
+    /// Translate X11 key modifiers.
226
+    fn translate_modifiers(&self, state: xproto::KeyButMask) -> Modifiers {
227
+        Modifiers {
228
+            shift: state.contains(xproto::KeyButMask::SHIFT),
229
+            ctrl: state.contains(xproto::KeyButMask::CONTROL),
230
+            alt: state.contains(xproto::KeyButMask::MOD1),
231
+            super_key: state.contains(xproto::KeyButMask::MOD4),
232
+            caps_lock: state.contains(xproto::KeyButMask::LOCK),
233
+            num_lock: state.contains(xproto::KeyButMask::MOD2),
234
+        }
235
+    }
236
+
237
+    /// Translate X11 keycode to Key.
238
+    fn translate_keycode(&self, keycode: u8) -> Key {
239
+        // Common keycodes (evdev-based)
240
+        match keycode {
241
+            9 => Key::Escape,
242
+            36 => Key::Return,
243
+            22 => Key::Backspace,
244
+            65 => Key::Space,
245
+            104 => Key::Return, // Numpad enter
246
+
247
+            // Letters (a-z: keycodes 38-58 approximately)
248
+            38 => Key::Char('a'),
249
+            56 => Key::Char('b'),
250
+            54 => Key::Char('c'),
251
+            40 => Key::Char('d'),
252
+            26 => Key::Char('e'),
253
+            41 => Key::Char('f'),
254
+            42 => Key::Char('g'),
255
+            43 => Key::Char('h'),
256
+            31 => Key::Char('i'),
257
+            44 => Key::Char('j'),
258
+            45 => Key::Char('k'),
259
+            46 => Key::Char('l'),
260
+            58 => Key::Char('m'),
261
+            57 => Key::Char('n'),
262
+            32 => Key::Char('o'),
263
+            33 => Key::Char('p'),
264
+            24 => Key::Char('q'),
265
+            27 => Key::Char('r'),
266
+            39 => Key::Char('s'),
267
+            28 => Key::Char('t'),
268
+            30 => Key::Char('u'),
269
+            55 => Key::Char('v'),
270
+            25 => Key::Char('w'),
271
+            53 => Key::Char('x'),
272
+            29 => Key::Char('y'),
273
+            52 => Key::Char('z'),
274
+
275
+            // Numbers
276
+            10 => Key::Char('1'),
277
+            11 => Key::Char('2'),
278
+            12 => Key::Char('3'),
279
+            13 => Key::Char('4'),
280
+            14 => Key::Char('5'),
281
+            15 => Key::Char('6'),
282
+            16 => Key::Char('7'),
283
+            17 => Key::Char('8'),
284
+            18 => Key::Char('9'),
285
+            19 => Key::Char('0'),
286
+
287
+            // Special characters
288
+            20 => Key::Char('-'),
289
+            21 => Key::Char('='),
290
+
291
+            _ => Key::Unknown(keycode),
292
+        }
293
+    }
294
+
295
+    /// Handle an input event.
296
+    fn handle_event(&mut self, event: InputEvent) -> Result<bool> {
297
+        let mut needs_redraw = false;
298
+
299
+        match &event {
300
+            // Handle toolbar clicks
301
+            InputEvent::MousePress(e) if e.position.y < 0 => {
302
+                // Click is in toolbar area (y < 0 because of offset)
303
+                let toolbar_pos = Point::new(e.position.x, e.position.y + self.image_offset_y);
304
+                if let Some(tool_type) = self.toolbar.handle_click(toolbar_pos) {
305
+                    self.select_tool(tool_type)?;
306
+                    needs_redraw = true;
307
+                }
308
+                return Ok(needs_redraw);
309
+            }
310
+
311
+            // Handle keyboard shortcuts
312
+            InputEvent::Key(e) if e.pressed => {
313
+                // Ctrl+Enter: Save
314
+                if e.modifiers.ctrl && e.key == Key::Return {
315
+                    self.save()?;
316
+                    return Ok(false);
317
+                }
318
+
319
+                // Escape: Cancel (if not in text editing mode)
320
+                if e.key == Key::Escape && !self.tool.is_drawing() {
321
+                    self.state.finish_cancel();
322
+                    return Ok(false);
323
+                }
324
+
325
+                // Ctrl+Z: Undo
326
+                if e.modifiers.ctrl && e.key == Key::Char('z') {
327
+                    if e.modifiers.shift {
328
+                        self.redo()?;
329
+                    } else {
330
+                        self.undo()?;
331
+                    }
332
+                    needs_redraw = true;
333
+                    return Ok(needs_redraw);
334
+                }
335
+
336
+                // Ctrl+Y: Redo
337
+                if e.modifiers.ctrl && e.key == Key::Char('y') {
338
+                    self.redo()?;
339
+                    needs_redraw = true;
340
+                    return Ok(needs_redraw);
341
+                }
342
+
343
+                // Tool shortcuts (only when not editing text)
344
+                if !self.tool.is_drawing() {
345
+                    if let Key::Char(c) = e.key {
346
+                        // Number keys for color presets
347
+                        if let Some(digit) = c.to_digit(10) {
348
+                            if digit >= 1 && digit <= 9 {
349
+                                self.state.set_color_preset((digit - 1) as usize);
350
+                                needs_redraw = true;
351
+                                return Ok(needs_redraw);
352
+                            }
353
+                        }
354
+
355
+                        // Tool shortcuts
356
+                        if let Some(tool_type) = ToolType::from_shortcut(c) {
357
+                            self.select_tool(tool_type)?;
358
+                            needs_redraw = true;
359
+                            return Ok(needs_redraw);
360
+                        }
361
+
362
+                        // Line width adjustment
363
+                        if c == '+' || c == '=' {
364
+                            self.state.properties.increase_line_width();
365
+                            needs_redraw = true;
366
+                            return Ok(needs_redraw);
367
+                        }
368
+                        if c == '-' {
369
+                            self.state.properties.decrease_line_width();
370
+                            needs_redraw = true;
371
+                            return Ok(needs_redraw);
372
+                        }
373
+                    }
374
+                }
375
+            }
376
+
377
+            _ => {}
378
+        }
379
+
380
+        // Pass event to current tool
381
+        let tool_needs_redraw = self.tool.handle_event(&event, &self.state.properties);
382
+        needs_redraw |= tool_needs_redraw;
383
+
384
+        // Check if tool completed a drawing
385
+        if let InputEvent::MouseRelease(_) = &event {
386
+            if self.tool.can_commit() && !self.tool.is_drawing() {
387
+                self.commit_tool()?;
388
+                needs_redraw = true;
389
+            }
390
+        }
391
+
392
+        Ok(needs_redraw)
393
+    }
394
+
395
+    /// Select a tool.
396
+    fn select_tool(&mut self, tool_type: ToolType) -> Result<()> {
397
+        // Commit current tool if it has a pending drawing
398
+        if self.tool.can_commit() {
399
+            self.commit_tool()?;
400
+        }
401
+
402
+        self.state.select_tool(tool_type);
403
+        self.tool = tools::create_tool(tool_type);
404
+        self.update_cursor()?;
405
+
406
+        Ok(())
407
+    }
408
+
409
+    /// Commit the current tool's drawing to the canvas.
410
+    fn commit_tool(&mut self) -> Result<()> {
411
+        // Save snapshot for undo
412
+        let snapshot = Snapshot::new(self.canvas.snapshot_annotations()?);
413
+        self.history.push(snapshot);
414
+
415
+        // Commit tool drawing to annotations layer
416
+        let ctx = self.canvas.annotations_surface().context()?;
417
+        self.tool.commit(&ctx, &self.state.properties);
418
+
419
+        // Reset tool
420
+        self.tool.reset();
421
+
422
+        Ok(())
423
+    }
424
+
425
+    /// Undo last action.
426
+    fn undo(&mut self) -> Result<()> {
427
+        let current = Snapshot::new(self.canvas.snapshot_annotations()?);
428
+        if let Some(previous) = self.history.undo(current) {
429
+            self.canvas.restore_annotations(&previous.data)?;
430
+        }
431
+        Ok(())
432
+    }
433
+
434
+    /// Redo last undone action.
435
+    fn redo(&mut self) -> Result<()> {
436
+        let current = Snapshot::new(self.canvas.snapshot_annotations()?);
437
+        if let Some(next) = self.history.redo(current) {
438
+            self.canvas.restore_annotations(&next.data)?;
439
+        }
440
+        Ok(())
441
+    }
442
+
443
+    /// Save the annotated image.
444
+    fn save(&mut self) -> Result<()> {
445
+        // Commit any pending tool drawing
446
+        if self.tool.can_commit() {
447
+            self.commit_tool()?;
448
+        }
449
+
450
+        // Export final image
451
+        let data = self.canvas.export()?;
452
+        let width = self.canvas.width();
453
+        let height = self.canvas.height();
454
+
455
+        self.state.finish_save(data, width, height);
456
+
457
+        Ok(())
458
+    }
459
+
460
+    /// Update cursor for current tool.
461
+    fn update_cursor(&mut self) -> Result<()> {
462
+        let shape = self.tool.cursor();
463
+        self.cursor_manager.set_window_cursor(self.window.id(), shape)?;
464
+        Ok(())
465
+    }
466
+
467
+    /// Redraw the overlay.
468
+    fn redraw(&mut self) -> Result<()> {
469
+        // Draw current tool preview
470
+        self.canvas.clear_preview()?;
471
+        let preview_ctx = self.canvas.preview_surface().context()?;
472
+        self.tool.draw_preview(&preview_ctx, &self.state.properties);
473
+
474
+        // Render canvas layers
475
+        self.canvas.render()?;
476
+
477
+        // Update toolbar state
478
+        self.toolbar
479
+            .update(self.state.current_tool, &self.state.properties);
480
+
481
+        // Blit to window (toolbar + image)
482
+        self.blit_to_window()?;
483
+
484
+        self.needs_redraw = false;
485
+
486
+        Ok(())
487
+    }
488
+
489
+    /// Blit canvas and toolbar to window.
490
+    fn blit_to_window(&mut self) -> Result<()> {
491
+        let width = self.canvas.width();
492
+        let height = self.canvas.height();
493
+
494
+        // Create toolbar surface and draw toolbar
495
+        let toolbar_surface = gartk_render::Surface::new(width, TOOLBAR_HEIGHT)?;
496
+        self.toolbar.draw(&toolbar_surface)?;
497
+
498
+        // Get toolbar data and convert to BGRA
499
+        let mut toolbar_surface = toolbar_surface;
500
+        let toolbar_data = toolbar_surface.to_rgba()?;
501
+        let mut toolbar_bgra: Vec<u8> = toolbar_data;
502
+        for chunk in toolbar_bgra.chunks_exact_mut(4) {
503
+            chunk.swap(0, 2);
504
+        }
505
+
506
+        // Blit toolbar at y=0
507
+        self.conn.inner().put_image(
508
+            xproto::ImageFormat::Z_PIXMAP,
509
+            self.window.id(),
510
+            self.gc,
511
+            width as u16,
512
+            TOOLBAR_HEIGHT as u16,
513
+            0,
514
+            0,
515
+            0,
516
+            24,
517
+            &toolbar_bgra,
518
+        )?;
519
+
520
+        // Get image composite data
521
+        let surface = self.canvas.composite_surface_mut();
522
+        let data = surface.to_rgba()?;
523
+        let mut bgra: Vec<u8> = data;
524
+        for chunk in bgra.chunks_exact_mut(4) {
525
+            chunk.swap(0, 2);
526
+        }
527
+
528
+        // Blit image at y=TOOLBAR_HEIGHT
529
+        self.conn.inner().put_image(
530
+            xproto::ImageFormat::Z_PIXMAP,
531
+            self.window.id(),
532
+            self.gc,
533
+            width as u16,
534
+            height as u16,
535
+            0,
536
+            self.image_offset_y as i16,
537
+            0,
538
+            24,
539
+            &bgra,
540
+        )?;
541
+
542
+        self.conn.inner().flush()?;
543
+
544
+        Ok(())
545
+    }
546
+
547
+    /// Set window to full opacity to prevent compositor transparency.
548
+    fn set_full_opacity(&self) -> Result<()> {
549
+        // Intern the _NET_WM_WINDOW_OPACITY atom
550
+        let opacity_atom = self.conn.inner()
551
+            .intern_atom(false, b"_NET_WM_WINDOW_OPACITY")?
552
+            .reply()
553
+            .context("Failed to intern opacity atom")?
554
+            .atom;
555
+
556
+        // Full opacity = 0xFFFFFFFF (max u32)
557
+        let opacity: u32 = 0xFFFFFFFF;
558
+
559
+        self.conn.inner().change_property32(
560
+            PropMode::REPLACE,
561
+            self.window.id(),
562
+            opacity_atom,
563
+            AtomEnum::CARDINAL,
564
+            &[opacity],
565
+        )?;
566
+
567
+        self.conn.inner().flush()?;
568
+        Ok(())
569
+    }
570
+}