| 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::{ |
| 10 | color_picker::{ColorPicker, ColorPickerResult}, |
| 11 | Toolbar, ToolbarClickResult, TOOLBAR_HEIGHT, |
| 12 | }; |
| 13 | |
| 14 | use gartk_core::{InputEvent, Key, KeyEvent, Modifiers, MouseButton, MouseEvent, Point}; |
| 15 | use gartk_render::Surface; |
| 16 | use gartk_x11::{Connection, CursorManager, Window, WindowConfig}; |
| 17 | use x11rb::connection::Connection as X11Connection; |
| 18 | use x11rb::protocol::xproto::{self, AtomEnum, ConnectionExt, ImageFormat, PropMode}; |
| 19 | use x11rb::wrapper::ConnectionExt as WrapperConnectionExt; |
| 20 | |
| 21 | /// Maximum bytes per put_image request (conservative, below typical 256KB limit). |
| 22 | const MAX_PUT_IMAGE_BYTES: usize = 65536; |
| 23 | |
| 24 | /// Annotation overlay for editing screenshots. |
| 25 | pub struct AnnotationOverlay { |
| 26 | /// X11 connection. |
| 27 | conn: Connection, |
| 28 | /// Overlay window. |
| 29 | window: Window, |
| 30 | /// Graphics context for blitting. |
| 31 | gc: u32, |
| 32 | /// Annotation canvas. |
| 33 | canvas: AnnotationCanvas, |
| 34 | /// Annotation state. |
| 35 | state: AnnotationState, |
| 36 | /// Current tool instance. |
| 37 | tool: Box<dyn Tool>, |
| 38 | /// Undo/redo history. |
| 39 | history: History, |
| 40 | /// Toolbar UI. |
| 41 | toolbar: Toolbar, |
| 42 | /// Toolbar surface (reused to avoid allocation each frame). |
| 43 | toolbar_surface: Surface, |
| 44 | /// Cursor manager. |
| 45 | cursor_manager: CursorManager, |
| 46 | /// Whether a redraw is needed. |
| 47 | needs_redraw: bool, |
| 48 | /// Image offset from top (for toolbar). |
| 49 | image_offset_y: i32, |
| 50 | /// Color picker dialog (when open). |
| 51 | color_picker: Option<ColorPicker>, |
| 52 | /// Color picker surface (reused to avoid allocation). |
| 53 | color_picker_surface: Option<Surface>, |
| 54 | } |
| 55 | |
| 56 | impl AnnotationOverlay { |
| 57 | /// Create a new annotation overlay. |
| 58 | /// |
| 59 | /// # Arguments |
| 60 | /// * `image_data` - RGBA pixel data of the screenshot |
| 61 | /// * `width` - Image width |
| 62 | /// * `height` - Image height |
| 63 | pub fn new(image_data: &[u8], width: u32, height: u32) -> Result<Self> { |
| 64 | tracing::debug!("Creating annotation overlay for image {}x{}", width, height); |
| 65 | |
| 66 | // Connect to X11 |
| 67 | let conn = Connection::connect(None).context("Failed to connect to X11")?; |
| 68 | |
| 69 | // Window size = image size + toolbar height |
| 70 | let window_width = width; |
| 71 | let window_height = height + TOOLBAR_HEIGHT; |
| 72 | tracing::debug!("Window size will be {}x{} (including {}px toolbar)", |
| 73 | window_width, window_height, TOOLBAR_HEIGHT); |
| 74 | |
| 75 | // Center window on screen |
| 76 | let screen_width = conn.screen_width(); |
| 77 | let screen_height = conn.screen_height(); |
| 78 | let pos_x = (screen_width as i32 - window_width as i32) / 2; |
| 79 | let pos_y = (screen_height as i32 - window_height as i32) / 2; |
| 80 | |
| 81 | // Create floating window (no override_redirect so gar can manage it) |
| 82 | let config = WindowConfig::new() |
| 83 | .title("garshot annotation") |
| 84 | .class("garshot-annotate") |
| 85 | .size(window_width, window_height) |
| 86 | .position(pos_x.max(0), pos_y.max(0)) |
| 87 | .map_on_create(false); |
| 88 | |
| 89 | let window = Window::create(conn.clone(), config).context("Failed to create window")?; |
| 90 | tracing::debug!("Created window {} at position ({}, {})", |
| 91 | window.id(), pos_x.max(0), pos_y.max(0)); |
| 92 | |
| 93 | // Set window type to DIALOG so gar floats it automatically |
| 94 | let window_type_atom = conn.inner() |
| 95 | .intern_atom(false, b"_NET_WM_WINDOW_TYPE")? |
| 96 | .reply() |
| 97 | .context("Failed to intern window type atom")? |
| 98 | .atom; |
| 99 | let dialog_type_atom = conn.inner() |
| 100 | .intern_atom(false, b"_NET_WM_WINDOW_TYPE_DIALOG")? |
| 101 | .reply() |
| 102 | .context("Failed to intern dialog type atom")? |
| 103 | .atom; |
| 104 | conn.inner().change_property32( |
| 105 | PropMode::REPLACE, |
| 106 | window.id(), |
| 107 | window_type_atom, |
| 108 | AtomEnum::ATOM, |
| 109 | &[dialog_type_atom], |
| 110 | )?; |
| 111 | |
| 112 | // Create GC for blitting |
| 113 | let gc = conn.generate_id()?; |
| 114 | conn.inner() |
| 115 | .create_gc(gc, window.id(), &Default::default())?; |
| 116 | |
| 117 | // Create canvas |
| 118 | let canvas = |
| 119 | AnnotationCanvas::new(image_data, width, height).context("Failed to create canvas")?; |
| 120 | |
| 121 | // Create toolbar (sized to image width) |
| 122 | let toolbar = Toolbar::new(width); |
| 123 | |
| 124 | // Create cursor manager |
| 125 | let cursor_manager = |
| 126 | CursorManager::new(conn.clone()).context("Failed to create cursor manager")?; |
| 127 | |
| 128 | // Create initial tool |
| 129 | let tool = tools::create_tool(ToolType::Arrow); |
| 130 | |
| 131 | // Create reusable toolbar surface |
| 132 | let toolbar_surface = Surface::new(width, TOOLBAR_HEIGHT) |
| 133 | .context("Failed to create toolbar surface")?; |
| 134 | |
| 135 | // Set WM_NORMAL_HINTS to lock window size (prevents WM from resizing) |
| 136 | // Flags: PMinSize (16) | PMaxSize (32) | PSize (8) = 56 |
| 137 | let size_hints: [u32; 18] = [ |
| 138 | 56, // flags: PSize | PMinSize | PMaxSize |
| 139 | 0, 0, // x, y (obsolete) |
| 140 | window_width, window_height, // width, height (PSize) |
| 141 | window_width, window_height, // min_width, min_height (PMinSize) |
| 142 | window_width, window_height, // max_width, max_height (PMaxSize) |
| 143 | 0, 0, // width_inc, height_inc |
| 144 | 0, 0, // min_aspect_num, min_aspect_den |
| 145 | 0, 0, // max_aspect_num, max_aspect_den |
| 146 | 0, 0, // base_width, base_height |
| 147 | 0, // win_gravity |
| 148 | ]; |
| 149 | conn.inner().change_property32( |
| 150 | PropMode::REPLACE, |
| 151 | window.id(), |
| 152 | AtomEnum::WM_NORMAL_HINTS, |
| 153 | AtomEnum::WM_SIZE_HINTS, |
| 154 | &size_hints, |
| 155 | )?; |
| 156 | tracing::debug!("Set WM_NORMAL_HINTS: min/max {}x{}", window_width, window_height); |
| 157 | |
| 158 | // Tell compositor to bypass this window (reduces effects/lag from picom) |
| 159 | let bypass_atom = conn.inner() |
| 160 | .intern_atom(false, b"_NET_WM_BYPASS_COMPOSITOR")? |
| 161 | .reply() |
| 162 | .context("Failed to intern bypass atom")? |
| 163 | .atom; |
| 164 | conn.inner().change_property32( |
| 165 | PropMode::REPLACE, |
| 166 | window.id(), |
| 167 | bypass_atom, |
| 168 | AtomEnum::CARDINAL, |
| 169 | &[1], // 1 = bypass compositor |
| 170 | )?; |
| 171 | |
| 172 | Ok(Self { |
| 173 | conn, |
| 174 | window, |
| 175 | gc, |
| 176 | canvas, |
| 177 | state: AnnotationState::new(), |
| 178 | tool, |
| 179 | history: History::new(), |
| 180 | toolbar, |
| 181 | toolbar_surface, |
| 182 | cursor_manager, |
| 183 | needs_redraw: true, |
| 184 | image_offset_y: TOOLBAR_HEIGHT as i32, |
| 185 | color_picker: None, |
| 186 | color_picker_surface: None, |
| 187 | }) |
| 188 | } |
| 189 | |
| 190 | /// Run the annotation overlay event loop. |
| 191 | pub fn run(mut self) -> Result<AnnotationResult> { |
| 192 | // Set full opacity to prevent picom from making window transparent |
| 193 | self.set_full_opacity()?; |
| 194 | |
| 195 | // Map window (gar will manage it as a floating dialog) |
| 196 | self.window.map()?; |
| 197 | |
| 198 | // Flush and sync to ensure window is mapped before we try to draw |
| 199 | self.conn.inner().flush()?; |
| 200 | self.conn.inner().sync()?; |
| 201 | |
| 202 | // Focus and raise window for keyboard input (no grab - allows WM keybinds to work) |
| 203 | self.window.focus()?; |
| 204 | self.window.raise()?; |
| 205 | |
| 206 | // Set initial cursor |
| 207 | self.update_cursor()?; |
| 208 | |
| 209 | // Initial draw |
| 210 | self.redraw()?; |
| 211 | |
| 212 | // Flush to ensure initial draw is visible |
| 213 | self.conn.inner().flush()?; |
| 214 | |
| 215 | // Event loop |
| 216 | loop { |
| 217 | // Wait for first event |
| 218 | let event = self.conn.inner().wait_for_event()?; |
| 219 | let mut needs_redraw = false; |
| 220 | |
| 221 | // Translate and handle the first event |
| 222 | if let Some(input_event) = self.translate_event(&event) { |
| 223 | needs_redraw |= self.handle_event(input_event)?; |
| 224 | } |
| 225 | |
| 226 | // Check if finished after first event |
| 227 | if self.state.is_finished() { |
| 228 | break; |
| 229 | } |
| 230 | |
| 231 | // Process all pending events before redrawing (batching) |
| 232 | while let Some(event) = self.conn.inner().poll_for_event()? { |
| 233 | if let Some(input_event) = self.translate_event(&event) { |
| 234 | needs_redraw |= self.handle_event(input_event)?; |
| 235 | } |
| 236 | if self.state.is_finished() { |
| 237 | break; |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // Single redraw for all batched events |
| 242 | if needs_redraw { |
| 243 | self.redraw()?; |
| 244 | } |
| 245 | |
| 246 | // Check if finished |
| 247 | if self.state.is_finished() { |
| 248 | break; |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // Return result |
| 253 | Ok(self.state.take_result().unwrap_or(AnnotationResult::Cancel)) |
| 254 | } |
| 255 | |
| 256 | /// Translate X11 event to InputEvent. |
| 257 | fn translate_event(&self, event: &x11rb::protocol::Event) -> Option<InputEvent> { |
| 258 | use x11rb::protocol::Event; |
| 259 | |
| 260 | match event { |
| 261 | Event::ButtonPress(e) => Some(InputEvent::MousePress(MouseEvent { |
| 262 | position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y), |
| 263 | button: match e.detail { |
| 264 | 1 => Some(MouseButton::Left), |
| 265 | 2 => Some(MouseButton::Middle), |
| 266 | 3 => Some(MouseButton::Right), |
| 267 | _ => None, |
| 268 | }, |
| 269 | modifiers: self.translate_modifiers(e.state), |
| 270 | })), |
| 271 | Event::ButtonRelease(e) => Some(InputEvent::MouseRelease(MouseEvent { |
| 272 | position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y), |
| 273 | button: match e.detail { |
| 274 | 1 => Some(MouseButton::Left), |
| 275 | 2 => Some(MouseButton::Middle), |
| 276 | 3 => Some(MouseButton::Right), |
| 277 | _ => None, |
| 278 | }, |
| 279 | modifiers: self.translate_modifiers(e.state), |
| 280 | })), |
| 281 | Event::MotionNotify(e) => Some(InputEvent::MouseMove(MouseEvent { |
| 282 | position: Point::new(e.event_x as i32, e.event_y as i32 - self.image_offset_y), |
| 283 | button: None, |
| 284 | modifiers: self.translate_modifiers(e.state), |
| 285 | })), |
| 286 | Event::KeyPress(e) => { |
| 287 | let key = self.translate_keycode(e.detail); |
| 288 | Some(InputEvent::Key(KeyEvent { |
| 289 | key, |
| 290 | keycode: e.detail, |
| 291 | pressed: true, |
| 292 | modifiers: self.translate_modifiers(e.state), |
| 293 | })) |
| 294 | } |
| 295 | Event::KeyRelease(e) => { |
| 296 | let key = self.translate_keycode(e.detail); |
| 297 | Some(InputEvent::Key(KeyEvent { |
| 298 | key, |
| 299 | keycode: e.detail, |
| 300 | pressed: false, |
| 301 | modifiers: self.translate_modifiers(e.state), |
| 302 | })) |
| 303 | } |
| 304 | Event::Expose(_) => Some(InputEvent::Expose), |
| 305 | _ => None, |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | /// Translate X11 key modifiers. |
| 310 | fn translate_modifiers(&self, state: xproto::KeyButMask) -> Modifiers { |
| 311 | Modifiers { |
| 312 | shift: state.contains(xproto::KeyButMask::SHIFT), |
| 313 | ctrl: state.contains(xproto::KeyButMask::CONTROL), |
| 314 | alt: state.contains(xproto::KeyButMask::MOD1), |
| 315 | super_key: state.contains(xproto::KeyButMask::MOD4), |
| 316 | caps_lock: state.contains(xproto::KeyButMask::LOCK), |
| 317 | num_lock: state.contains(xproto::KeyButMask::MOD2), |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | /// Translate X11 keycode to Key. |
| 322 | fn translate_keycode(&self, keycode: u8) -> Key { |
| 323 | // Common keycodes (evdev-based) |
| 324 | match keycode { |
| 325 | 9 => Key::Escape, |
| 326 | 36 => Key::Return, |
| 327 | 22 => Key::Backspace, |
| 328 | 65 => Key::Space, |
| 329 | 104 => Key::Return, // Numpad enter |
| 330 | |
| 331 | // Letters (a-z: keycodes 38-58 approximately) |
| 332 | 38 => Key::Char('a'), |
| 333 | 56 => Key::Char('b'), |
| 334 | 54 => Key::Char('c'), |
| 335 | 40 => Key::Char('d'), |
| 336 | 26 => Key::Char('e'), |
| 337 | 41 => Key::Char('f'), |
| 338 | 42 => Key::Char('g'), |
| 339 | 43 => Key::Char('h'), |
| 340 | 31 => Key::Char('i'), |
| 341 | 44 => Key::Char('j'), |
| 342 | 45 => Key::Char('k'), |
| 343 | 46 => Key::Char('l'), |
| 344 | 58 => Key::Char('m'), |
| 345 | 57 => Key::Char('n'), |
| 346 | 32 => Key::Char('o'), |
| 347 | 33 => Key::Char('p'), |
| 348 | 24 => Key::Char('q'), |
| 349 | 27 => Key::Char('r'), |
| 350 | 39 => Key::Char('s'), |
| 351 | 28 => Key::Char('t'), |
| 352 | 30 => Key::Char('u'), |
| 353 | 55 => Key::Char('v'), |
| 354 | 25 => Key::Char('w'), |
| 355 | 53 => Key::Char('x'), |
| 356 | 29 => Key::Char('y'), |
| 357 | 52 => Key::Char('z'), |
| 358 | |
| 359 | // Numbers |
| 360 | 10 => Key::Char('1'), |
| 361 | 11 => Key::Char('2'), |
| 362 | 12 => Key::Char('3'), |
| 363 | 13 => Key::Char('4'), |
| 364 | 14 => Key::Char('5'), |
| 365 | 15 => Key::Char('6'), |
| 366 | 16 => Key::Char('7'), |
| 367 | 17 => Key::Char('8'), |
| 368 | 18 => Key::Char('9'), |
| 369 | 19 => Key::Char('0'), |
| 370 | |
| 371 | // Special characters |
| 372 | 20 => Key::Char('-'), |
| 373 | 21 => Key::Char('='), |
| 374 | |
| 375 | _ => Key::Unknown(keycode), |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | /// Handle an input event. |
| 380 | fn handle_event(&mut self, event: InputEvent) -> Result<bool> { |
| 381 | let mut needs_redraw = false; |
| 382 | |
| 383 | // If color picker is open, route events to it first |
| 384 | if let Some(ref mut picker) = self.color_picker { |
| 385 | match picker.handle_event(&event) { |
| 386 | ColorPickerResult::Confirm(color) => { |
| 387 | self.state.properties.color = color; |
| 388 | self.color_picker = None; |
| 389 | self.color_picker_surface = None; |
| 390 | return Ok(true); |
| 391 | } |
| 392 | ColorPickerResult::Cancel => { |
| 393 | self.color_picker = None; |
| 394 | self.color_picker_surface = None; |
| 395 | return Ok(true); |
| 396 | } |
| 397 | ColorPickerResult::StartEyedropper => { |
| 398 | // Close color picker and start eyedropper |
| 399 | let original_color = picker.current_color(); |
| 400 | self.color_picker = None; |
| 401 | self.color_picker_surface = None; |
| 402 | |
| 403 | // Run eyedropper (this blocks until user picks or cancels) |
| 404 | match self.run_eyedropper()? { |
| 405 | Some(color) => { |
| 406 | self.state.properties.color = color; |
| 407 | } |
| 408 | None => { |
| 409 | // Cancelled - reopen picker with original color |
| 410 | self.open_color_picker(original_color)?; |
| 411 | } |
| 412 | } |
| 413 | return Ok(true); |
| 414 | } |
| 415 | ColorPickerResult::Changed => { |
| 416 | // Color changed, redraw picker only |
| 417 | self.redraw_color_picker()?; |
| 418 | return Ok(false); // Don't redraw the whole overlay |
| 419 | } |
| 420 | ColorPickerResult::None => { |
| 421 | // No change, no redraw needed |
| 422 | return Ok(false); |
| 423 | } |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | match &event { |
| 428 | // Handle toolbar clicks |
| 429 | InputEvent::MousePress(e) if e.position.y < 0 => { |
| 430 | // Click is in toolbar area (y < 0 because of offset) |
| 431 | let toolbar_pos = Point::new(e.position.x, e.position.y + self.image_offset_y); |
| 432 | match self.toolbar.handle_click(toolbar_pos) { |
| 433 | ToolbarClickResult::Tool(tool_type) => { |
| 434 | self.select_tool(tool_type)?; |
| 435 | needs_redraw = true; |
| 436 | } |
| 437 | ToolbarClickResult::ColorPreview => { |
| 438 | self.open_color_picker(self.state.properties.color)?; |
| 439 | needs_redraw = true; |
| 440 | } |
| 441 | ToolbarClickResult::None => {} |
| 442 | } |
| 443 | return Ok(needs_redraw); |
| 444 | } |
| 445 | |
| 446 | // Handle keyboard shortcuts |
| 447 | InputEvent::Key(e) if e.pressed => { |
| 448 | // Ctrl+Enter: Save |
| 449 | if e.modifiers.ctrl && e.key == Key::Return { |
| 450 | self.save()?; |
| 451 | return Ok(false); |
| 452 | } |
| 453 | |
| 454 | // Escape: Cancel (if not in text editing mode) |
| 455 | if e.key == Key::Escape && !self.tool.is_drawing() { |
| 456 | self.state.finish_cancel(); |
| 457 | return Ok(false); |
| 458 | } |
| 459 | |
| 460 | // Ctrl+Z: Undo |
| 461 | if e.modifiers.ctrl && e.key == Key::Char('z') { |
| 462 | if e.modifiers.shift { |
| 463 | self.redo()?; |
| 464 | } else { |
| 465 | self.undo()?; |
| 466 | } |
| 467 | needs_redraw = true; |
| 468 | return Ok(needs_redraw); |
| 469 | } |
| 470 | |
| 471 | // Ctrl+Y: Redo |
| 472 | if e.modifiers.ctrl && e.key == Key::Char('y') { |
| 473 | self.redo()?; |
| 474 | needs_redraw = true; |
| 475 | return Ok(needs_redraw); |
| 476 | } |
| 477 | |
| 478 | // Tool shortcuts (only when not editing text) |
| 479 | if !self.tool.is_drawing() { |
| 480 | if let Key::Char(c) = e.key { |
| 481 | // Number keys for color presets |
| 482 | if let Some(digit) = c.to_digit(10) { |
| 483 | if digit >= 1 && digit <= 9 { |
| 484 | self.state.set_color_preset((digit - 1) as usize); |
| 485 | needs_redraw = true; |
| 486 | return Ok(needs_redraw); |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | // Tool shortcuts |
| 491 | if let Some(tool_type) = ToolType::from_shortcut(c) { |
| 492 | self.select_tool(tool_type)?; |
| 493 | needs_redraw = true; |
| 494 | return Ok(needs_redraw); |
| 495 | } |
| 496 | |
| 497 | // Line width adjustment |
| 498 | if c == '+' || c == '=' { |
| 499 | self.state.properties.increase_line_width(); |
| 500 | needs_redraw = true; |
| 501 | return Ok(needs_redraw); |
| 502 | } |
| 503 | if c == '-' { |
| 504 | self.state.properties.decrease_line_width(); |
| 505 | needs_redraw = true; |
| 506 | return Ok(needs_redraw); |
| 507 | } |
| 508 | } |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // Handle expose events (window needs redraw) |
| 513 | InputEvent::Expose => { |
| 514 | return Ok(true); |
| 515 | } |
| 516 | |
| 517 | _ => {} |
| 518 | } |
| 519 | |
| 520 | // Pass event to current tool |
| 521 | let tool_needs_redraw = self.tool.handle_event(&event, &self.state.properties); |
| 522 | needs_redraw |= tool_needs_redraw; |
| 523 | |
| 524 | // Check if tool completed a drawing |
| 525 | if let InputEvent::MouseRelease(_) = &event { |
| 526 | if self.tool.can_commit() && !self.tool.is_drawing() { |
| 527 | self.commit_tool()?; |
| 528 | needs_redraw = true; |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | Ok(needs_redraw) |
| 533 | } |
| 534 | |
| 535 | /// Select a tool. |
| 536 | fn select_tool(&mut self, tool_type: ToolType) -> Result<()> { |
| 537 | // Commit current tool if it has a pending drawing |
| 538 | if self.tool.can_commit() { |
| 539 | self.commit_tool()?; |
| 540 | } |
| 541 | |
| 542 | self.state.select_tool(tool_type); |
| 543 | self.tool = tools::create_tool(tool_type); |
| 544 | self.update_cursor()?; |
| 545 | |
| 546 | Ok(()) |
| 547 | } |
| 548 | |
| 549 | /// Commit the current tool's drawing to the canvas. |
| 550 | fn commit_tool(&mut self) -> Result<()> { |
| 551 | // Save snapshot for undo |
| 552 | let snapshot = Snapshot::new(self.canvas.snapshot_annotations()?); |
| 553 | self.history.push(snapshot); |
| 554 | |
| 555 | // Commit tool drawing to annotations layer |
| 556 | let ctx = self.canvas.annotations_surface().context()?; |
| 557 | self.tool.commit(&ctx, &self.state.properties); |
| 558 | |
| 559 | // Reset tool |
| 560 | self.tool.reset(); |
| 561 | |
| 562 | Ok(()) |
| 563 | } |
| 564 | |
| 565 | /// Undo last action. |
| 566 | fn undo(&mut self) -> Result<()> { |
| 567 | let current = Snapshot::new(self.canvas.snapshot_annotations()?); |
| 568 | if let Some(previous) = self.history.undo(current) { |
| 569 | self.canvas.restore_annotations(&previous.data)?; |
| 570 | } |
| 571 | Ok(()) |
| 572 | } |
| 573 | |
| 574 | /// Redo last undone action. |
| 575 | fn redo(&mut self) -> Result<()> { |
| 576 | let current = Snapshot::new(self.canvas.snapshot_annotations()?); |
| 577 | if let Some(next) = self.history.redo(current) { |
| 578 | self.canvas.restore_annotations(&next.data)?; |
| 579 | } |
| 580 | Ok(()) |
| 581 | } |
| 582 | |
| 583 | /// Save the annotated image. |
| 584 | fn save(&mut self) -> Result<()> { |
| 585 | // Commit any pending tool drawing |
| 586 | if self.tool.can_commit() { |
| 587 | self.commit_tool()?; |
| 588 | } |
| 589 | |
| 590 | // Export final image |
| 591 | let data = self.canvas.export()?; |
| 592 | let width = self.canvas.width(); |
| 593 | let height = self.canvas.height(); |
| 594 | |
| 595 | self.state.finish_save(data, width, height); |
| 596 | |
| 597 | Ok(()) |
| 598 | } |
| 599 | |
| 600 | /// Update cursor for current tool. |
| 601 | fn update_cursor(&mut self) -> Result<()> { |
| 602 | let shape = self.tool.cursor(); |
| 603 | self.cursor_manager.set_window_cursor(self.window.id(), shape)?; |
| 604 | Ok(()) |
| 605 | } |
| 606 | |
| 607 | /// Open the color picker dialog. |
| 608 | fn open_color_picker(&mut self, initial_color: gartk_core::Color) -> Result<()> { |
| 609 | use crate::annotate::ui::{PICKER_HEIGHT, PICKER_WIDTH}; |
| 610 | |
| 611 | let screen_width = self.canvas.width(); |
| 612 | let screen_height = self.canvas.height() + TOOLBAR_HEIGHT; |
| 613 | |
| 614 | let picker = ColorPicker::new(initial_color, screen_width, screen_height); |
| 615 | |
| 616 | // Create surface for color picker if needed |
| 617 | if self.color_picker_surface.is_none() { |
| 618 | self.color_picker_surface = Some( |
| 619 | Surface::new(PICKER_WIDTH, PICKER_HEIGHT) |
| 620 | .context("Failed to create color picker surface")?, |
| 621 | ); |
| 622 | } |
| 623 | |
| 624 | self.color_picker = Some(picker); |
| 625 | Ok(()) |
| 626 | } |
| 627 | |
| 628 | /// Redraw just the color picker (not the whole overlay). |
| 629 | fn redraw_color_picker(&mut self) -> Result<()> { |
| 630 | if let (Some(picker), Some(surface)) = (&self.color_picker, &self.color_picker_surface) { |
| 631 | picker.draw(surface)?; |
| 632 | self.blit_color_picker()?; |
| 633 | } |
| 634 | Ok(()) |
| 635 | } |
| 636 | |
| 637 | /// Run the eyedropper to sample a color. |
| 638 | fn run_eyedropper(&mut self) -> Result<Option<gartk_core::Color>> { |
| 639 | use crate::annotate::ui::color_picker::eyedropper::{Eyedropper, EyedropperResult}; |
| 640 | |
| 641 | let eyedropper = Eyedropper::new()?; |
| 642 | let result = eyedropper.run()?; |
| 643 | |
| 644 | // Restore focus to our window after eyedropper closes |
| 645 | self.window.focus()?; |
| 646 | |
| 647 | match result { |
| 648 | EyedropperResult::Color(color) => Ok(Some(color)), |
| 649 | EyedropperResult::Cancel => Ok(None), |
| 650 | } |
| 651 | } |
| 652 | |
| 653 | /// Redraw the overlay. |
| 654 | fn redraw(&mut self) -> Result<()> { |
| 655 | // Draw current tool preview |
| 656 | self.canvas.clear_preview()?; |
| 657 | let preview_ctx = self.canvas.preview_surface().context()?; |
| 658 | self.tool.draw_preview(&preview_ctx, &self.state.properties); |
| 659 | |
| 660 | // Render canvas layers |
| 661 | self.canvas.render()?; |
| 662 | |
| 663 | // Update toolbar state |
| 664 | self.toolbar |
| 665 | .update(self.state.current_tool, &self.state.properties); |
| 666 | |
| 667 | // Blit to window (toolbar + image) |
| 668 | self.blit_to_window()?; |
| 669 | |
| 670 | // Draw color picker if open |
| 671 | if let (Some(picker), Some(surface)) = (&self.color_picker, &self.color_picker_surface) { |
| 672 | picker.draw(surface)?; |
| 673 | self.blit_color_picker()?; |
| 674 | } |
| 675 | |
| 676 | self.needs_redraw = false; |
| 677 | |
| 678 | Ok(()) |
| 679 | } |
| 680 | |
| 681 | /// Blit canvas and toolbar to window. |
| 682 | fn blit_to_window(&mut self) -> Result<()> { |
| 683 | let width = self.canvas.width(); |
| 684 | let height = self.canvas.height(); |
| 685 | |
| 686 | // Draw toolbar to reusable surface |
| 687 | self.toolbar.draw(&self.toolbar_surface)?; |
| 688 | |
| 689 | // Get toolbar data and convert to BGRA |
| 690 | let toolbar_data = self.toolbar_surface.to_rgba()?; |
| 691 | let toolbar_bgra = rgba_to_bgra(&toolbar_data); |
| 692 | |
| 693 | // Blit toolbar at y=0 (toolbar is small, no chunking needed) |
| 694 | self.conn.inner().put_image( |
| 695 | ImageFormat::Z_PIXMAP, |
| 696 | self.window.id(), |
| 697 | self.gc, |
| 698 | width as u16, |
| 699 | TOOLBAR_HEIGHT as u16, |
| 700 | 0, |
| 701 | 0, |
| 702 | 0, |
| 703 | 24, |
| 704 | &toolbar_bgra, |
| 705 | )?; |
| 706 | |
| 707 | // Get image composite data |
| 708 | let surface = self.canvas.composite_surface_mut(); |
| 709 | let data = surface.to_rgba()?; |
| 710 | let bgra = rgba_to_bgra(&data); |
| 711 | |
| 712 | // Blit image at y=TOOLBAR_HEIGHT using chunked put_image for large images |
| 713 | put_image_chunked( |
| 714 | self.conn.inner(), |
| 715 | self.window.id(), |
| 716 | self.gc, |
| 717 | width as u16, |
| 718 | height as u16, |
| 719 | 0, |
| 720 | self.image_offset_y as i16, |
| 721 | &bgra, |
| 722 | )?; |
| 723 | |
| 724 | self.conn.inner().flush()?; |
| 725 | |
| 726 | Ok(()) |
| 727 | } |
| 728 | |
| 729 | /// Blit color picker to window. |
| 730 | fn blit_color_picker(&mut self) -> Result<()> { |
| 731 | use crate::annotate::ui::{PICKER_HEIGHT, PICKER_WIDTH}; |
| 732 | |
| 733 | let picker = self.color_picker.as_ref().ok_or_else(|| anyhow::anyhow!("No color picker"))?; |
| 734 | let surface = self.color_picker_surface.as_mut().ok_or_else(|| anyhow::anyhow!("No color picker surface"))?; |
| 735 | |
| 736 | let rect = picker.rect(); |
| 737 | |
| 738 | // Get picker data and convert to BGRA |
| 739 | let data = surface.to_rgba()?; |
| 740 | let bgra = rgba_to_bgra(&data); |
| 741 | |
| 742 | // Blit at picker position (adjusted for toolbar offset) |
| 743 | self.conn.inner().put_image( |
| 744 | ImageFormat::Z_PIXMAP, |
| 745 | self.window.id(), |
| 746 | self.gc, |
| 747 | PICKER_WIDTH as u16, |
| 748 | PICKER_HEIGHT as u16, |
| 749 | rect.x as i16, |
| 750 | (rect.y + self.image_offset_y) as i16, |
| 751 | 0, |
| 752 | 24, |
| 753 | &bgra, |
| 754 | )?; |
| 755 | |
| 756 | self.conn.inner().flush()?; |
| 757 | |
| 758 | Ok(()) |
| 759 | } |
| 760 | |
| 761 | /// Set window to full opacity to prevent compositor transparency. |
| 762 | fn set_full_opacity(&self) -> Result<()> { |
| 763 | // Intern the _NET_WM_WINDOW_OPACITY atom |
| 764 | let opacity_atom = self.conn.inner() |
| 765 | .intern_atom(false, b"_NET_WM_WINDOW_OPACITY")? |
| 766 | .reply() |
| 767 | .context("Failed to intern opacity atom")? |
| 768 | .atom; |
| 769 | |
| 770 | // Full opacity = 0xFFFFFFFF (max u32) |
| 771 | let opacity: u32 = 0xFFFFFFFF; |
| 772 | |
| 773 | self.conn.inner().change_property32( |
| 774 | PropMode::REPLACE, |
| 775 | self.window.id(), |
| 776 | opacity_atom, |
| 777 | AtomEnum::CARDINAL, |
| 778 | &[opacity], |
| 779 | )?; |
| 780 | |
| 781 | self.conn.inner().flush()?; |
| 782 | Ok(()) |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | /// Convert RGBA to BGRA (X11 native format). |
| 787 | fn rgba_to_bgra(data: &[u8]) -> Vec<u8> { |
| 788 | let mut bgra = data.to_vec(); |
| 789 | for chunk in bgra.chunks_exact_mut(4) { |
| 790 | chunk.swap(0, 2); // Swap R and B |
| 791 | } |
| 792 | bgra |
| 793 | } |
| 794 | |
| 795 | /// Put image data in chunks to avoid exceeding X11 max request size. |
| 796 | fn put_image_chunked<C: X11Connection>( |
| 797 | conn: &C, |
| 798 | drawable: u32, |
| 799 | gc: u32, |
| 800 | width: u16, |
| 801 | height: u16, |
| 802 | dst_x: i16, |
| 803 | dst_y: i16, |
| 804 | data: &[u8], |
| 805 | ) -> Result<()> { |
| 806 | let bytes_per_row = width as usize * 4; |
| 807 | let rows_per_chunk = (MAX_PUT_IMAGE_BYTES / bytes_per_row).max(1); |
| 808 | |
| 809 | let mut y = 0u16; |
| 810 | while (y as usize) < height as usize { |
| 811 | let chunk_height = ((height as usize - y as usize).min(rows_per_chunk)) as u16; |
| 812 | let start = y as usize * bytes_per_row; |
| 813 | let end = start + chunk_height as usize * bytes_per_row; |
| 814 | let chunk_data = &data[start..end]; |
| 815 | |
| 816 | conn.put_image( |
| 817 | ImageFormat::Z_PIXMAP, |
| 818 | drawable, |
| 819 | gc, |
| 820 | width, |
| 821 | chunk_height, |
| 822 | dst_x, |
| 823 | dst_y + y as i16, |
| 824 | 0, |
| 825 | 24, |
| 826 | chunk_data, |
| 827 | )?; |
| 828 | |
| 829 | y += chunk_height; |
| 830 | } |
| 831 | |
| 832 | Ok(()) |
| 833 | } |
| 834 |