From 0656e49151ff4e8474d26e87cdfcabd9e88c35eb Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 31 May 2026 08:05:45 +0200 Subject: [PATCH] feat: export boards as PDF --- Cargo.toml | 2 +- README.md | 1 + config.example.toml | 1 + .../models/keybindings/field/config/read.rs | 1 + .../models/keybindings/field/config/write.rs | 1 + .../src/models/keybindings/field/labels.rs | 2 + .../src/models/keybindings/field/list.rs | 1 + .../src/models/keybindings/field/mod.rs | 1 + .../src/models/keybindings/field/tab.rs | 1 + configurator/src/models/keybindings/tests.rs | 26 +++ docs/CONFIG.md | 7 +- .../wayland/backend/event_loop/capture.rs | 6 + src/backend/wayland/state.rs | 9 +- src/backend/wayland/state/capture.rs | 200 ++++++++++++++++++ src/canvas_export.rs | 179 +++++++++++++++- src/capture/manager.rs | 20 +- src/capture/mod.rs | 3 +- src/capture/pipeline.rs | 74 ++++++- src/capture/tests/manager.rs | 116 +++++++++- src/capture/tests/perform_capture.rs | 173 ++++++++++++++- src/capture/types.rs | 34 +++ src/config/action_meta/entries/capture.rs | 11 + src/config/action_meta/tests.rs | 2 + src/config/keybindings/actions.rs | 1 + src/config/keybindings/config/map/capture.rs | 4 + .../config/types/bindings/capture.rs | 4 + src/config/keybindings/defaults/capture.rs | 4 + src/config/keybindings/tests.rs | 11 + .../state/actions/action_capture_zoom.rs | 9 + src/input/state/core/base/types.rs | 1 + src/input/state/core/command_palette/mod.rs | 46 ++++ src/input/state/core/utility/toasts.rs | 1 + src/input/state/interaction/actions.rs | 1 + src/input/state/tests/text_input/actions.rs | 14 ++ .../help_overlay/sections/builder/sections.rs | 4 + src/ui/help_overlay/sections/tests.rs | 1 + 36 files changed, 938 insertions(+), 34 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a10007c7..fc27d8b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ wayland-protocols-wlr = { version = "0.3", features = ["client"] } smithay-client-toolkit = { version = "0.20", default-features = false, features = ["calloop", "xkbcommon"] } # Cairo for drawing -cairo-rs = { version = "0.22", features = ["png"] } +cairo-rs = { version = "0.22", features = ["png", "pdf"] } # Pango for advanced text rendering and font support pango = "0.22" diff --git a/README.md b/README.md index 0dc6151a..b75c4f44 100644 --- a/README.md +++ b/README.md @@ -568,6 +568,7 @@ Press F1/F10 for help or Shift+F1 for quick ref | Ctrl+Alt+O | Open last capture folder | Requires `wl-clipboard`, `grim`, `slurp` (installed automatically by deb/rpm/AUR packages). Falls back to xdg-desktop-portal if missing. +Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, one PDF page per Wayscriber page, and is unbound by default. Use `--exit-after-capture` / `--no-exit-after-capture` to override whether the overlay closes after a capture. `--about` opens the About window. --- diff --git a/config.example.toml b/config.example.toml index 55b905a7..ef5445af 100644 --- a/config.example.toml +++ b/config.example.toml @@ -185,6 +185,7 @@ capture_file_region = ["Ctrl+Alt+6"] export_canvas_file = [] export_canvas_clipboard = [] export_canvas_clipboard_and_file = [] +export_board_pdf_file = [] # Open the most recent capture folder open_capture_folder = ["Ctrl+Alt+O"] diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index d4d553ad..cf86124f 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -111,6 +111,7 @@ impl KeybindingField { Self::ExportCanvasFile => &config.capture.export_canvas_file, Self::ExportCanvasClipboard => &config.capture.export_canvas_clipboard, Self::ExportCanvasClipboardAndFile => &config.capture.export_canvas_clipboard_and_file, + Self::ExportBoardPdfFile => &config.capture.export_board_pdf_file, Self::OpenCaptureFolder => &config.capture.open_capture_folder, Self::ToggleFrozenMode => &config.zoom.toggle_frozen_mode, Self::ZoomIn => &config.zoom.zoom_in, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 4c335f79..be308e61 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -114,6 +114,7 @@ impl KeybindingField { Self::ExportCanvasClipboardAndFile => { config.capture.export_canvas_clipboard_and_file = value; } + Self::ExportBoardPdfFile => config.capture.export_board_pdf_file = value, Self::OpenCaptureFolder => config.capture.open_capture_folder = value, Self::ToggleFrozenMode => config.zoom.toggle_frozen_mode = value, Self::ZoomIn => config.zoom.zoom_in = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index 90195412..409b2aa4 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -106,6 +106,7 @@ impl KeybindingField { Self::ExportCanvasFile => "Export canvas: file", Self::ExportCanvasClipboard => "Export canvas: clipboard", Self::ExportCanvasClipboardAndFile => "Export canvas: clipboard and file", + Self::ExportBoardPdfFile => "Export board: PDF file", Self::OpenCaptureFolder => "Open capture folder", Self::ToggleFrozenMode => "Toggle freeze", Self::ZoomIn => "Zoom in", @@ -238,6 +239,7 @@ impl KeybindingField { Self::ExportCanvasFile => "export_canvas_file", Self::ExportCanvasClipboard => "export_canvas_clipboard", Self::ExportCanvasClipboardAndFile => "export_canvas_clipboard_and_file", + Self::ExportBoardPdfFile => "export_board_pdf_file", Self::OpenCaptureFolder => "open_capture_folder", Self::ToggleFrozenMode => "toggle_frozen_mode", Self::ZoomIn => "zoom_in", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 0ad35b62..e5119e5f 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -106,6 +106,7 @@ impl KeybindingField { Self::ExportCanvasFile, Self::ExportCanvasClipboard, Self::ExportCanvasClipboardAndFile, + Self::ExportBoardPdfFile, Self::OpenCaptureFolder, Self::ToggleFrozenMode, Self::ZoomIn, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index efe4ea67..dbe4a4fa 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -93,6 +93,7 @@ pub enum KeybindingField { ExportCanvasFile, ExportCanvasClipboard, ExportCanvasClipboardAndFile, + ExportBoardPdfFile, OpenCaptureFolder, ToggleFrozenMode, ZoomIn, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index 9802b1bc..85ff5bb9 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -108,6 +108,7 @@ impl KeybindingField { | Self::ExportCanvasFile | Self::ExportCanvasClipboard | Self::ExportCanvasClipboardAndFile + | Self::ExportBoardPdfFile | Self::OpenCaptureFolder | Self::ToggleFrozenMode | Self::ZoomIn diff --git a/configurator/src/models/keybindings/tests.rs b/configurator/src/models/keybindings/tests.rs index c378be01..0b668b87 100644 --- a/configurator/src/models/keybindings/tests.rs +++ b/configurator/src/models/keybindings/tests.rs @@ -3,6 +3,7 @@ use wayscriber::config::keybindings::KeybindingsConfig; use super::draft::KeybindingsDraft; use super::field::KeybindingField; use super::parse::parse_keybinding_list; +use crate::models::KeybindingsTabId; #[test] fn parse_keybinding_list_trims_and_ignores_empty() { @@ -21,3 +22,28 @@ fn keybindings_draft_to_config_updates_fields() { vec!["Ctrl+Q".to_string(), "Escape".to_string()] ); } + +#[test] +fn board_pdf_export_keybinding_field_is_visible_and_in_capture_tab() { + assert!( + KeybindingField::all().contains(&KeybindingField::ExportBoardPdfFile), + "PDF export field should appear in ordered keybinding list" + ); + assert_eq!( + KeybindingField::ExportBoardPdfFile.tab(), + KeybindingsTabId::CaptureView + ); +} + +#[test] +fn board_pdf_export_keybinding_field_reads_and_writes_config() { + let mut config = KeybindingsConfig::default(); + assert!(KeybindingField::ExportBoardPdfFile.get(&config).is_empty()); + + KeybindingField::ExportBoardPdfFile.set(&mut config, vec!["Ctrl+Alt+P".to_string()]); + + assert_eq!( + config.capture.export_board_pdf_file, + vec!["Ctrl+Alt+P".to_string()] + ); +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 08f762f8..6d5c2ad9 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -709,9 +709,10 @@ mappings = [ - Set `apply_to_ui = false` to preview remapped canvas content while keeping screen-space UI text and controls in the normal theme. - Profiles do not recolor the compositor-owned live desktop seen through a transparent overlay. - Explicit canvas PNG export applies its resolved export profile to persisted Wayscriber canvas content only, uses the current panned board viewport, respects output scale, and excludes frozen/zoom desktop pixels. +- Board PDF export writes the active board to a file with one logical viewport-sized PDF page per Wayscriber page. PDF export preserves page order and solid board backgrounds, but does not apply export render profiles. - Explicit canvas export and its clipboard-failure fallback save PNG data as `.png`; screenshot clipboard fallback still uses `[capture].format`. -- `[capture].enabled` disables compositor screenshot capture actions, not explicit canvas export actions. -- PDF export is out of scope. +- `[capture].enabled` disables compositor screenshot capture actions, not explicit export actions. +- Board PDF export is file-only; clipboard PDF export is not supported yet. **Runtime actions:** - `render_profile_next` @@ -722,6 +723,7 @@ mappings = [ - `export_canvas_file` - `export_canvas_clipboard` - `export_canvas_clipboard_and_file` +- `export_board_pdf_file` ### `[capture]` - Screenshot Capture @@ -1023,6 +1025,7 @@ capture_file_region = ["Ctrl+Alt+6"] export_canvas_file = [] export_canvas_clipboard = [] export_canvas_clipboard_and_file = [] +export_board_pdf_file = [] # Open the most recent capture folder open_capture_folder = ["Ctrl+Alt+O"] diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 78610c09..b21e30aa 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -36,6 +36,9 @@ pub(super) fn handle_pending_actions( match action { PendingBackendAction::Screenshot(action) => state.handle_capture_action(action), PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action), + PendingBackendAction::BoardPdfExport(action) => { + state.handle_board_pdf_export_action(action); + } } } if let Some(action) = state.input_state.take_pending_output_focus_action() { @@ -171,6 +174,9 @@ fn handle_capture_results(state: &mut WaylandState) { crate::capture::ImageOperationKind::CanvasExport => { "Canvas exported".to_string() } + crate::capture::ImageOperationKind::BoardPdfExport => { + "Board exported".to_string() + } } } else { message_parts.join(" - ") diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index d4823f5a..b7f33a5a 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -45,12 +45,13 @@ use crate::input::tablet::TabletSettings; use crate::{ backend::ExitAfterCaptureMode, canvas_export::{ - BoardExportSnapshot, CanvasExportBackdropSnapshot, CanvasExportSnapshot, - CanvasExportViewport, render_canvas_png, + BoardExportSnapshot, BoardPdfExportSnapshot, CanvasExportBackdropSnapshot, + CanvasExportSnapshot, CanvasExportViewport, CanvasPageExportSnapshot, render_board_pdf, + render_canvas_png, }, capture::{ - CaptureDestination, CaptureManager, ImageDeliveryRequest, ImageFormatMetadata, - ImageOperationKind, + CaptureDestination, CaptureManager, DocumentDeliveryRequest, ImageDeliveryRequest, + ImageFormatMetadata, ImageOperationKind, RenderedDocument, file::{FileSaveConfig, expand_tilde}, types::CaptureType, }, diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 4d4cd870..1b1d8c3c 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -214,6 +214,70 @@ impl WaylandState { } } + pub(in crate::backend::wayland) fn handle_board_pdf_export_action(&mut self, action: Action) { + if self.capture.is_in_progress() { + log::warn!( + "Board PDF export action {:?} requested while another image operation is running; ignoring", + action + ); + return; + } + + if !matches!(action, Action::ExportBoardPdfFile) { + log::error!( + "Non-board-PDF-export action passed to handle_board_pdf_export_action: {:?}", + action + ); + return; + } + + let snapshot = self.board_pdf_export_snapshot(); + let bytes = match render_board_pdf(&snapshot) { + Ok(bytes) => bytes, + Err(err) => { + let message = ImageOperationKind::BoardPdfExport.format_error(&err); + log::error!("Board PDF export failed: {}", message); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, message); + return; + } + }; + + let destination = CaptureDestination::FileOnly; + self.capture + .set_exit_on_success(self.should_exit_after_capture(destination)); + self.capture.mark_in_progress(); + + let request = DocumentDeliveryRequest { + document: RenderedDocument { + bytes, + extension: "pdf".to_string(), + mime_type: "application/pdf".to_string(), + }, + destination, + save_config: Some(FileSaveConfig { + save_directory: expand_tilde(&self.config.capture.save_directory), + filename_template: self.config.capture.filename_template.clone(), + format: "pdf".to_string(), + }), + operation: ImageOperationKind::BoardPdfExport, + }; + + if let Err(err) = self + .capture + .manager_mut() + .request_document_delivery(request) + { + log::error!("Failed to request board PDF export delivery: {}", err); + self.capture.clear_in_progress(); + self.capture.clear_exit_on_success(); + self.input_state.set_ui_toast( + crate::input::state::UiToastKind::Error, + format!("Board PDF export failed: {err}"), + ); + } + } + fn canvas_export_snapshot(&self) -> CanvasExportSnapshot { let (origin_x, origin_y) = self.board_view_offset(); CanvasExportSnapshot { @@ -243,6 +307,16 @@ impl WaylandState { } } + fn board_pdf_export_snapshot(&self) -> BoardPdfExportSnapshot { + build_board_pdf_export_snapshot( + self.surface.width(), + self.surface.height(), + self.input_state.boards.active_background(), + self.input_state.boards.pan_enabled(), + self.input_state.boards.active_pages().pages(), + ) + } + pub(in crate::backend::wayland) fn begin_pending_capture(&mut self, request: CaptureRequest) { log::info!("Requesting {:?} capture", request.capture_type); if let Err(e) = self.capture.manager_mut().request_capture( @@ -260,3 +334,129 @@ impl WaylandState { } } } + +fn build_board_pdf_export_snapshot( + logical_width: u32, + logical_height: u32, + background: &crate::input::BoardBackground, + pan_enabled: bool, + frames: &[crate::draw::Frame], +) -> BoardPdfExportSnapshot { + let backdrop = match background { + crate::input::BoardBackground::Transparent => CanvasExportBackdropSnapshot::Transparent, + crate::input::BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::Solid(*color), + }; + let use_page_offsets = pan_enabled && !background.is_transparent(); + + let mut pages = frames + .iter() + .map(|frame| { + let (origin_x, origin_y) = if use_page_offsets { + frame.view_offset() + } else { + (0, 0) + }; + CanvasPageExportSnapshot { + frame: frame.clone_without_history(), + backdrop: backdrop.clone(), + viewport_width: logical_width, + viewport_height: logical_height, + origin_x, + origin_y, + } + }) + .collect::>(); + + if pages.is_empty() { + pages.push(CanvasPageExportSnapshot { + frame: crate::draw::Frame::new(), + backdrop, + viewport_width: logical_width, + viewport_height: logical_height, + origin_x: 0, + origin_y: 0, + }); + } + + BoardPdfExportSnapshot { + logical_width, + logical_height, + pages, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::{Frame, WHITE}; + use crate::input::BoardBackground; + + #[test] + fn board_pdf_snapshot_preserves_page_order() { + let mut first = Frame::new(); + first.set_page_name(Some("first".to_string())); + let mut second = Frame::new(); + second.set_page_name(Some("second".to_string())); + + let snapshot = build_board_pdf_export_snapshot( + 800, + 600, + &BoardBackground::Solid(WHITE), + true, + &[first, second], + ); + + assert_eq!(snapshot.pages.len(), 2); + assert_eq!(snapshot.pages[0].frame.page_name(), Some("first")); + assert_eq!(snapshot.pages[1].frame.page_name(), Some("second")); + } + + #[test] + fn board_pdf_snapshot_uses_per_page_view_offset_for_solid_pannable_boards() { + let mut first = Frame::new(); + assert!(first.set_view_offset(100, -50)); + let mut second = Frame::new(); + assert!(second.set_view_offset(-25, 40)); + + let snapshot = build_board_pdf_export_snapshot( + 800, + 600, + &BoardBackground::Solid(WHITE), + true, + &[first, second], + ); + + assert_eq!(snapshot.pages[0].origin_x, 100); + assert_eq!(snapshot.pages[0].origin_y, -50); + assert_eq!(snapshot.pages[1].origin_x, -25); + assert_eq!(snapshot.pages[1].origin_y, 40); + } + + #[test] + fn board_pdf_snapshot_forces_origin_for_transparent_boards() { + let mut frame = Frame::new(); + assert!(frame.set_view_offset(100, -50)); + + let snapshot = build_board_pdf_export_snapshot( + 800, + 600, + &BoardBackground::Transparent, + true, + &[frame], + ); + + assert_eq!(snapshot.pages[0].origin_x, 0); + assert_eq!(snapshot.pages[0].origin_y, 0); + } + + #[test] + fn board_pdf_snapshot_normalizes_empty_frame_list_to_blank_page() { + let snapshot = + build_board_pdf_export_snapshot(800, 600, &BoardBackground::Solid(WHITE), true, &[]); + + assert_eq!(snapshot.pages.len(), 1); + assert!(snapshot.pages[0].frame.is_empty()); + assert_eq!(snapshot.pages[0].viewport_width, 800); + assert_eq!(snapshot.pages[0].viewport_height, 600); + } +} diff --git a/src/canvas_export.rs b/src/canvas_export.rs index da18b90a..caedc4d5 100644 --- a/src/canvas_export.rs +++ b/src/canvas_export.rs @@ -16,6 +16,23 @@ pub struct CanvasExportSnapshot { pub render_profile: Option, } +#[derive(Debug, Clone)] +pub struct CanvasPageExportSnapshot { + pub frame: Frame, + pub backdrop: CanvasExportBackdropSnapshot, + pub viewport_width: u32, + pub viewport_height: u32, + pub origin_x: i32, + pub origin_y: i32, +} + +#[derive(Debug, Clone)] +pub struct BoardPdfExportSnapshot { + pub logical_width: u32, + pub logical_height: u32, + pub pages: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CanvasExportViewport { pub logical_width: u32, @@ -90,8 +107,8 @@ fn render_canvas_surface( CaptureError::ImageError(format!("Failed to create canvas context: {err}")) })?; - let backdrop = ExportBackdrop::new(&snapshot.backdrop)?; - draw_canvas_snapshot(&ctx, snapshot, &backdrop, scale); + let page = canvas_page_from_snapshot(snapshot); + draw_canvas_page(&ctx, &page, scale as f64)?; } if let Some(profile) = snapshot.render_profile.as_ref() { @@ -122,24 +139,88 @@ fn render_canvas_surface( Ok(surface) } -fn draw_canvas_snapshot( +pub fn draw_canvas_page( ctx: &cairo::Context, - snapshot: &CanvasExportSnapshot, + page: &CanvasPageExportSnapshot, + output_scale: f64, +) -> Result<(), CaptureError> { + let backdrop = ExportBackdrop::new(&page.backdrop)?; + draw_canvas_page_contents(ctx, page, &backdrop, output_scale); + Ok(()) +} + +pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, CaptureError> { + if snapshot.logical_width == 0 || snapshot.logical_height == 0 { + return Err(CaptureError::ImageError( + "Board PDF export requires a configured non-empty surface".to_string(), + )); + } + if snapshot.pages.is_empty() { + return Err(CaptureError::ImageError( + "Board PDF export requires at least one page".to_string(), + )); + } + + let width = snapshot.logical_width as f64; + let height = snapshot.logical_height as f64; + let surface = cairo::PdfSurface::for_stream(width, height, Vec::::new()) + .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF surface: {err}")))?; + let ctx = cairo::Context::new(&surface) + .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF context: {err}")))?; + + for page in &snapshot.pages { + surface.set_size(width, height).map_err(|err| { + CaptureError::ImageError(format!("Failed to set PDF page size: {err}")) + })?; + draw_canvas_page(&ctx, page, 1.0)?; + ctx.show_page() + .map_err(|err| CaptureError::ImageError(format!("Failed to finish PDF page: {err}")))?; + } + + drop(ctx); + + let stream = surface + .finish_output_stream() + .map_err(|err| CaptureError::ImageError(format!("Failed to finish PDF output: {err}")))?; + let bytes = stream.downcast::>().map_err(|_| { + CaptureError::ImageError("PDF output stream had unexpected type".to_string()) + })?; + Ok(*bytes) +} + +fn canvas_page_from_snapshot(snapshot: &CanvasExportSnapshot) -> CanvasPageExportSnapshot { + CanvasPageExportSnapshot { + frame: snapshot.board.frame.clone_without_history(), + backdrop: snapshot.backdrop.clone(), + viewport_width: snapshot.viewport.logical_width, + viewport_height: snapshot.viewport.logical_height, + origin_x: snapshot.viewport.origin_x, + origin_y: snapshot.viewport.origin_y, + } +} + +fn draw_canvas_page_contents( + ctx: &cairo::Context, + page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, - scale: i32, + output_scale: f64, ) { let _ = ctx.save(); - if scale > 1 { - ctx.scale(scale as f64, scale as f64); + if (output_scale - 1.0).abs() > f64::EPSILON { + ctx.scale(output_scale, output_scale); } - ctx.translate( - -(snapshot.viewport.origin_x as f64), - -(snapshot.viewport.origin_y as f64), + ctx.rectangle( + 0.0, + 0.0, + page.viewport_width as f64, + page.viewport_height as f64, ); + ctx.clip(); + ctx.translate(-(page.origin_x as f64), -(page.origin_y as f64)); backdrop.paint(ctx); let replay_ctx = backdrop.replay_context(); - for drawn_shape in &snapshot.board.frame.shapes { + for drawn_shape in &page.frame.shapes { match &drawn_shape.shape { Shape::EraserStroke { points, brush } => { render_eraser_stroke(ctx, points, brush, &replay_ctx); @@ -327,6 +408,17 @@ mod tests { } } + fn page_snapshot(frame: Frame) -> CanvasPageExportSnapshot { + CanvasPageExportSnapshot { + frame, + backdrop: CanvasExportBackdropSnapshot::Transparent, + viewport_width: 20, + viewport_height: 20, + origin_x: 0, + origin_y: 0, + } + } + fn pixel(surface: &mut cairo::ImageSurface, x: i32, y: i32) -> u32 { surface.flush(); let stride = surface.stride() as usize; @@ -392,6 +484,71 @@ mod tests { assert_eq!(pixel(&mut surface, 0, 0), 0); } + #[test] + fn draw_canvas_page_uses_explicit_output_scale() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 4, + y: 4, + w: 2, + h: 2, + fill: true, + color: RED, + thick: 1.0, + }); + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + draw_canvas_page(&ctx, &page_snapshot(frame), 2.0).expect("draw"); + } + + assert_ne!(pixel(&mut surface, 9, 9), 0); + assert_eq!(pixel(&mut surface, 1, 1), 0); + } + + #[test] + fn render_board_pdf_returns_pdf_bytes() { + let pdf = render_board_pdf(&BoardPdfExportSnapshot { + logical_width: 64, + logical_height: 48, + pages: vec![page_snapshot(Frame::new())], + }) + .expect("pdf"); + + assert!(pdf.starts_with(b"%PDF-")); + } + + #[test] + fn render_board_pdf_rejects_zero_dimensions() { + let err = render_board_pdf(&BoardPdfExportSnapshot { + logical_width: 0, + logical_height: 48, + pages: vec![page_snapshot(Frame::new())], + }) + .expect_err("zero width should fail"); + + assert!( + err.to_string().contains("non-empty surface"), + "unexpected error: {err}" + ); + } + + #[test] + fn render_board_pdf_rejects_empty_pages() { + let err = render_board_pdf(&BoardPdfExportSnapshot { + logical_width: 64, + logical_height: 48, + pages: Vec::new(), + }) + .expect_err("empty pages should fail"); + + assert!( + err.to_string().contains("at least one page"), + "unexpected error: {err}" + ); + } + #[test] fn export_applies_cloned_profile_to_pixels() { let mut frame = Frame::new(); diff --git a/src/capture/manager.rs b/src/capture/manager.rs index fedd82e2..9abe5824 100644 --- a/src/capture/manager.rs +++ b/src/capture/manager.rs @@ -5,10 +5,12 @@ use tokio::sync::{Mutex, mpsc}; use crate::capture::{ dependencies::CaptureDependencies, file::FileSaveConfig, - pipeline::{CaptureManagerRequest, CaptureRequest, deliver_image, perform_capture}, + pipeline::{ + CaptureManagerRequest, CaptureRequest, deliver_document, deliver_image, perform_capture, + }, types::{ CaptureDestination, CaptureError, CaptureOutcome, CaptureStatus, CaptureType, - ImageDeliveryRequest, + DocumentDeliveryRequest, ImageDeliveryRequest, }, }; @@ -67,6 +69,9 @@ impl CaptureManager { CaptureManagerRequest::DeliverImage(request) => { deliver_image(request, deps_clone.clone()).await } + CaptureManagerRequest::DeliverDocument(request) => { + deliver_document(request, deps_clone.clone()).await + } }; match outcome { @@ -140,6 +145,17 @@ impl CaptureManager { Ok(()) } + pub fn request_document_delivery( + &self, + request: DocumentDeliveryRequest, + ) -> Result<(), CaptureError> { + self.request_tx + .send(CaptureManagerRequest::DeliverDocument(request)) + .map_err(|_| CaptureError::ImageError("Capture manager not running".to_string()))?; + + Ok(()) + } + /// Get the current capture status. #[allow(dead_code)] // Will be used in Phase 2 for status UI pub async fn get_status(&self) -> CaptureStatus { diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 60c29935..348ce177 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -26,5 +26,6 @@ pub(crate) use pipeline::CaptureRequest; #[allow(unused_imports)] pub use types::{ CaptureDestination, CaptureError, CaptureOutcome, CaptureResult, CaptureStatus, CaptureType, - ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage, + DocumentDeliveryRequest, ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, + RenderedDocument, RenderedImage, }; diff --git a/src/capture/pipeline.rs b/src/capture/pipeline.rs index 6ccb57cd..cd4ca716 100644 --- a/src/capture/pipeline.rs +++ b/src/capture/pipeline.rs @@ -4,8 +4,8 @@ use crate::capture::{ dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver}, file::FileSaveConfig, types::{ - CaptureDestination, CaptureError, CaptureResult, CaptureType, ImageDeliveryRequest, - ImageOperationKind, + CaptureDestination, CaptureError, CaptureResult, CaptureType, DocumentDeliveryRequest, + ImageDeliveryRequest, ImageOperationKind, }, }; use tokio::task; @@ -37,6 +37,7 @@ impl fmt::Debug for CaptureRequest { pub(crate) enum CaptureManagerRequest { Capture(CaptureRequest), DeliverImage(ImageDeliveryRequest), + DeliverDocument(DocumentDeliveryRequest), } impl CaptureManagerRequest { @@ -44,6 +45,7 @@ impl CaptureManagerRequest { match self { Self::Capture(_) => ImageOperationKind::Screenshot, Self::DeliverImage(request) => request.operation, + Self::DeliverDocument(request) => request.operation, } } } @@ -60,6 +62,13 @@ impl fmt::Debug for CaptureManagerRequest { .field("height", &request.image.height) .field("format", &request.image.format) .finish(), + Self::DeliverDocument(request) => f + .debug_struct("DeliverDocument") + .field("destination", &request.destination) + .field("operation", &request.operation) + .field("extension", &request.document.extension) + .field("mime_type", &request.document.mime_type) + .finish(), } } } @@ -95,7 +104,7 @@ pub(crate) async fn perform_capture( if let Some(save_config) = request.save_config.clone() { if !save_config.save_directory.as_os_str().is_empty() { Some( - save_image( + save_bytes( Arc::clone(&dependencies.saver), image_data.clone(), save_config, @@ -112,7 +121,7 @@ pub(crate) async fn perform_capture( CaptureDestination::ClipboardAndFile => { if let Some(save_config) = request.save_config.clone() { if !save_config.save_directory.as_os_str().is_empty() { - match save_image( + match save_bytes( Arc::clone(&dependencies.saver), image_data.clone(), save_config, @@ -188,7 +197,7 @@ pub(crate) async fn deliver_image( if let Some(config) = save_config.filter(|config| !config.save_directory.as_os_str().is_empty()) { - Some(save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await?) + Some(save_bytes(Arc::clone(&dependencies.saver), image_data.clone(), config).await?) } else { None } @@ -197,7 +206,7 @@ pub(crate) async fn deliver_image( if let Some(config) = save_config.filter(|config| !config.save_directory.as_os_str().is_empty()) { - match save_image(Arc::clone(&dependencies.saver), image_data.clone(), config).await + match save_bytes(Arc::clone(&dependencies.saver), image_data.clone(), config).await { Ok(path) => Some(path), Err(err) => { @@ -240,12 +249,59 @@ pub(crate) async fn deliver_image( }) } -async fn save_image( +pub(crate) async fn deliver_document( + request: DocumentDeliveryRequest, + dependencies: Arc, +) -> Result { + log::info!( + "Starting document delivery: {:?} {} {} bytes", + request.operation, + request.document.mime_type, + request.document.bytes.len() + ); + + if !matches!(request.destination, CaptureDestination::FileOnly) { + return Err(CaptureError::ImageError( + "PDF clipboard export is not supported yet".to_string(), + )); + } + + let Some(mut save_config) = request.save_config else { + return Err(CaptureError::ImageError( + "Board PDF export requires file save configuration".to_string(), + )); + }; + + if save_config.save_directory.as_os_str().is_empty() { + return Err(CaptureError::ImageError( + "Board PDF export requires a save directory".to_string(), + )); + } + + save_config.format = request.document.extension.clone(); + let document_bytes = request.document.bytes; + let saved_path = save_bytes( + Arc::clone(&dependencies.saver), + document_bytes.clone(), + save_config, + ) + .await?; + + Ok(CaptureResult { + image_data: document_bytes, + operation: request.operation, + fallback_format_override: None, + saved_path: Some(saved_path), + copied_to_clipboard: false, + }) +} + +async fn save_bytes( saver: Arc, - image_data: Vec, + bytes: Vec, config: FileSaveConfig, ) -> Result { - task::spawn_blocking(move || saver.save(&image_data, &config)) + task::spawn_blocking(move || saver.save(&bytes, &config)) .await .map_err(|e| CaptureError::ImageError(format!("Save task failed: {}", e)))? } diff --git a/src/capture/tests/manager.rs b/src/capture/tests/manager.rs index 9ba30b4c..00b17c40 100644 --- a/src/capture/tests/manager.rs +++ b/src/capture/tests/manager.rs @@ -6,7 +6,8 @@ use std::{ use tokio::time::{Duration, sleep}; use crate::capture::{ - ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedImage, + DocumentDeliveryRequest, ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, + RenderedDocument, RenderedImage, dependencies::CaptureDependencies, file::FileSaveConfig, manager::CaptureManager, @@ -34,6 +35,14 @@ fn rendered_png(bytes: Vec) -> RenderedImage { } } +fn rendered_pdf(bytes: Vec) -> RenderedDocument { + RenderedDocument { + bytes, + extension: "pdf".to_string(), + mime_type: "application/pdf".to_string(), + } +} + #[tokio::test] async fn test_capture_manager_creation() { let manager = CaptureManager::new(&tokio::runtime::Handle::current()); @@ -214,6 +223,56 @@ async fn request_image_delivery_queues_manager_backed_path() { assert_eq!(manager.get_status().await, CaptureStatus::Success); } +#[tokio::test] +async fn request_document_delivery_reports_board_pdf_success() { + let captured_types = Arc::new(Mutex::new(Vec::new())); + let source = MockSource { + data: vec![99], + error: Arc::new(Mutex::new(None)), + captured_types: captured_types.clone(), + }; + let saver = MockSaver { + should_fail: false, + path: PathBuf::from("/tmp/board.pdf"), + calls: Arc::new(Mutex::new(0)), + }; + let saver_handle = saver.clone(); + let clipboard = MockClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + }; + let deps = CaptureDependencies { + source: Arc::new(source), + saver: Arc::new(saver), + clipboard: Arc::new(clipboard), + }; + let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps); + + manager + .request_document_delivery(DocumentDeliveryRequest { + document: rendered_pdf(b"%PDF-".to_vec()), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::BoardPdfExport, + }) + .unwrap(); + + let outcome = wait_for_manager_outcome(&manager).await; + + match outcome { + Some(CaptureOutcome::Success(result)) => { + assert_eq!(result.operation, ImageOperationKind::BoardPdfExport); + assert_eq!(result.image_data, b"%PDF-".to_vec()); + assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/board.pdf"))); + assert!(!result.copied_to_clipboard); + } + other => panic!("Expected success outcome, got {other:?}"), + } + assert_eq!(*saver_handle.calls.lock().unwrap(), 1); + assert!(captured_types.lock().unwrap().is_empty()); + assert_eq!(manager.get_status().await, CaptureStatus::Success); +} + #[tokio::test] async fn request_image_delivery_records_canvas_save_failure() { let captured_types = Arc::new(Mutex::new(Vec::new())); @@ -275,6 +334,61 @@ async fn request_image_delivery_records_canvas_save_failure() { )); } +#[tokio::test] +async fn request_document_delivery_records_board_pdf_save_failure() { + let captured_types = Arc::new(Mutex::new(Vec::new())); + let source = MockSource { + data: vec![99], + error: Arc::new(Mutex::new(None)), + captured_types: captured_types.clone(), + }; + let saver = MockSaver { + should_fail: true, + path: PathBuf::from("/tmp/board.pdf"), + calls: Arc::new(Mutex::new(0)), + }; + let saver_handle = saver.clone(); + let clipboard = MockClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + }; + let deps = CaptureDependencies { + source: Arc::new(source), + saver: Arc::new(saver), + clipboard: Arc::new(clipboard), + }; + let manager = CaptureManager::with_dependencies(&tokio::runtime::Handle::current(), deps); + + manager + .request_document_delivery(DocumentDeliveryRequest { + document: rendered_pdf(b"%PDF-".to_vec()), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::BoardPdfExport, + }) + .unwrap(); + + let outcome = wait_for_manager_outcome(&manager).await; + + match outcome { + Some(CaptureOutcome::Failed { operation, message }) => { + assert_eq!(operation, ImageOperationKind::BoardPdfExport); + assert!( + message.contains("Failed to save board PDF export"), + "unexpected failure message: {message}" + ); + } + other => panic!("Expected failure outcome, got {other:?}"), + } + assert_eq!(*saver_handle.calls.lock().unwrap(), 1); + assert!(captured_types.lock().unwrap().is_empty()); + assert!(matches!( + manager.get_status().await, + CaptureStatus::Failed(ref message) + if message.contains("Failed to save board PDF export") + )); +} + #[tokio::test] async fn request_image_delivery_preserves_clipboard_success_when_file_fails() { let captured_types = Arc::new(Mutex::new(Vec::new())); diff --git a/src/capture/tests/perform_capture.rs b/src/capture/tests/perform_capture.rs index 75763cdb..e31d10d9 100644 --- a/src/capture/tests/perform_capture.rs +++ b/src/capture/tests/perform_capture.rs @@ -6,10 +6,11 @@ use std::{ use crate::capture::{ dependencies::{CaptureClipboard, CaptureDependencies, CaptureFileSaver}, file::FileSaveConfig, - pipeline::{CaptureRequest, deliver_image, perform_capture}, + pipeline::{CaptureRequest, deliver_document, deliver_image, perform_capture}, types::{ - CaptureDestination, CaptureError, CaptureType, ImageDeliveryRequest, ImageFormatMetadata, - ImageOperationKind, RenderedImage, + CaptureDestination, CaptureError, CaptureType, DocumentDeliveryRequest, + ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedDocument, + RenderedImage, }, }; @@ -67,6 +68,14 @@ fn rendered_png(bytes: Vec) -> RenderedImage { } } +fn rendered_pdf(bytes: Vec) -> RenderedDocument { + RenderedDocument { + bytes, + extension: "pdf".to_string(), + mime_type: "application/pdf".to_string(), + } +} + #[tokio::test] async fn test_perform_capture_clipboard_only_success() { let source = MockSource { @@ -153,6 +162,164 @@ async fn deliver_image_file_only_saves_rendered_format_extension() { assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/canvas.png"))); } +#[tokio::test] +async fn deliver_document_file_only_saves_pdf_bytes_with_pdf_extension() { + let configs = Arc::new(Mutex::new(Vec::new())); + let saver = RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/board.pdf"), + calls: Arc::new(Mutex::new(0)), + configs: configs.clone(), + }; + let clipboard = RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }; + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(saver.clone()), + clipboard: Arc::new(clipboard.clone()), + }; + let request = DocumentDeliveryRequest { + document: rendered_pdf(b"%PDF-".to_vec()), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig { + format: "png".to_string(), + ..FileSaveConfig::default() + }), + operation: ImageOperationKind::BoardPdfExport, + }; + + let result = deliver_document(request, Arc::new(deps)).await.unwrap(); + + assert_eq!(result.operation, ImageOperationKind::BoardPdfExport); + assert_eq!(result.image_data, b"%PDF-".to_vec()); + assert_eq!(result.saved_path, Some(PathBuf::from("/tmp/board.pdf"))); + assert!(!result.copied_to_clipboard); + assert_eq!(*saver.calls.lock().unwrap(), 1); + assert_eq!(configs.lock().unwrap()[0].format, "pdf"); + assert_eq!(*clipboard.calls.lock().unwrap(), 0); +} + +#[tokio::test] +async fn deliver_document_rejects_clipboard_destinations() { + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/unused.pdf"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }), + clipboard: Arc::new(RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }), + }; + let request = DocumentDeliveryRequest { + document: rendered_pdf(Vec::new()), + destination: CaptureDestination::ClipboardOnly, + save_config: Some(FileSaveConfig::default()), + operation: ImageOperationKind::BoardPdfExport, + }; + + let err = deliver_document(request, Arc::new(deps)) + .await + .expect_err("clipboard PDF should fail"); + + assert!( + err.to_string().contains("not supported yet"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn deliver_document_requires_save_config() { + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/unused.pdf"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }), + clipboard: Arc::new(RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }), + }; + let request = DocumentDeliveryRequest { + document: rendered_pdf(Vec::new()), + destination: CaptureDestination::FileOnly, + save_config: None, + operation: ImageOperationKind::BoardPdfExport, + }; + + let err = deliver_document(request, Arc::new(deps)) + .await + .expect_err("missing save config should fail"); + + assert!( + err.to_string().contains("file save configuration"), + "unexpected error: {err}" + ); +} + +#[tokio::test] +async fn deliver_document_requires_save_directory() { + let deps = CaptureDependencies { + source: Arc::new(MockSource { + data: Vec::new(), + error: Arc::new(Mutex::new(None)), + captured_types: Arc::new(Mutex::new(Vec::new())), + }), + saver: Arc::new(RecordingSaver { + should_fail: false, + path: PathBuf::from("/tmp/unused.pdf"), + calls: Arc::new(Mutex::new(0)), + configs: Arc::new(Mutex::new(Vec::new())), + }), + clipboard: Arc::new(RecordingClipboard { + should_fail: false, + calls: Arc::new(Mutex::new(0)), + copied: Arc::new(Mutex::new(Vec::new())), + }), + }; + let request = DocumentDeliveryRequest { + document: rendered_pdf(Vec::new()), + destination: CaptureDestination::FileOnly, + save_config: Some(FileSaveConfig { + save_directory: PathBuf::new(), + ..FileSaveConfig::default() + }), + operation: ImageOperationKind::BoardPdfExport, + }; + + let err = deliver_document(request, Arc::new(deps)) + .await + .expect_err("empty save directory should fail"); + + assert!( + err.to_string().contains("save directory"), + "unexpected error: {err}" + ); +} + #[tokio::test] async fn deliver_image_clipboard_only_copies_png_bytes() { let copied = Arc::new(Mutex::new(Vec::new())); diff --git a/src/capture/types.rs b/src/capture/types.rs index e1096928..fb87998b 100644 --- a/src/capture/types.rs +++ b/src/capture/types.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; pub enum ImageOperationKind { Screenshot, CanvasExport, + BoardPdfExport, } impl ImageOperationKind { @@ -15,6 +16,7 @@ impl ImageOperationKind { match self { Self::Screenshot => "Screenshot Captured", Self::CanvasExport => "Canvas exported", + Self::BoardPdfExport => "Board exported", } } @@ -22,6 +24,7 @@ impl ImageOperationKind { match self { Self::Screenshot => "Screenshot Failed", Self::CanvasExport => "Canvas export failed", + Self::BoardPdfExport => "Board PDF export failed", } } @@ -29,6 +32,7 @@ impl ImageOperationKind { match self { Self::Screenshot => "Screenshot Clipboard Failed", Self::CanvasExport => "Canvas clipboard failed", + Self::BoardPdfExport => "Board PDF clipboard failed", } } @@ -36,6 +40,7 @@ impl ImageOperationKind { match self { Self::Screenshot => "Clipboard failed", Self::CanvasExport => "Canvas clipboard failed", + Self::BoardPdfExport => "Board PDF clipboard failed", } } @@ -43,6 +48,7 @@ impl ImageOperationKind { match self { Self::Screenshot => "Screenshot", Self::CanvasExport => "Canvas export", + Self::BoardPdfExport => "Board PDF export", } } @@ -60,6 +66,19 @@ impl ImageOperationKind { CaptureError::Cancelled(reason) => format!("Canvas export cancelled: {reason}"), other => other.to_string(), }, + Self::BoardPdfExport => match err { + CaptureError::SaveError(err) => { + format!("Failed to save board PDF export: {err}") + } + CaptureError::ClipboardError(err) => { + format!("Board PDF export clipboard operation failed: {err}") + } + CaptureError::ImageError(err) => format!("Board PDF export failed: {err}"), + CaptureError::Cancelled(reason) => { + format!("Board PDF export cancelled: {reason}") + } + other => other.to_string(), + }, } } } @@ -96,6 +115,21 @@ pub struct ImageDeliveryRequest { pub fallback_format_override: Option, } +#[derive(Debug, Clone)] +pub struct RenderedDocument { + pub bytes: Vec, + pub extension: String, + pub mime_type: String, +} + +#[derive(Debug, Clone)] +pub struct DocumentDeliveryRequest { + pub document: RenderedDocument, + pub destination: CaptureDestination, + pub save_config: Option, + pub operation: ImageOperationKind, +} + /// Type of screenshot capture to perform. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CaptureType { diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs index 595e7d29..8cd6cb6a 100644 --- a/src/config/action_meta/entries/capture.rs +++ b/src/config/action_meta/entries/capture.rs @@ -130,6 +130,17 @@ pub const ENTRIES: &[ActionMeta] = &[ "clipboard" ] ), + meta!( + ExportBoardPdfFile, + "Export Board to PDF", + Some("Board to PDF"), + "Export active board pages as a PDF", + Capture, + true, + true, + false, + &["export board pdf", "board pdf", "pdf", "export pdf"] + ), meta!( OpenCaptureFolder, "Open Capture Folder", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index f2d580d3..5bec9221 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -83,6 +83,7 @@ const HELP_ACTIONS: &[Action] = &[ Action::ExportCanvasFile, Action::ExportCanvasClipboard, Action::ExportCanvasClipboardAndFile, + Action::ExportBoardPdfFile, Action::CaptureClipboardSelection, Action::CaptureFileSelection, Action::CaptureActiveWindow, @@ -191,6 +192,7 @@ const PALETTE_ACTIONS: &[Action] = &[ Action::ExportCanvasFile, Action::ExportCanvasClipboard, Action::ExportCanvasClipboardAndFile, + Action::ExportBoardPdfFile, Action::OpenCaptureFolder, Action::ToggleFrozenMode, Action::ZoomIn, diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs index 3ea63eee..4b9b9104 100644 --- a/src/config/keybindings/actions.rs +++ b/src/config/keybindings/actions.rs @@ -148,6 +148,7 @@ pub enum Action { ExportCanvasFile, ExportCanvasClipboard, ExportCanvasClipboardAndFile, + ExportBoardPdfFile, OpenCaptureFolder, ToggleFrozenMode, ZoomIn, diff --git a/src/config/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs index c2810295..23a8aa0d 100644 --- a/src/config/keybindings/config/map/capture.rs +++ b/src/config/keybindings/config/map/capture.rs @@ -40,6 +40,10 @@ impl KeybindingsConfig { &self.capture.export_canvas_clipboard_and_file, Action::ExportCanvasClipboardAndFile, )?; + inserter.insert_all( + &self.capture.export_board_pdf_file, + Action::ExportBoardPdfFile, + )?; inserter.insert_all(&self.capture.open_capture_folder, Action::OpenCaptureFolder)?; Ok(()) } diff --git a/src/config/keybindings/config/types/bindings/capture.rs b/src/config/keybindings/config/types/bindings/capture.rs index 194fd34d..dfaa2332 100644 --- a/src/config/keybindings/config/types/bindings/capture.rs +++ b/src/config/keybindings/config/types/bindings/capture.rs @@ -41,6 +41,9 @@ pub struct CaptureKeybindingsConfig { #[serde(default = "default_export_canvas_clipboard_and_file")] pub export_canvas_clipboard_and_file: Vec, + #[serde(default = "default_export_board_pdf_file")] + pub export_board_pdf_file: Vec, + #[serde(default = "default_open_capture_folder")] pub open_capture_folder: Vec, } @@ -60,6 +63,7 @@ impl Default for CaptureKeybindingsConfig { export_canvas_file: default_export_canvas_file(), export_canvas_clipboard: default_export_canvas_clipboard(), export_canvas_clipboard_and_file: default_export_canvas_clipboard_and_file(), + export_board_pdf_file: default_export_board_pdf_file(), open_capture_folder: default_open_capture_folder(), } } diff --git a/src/config/keybindings/defaults/capture.rs b/src/config/keybindings/defaults/capture.rs index d518ab2a..e78c1e6b 100644 --- a/src/config/keybindings/defaults/capture.rs +++ b/src/config/keybindings/defaults/capture.rs @@ -46,6 +46,10 @@ pub(crate) fn default_export_canvas_clipboard_and_file() -> Vec { Vec::new() } +pub(crate) fn default_export_board_pdf_file() -> Vec { + Vec::new() +} + pub(crate) fn default_open_capture_folder() -> Vec { vec!["Ctrl+Alt+O".to_string()] } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 9de5e654..5a08640c 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -254,6 +254,7 @@ fn build_action_map_includes_canvas_export_bindings() { config.capture.export_canvas_file = vec!["Ctrl+Alt+Shift+F".to_string()]; config.capture.export_canvas_clipboard = vec!["Ctrl+Alt+Shift+C".to_string()]; config.capture.export_canvas_clipboard_and_file = vec!["Ctrl+Alt+Shift+B".to_string()]; + config.capture.export_board_pdf_file = vec!["Ctrl+Alt+Shift+P".to_string()]; let map = config.build_action_map().unwrap(); @@ -269,6 +270,10 @@ fn build_action_map_includes_canvas_export_bindings() { map.get(&KeyBinding::parse("Ctrl+Alt+Shift+B").unwrap()), Some(&Action::ExportCanvasClipboardAndFile) ); + assert_eq!( + map.get(&KeyBinding::parse("Ctrl+Alt+Shift+P").unwrap()), + Some(&Action::ExportBoardPdfFile) + ); } #[test] @@ -296,4 +301,10 @@ fn canvas_export_actions_deserialize_from_config_names() { .action, Action::ExportCanvasClipboardAndFile ); + assert_eq!( + toml::from_str::("action = \"export_board_pdf_file\"") + .unwrap() + .action, + Action::ExportBoardPdfFile + ); } diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index af603808..9693c464 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -37,6 +37,15 @@ impl InputState { self.reset_modifiers(); true } + Action::ExportBoardPdfFile => { + log::debug!("Board PDF export action {:?} pending for backend", action); + self.set_pending_backend_action(PendingBackendAction::BoardPdfExport(action)); + + // Clear modifiers to prevent them from being "stuck" after capture + // (portal dialog causes key releases to be missed or focus to flicker) + self.reset_modifiers(); + true + } Action::ToggleFrozenMode => { log::info!("Toggle frozen mode requested"); self.request_frozen_toggle(); diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 799fa9e6..ea659573 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -316,6 +316,7 @@ pub(crate) struct PendingClipboardFallback { pub enum PendingBackendAction { Screenshot(Action), CanvasExport(Action), + BoardPdfExport(Action), } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 77b5a61c..af12fda0 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -295,6 +295,27 @@ mod tests { ); } + #[test] + fn return_key_sets_pending_board_pdf_export_backend_action() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "export pdf".to_string(); + let selected = state.selected_command().expect("selected command"); + assert_eq!( + selected.action, + crate::config::keybindings::Action::ExportBoardPdfFile + ); + + assert!(state.handle_command_palette_key(crate::input::Key::Return)); + + assert_eq!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::BoardPdfExport( + crate::config::keybindings::Action::ExportBoardPdfFile + )) + ); + } + #[test] fn clicking_outside_palette_closes_it() { let mut state = make_state(); @@ -387,6 +408,31 @@ mod tests { ); } + #[test] + fn clicking_visible_board_pdf_export_item_sets_pending_backend_action() { + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette_query = "export pdf".to_string(); + state.command_palette_selected = 0; + let filtered = state.filtered_commands(); + assert_eq!( + filtered.first().expect("selected command").action, + crate::config::keybindings::Action::ExportBoardPdfFile + ); + let geometry = state.command_palette_geometry(1920, 1000, filtered.len()); + let x = (geometry.x + geometry.inner_x + 4.0) as i32; + let y = (geometry.y + geometry.items_top + COMMAND_PALETTE_ITEM_HEIGHT * 0.5) as i32; + + assert!(state.handle_command_palette_click(x, y, 1920, 1000)); + + assert_eq!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::BoardPdfExport( + crate::config::keybindings::Action::ExportBoardPdfFile + )) + ); + } + #[test] fn cursor_hint_rejects_strip_below_clamped_panel_height() { let mut state = make_state(); diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index b82dd886..d7868b74 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -227,6 +227,7 @@ impl InputState { match fallback.operation { ImageOperationKind::Screenshot => "Screenshot saved", ImageOperationKind::CanvasExport => "Canvas exported", + ImageOperationKind::BoardPdfExport => "Board exported", }, ); } diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 94c6b4cc..5a0a55c1 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -119,6 +119,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::ExportCanvasFile | Action::ExportCanvasClipboard | Action::ExportCanvasClipboardAndFile + | Action::ExportBoardPdfFile | Action::ToggleFrozenMode | Action::ZoomIn | Action::ZoomOut diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs index 5ca97d75..7a6106bb 100644 --- a/src/input/state/tests/text_input/actions.rs +++ b/src/input/state/tests/text_input/actions.rs @@ -69,3 +69,17 @@ fn canvas_export_action_sets_pending_backend_action() { )) ); } + +#[test] +fn board_pdf_export_action_sets_pending_backend_action() { + let mut state = create_test_input_state(); + + state.handle_action(Action::ExportBoardPdfFile); + + assert_eq!( + state.take_pending_backend_action(), + Some(PendingBackendAction::BoardPdfExport( + Action::ExportBoardPdfFile + )) + ); +} diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index af4db7ab..dd21bfb3 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -430,6 +430,10 @@ pub(super) fn build_main_sections( ), action_label(Action::ExportCanvasClipboardAndFile), ), + row( + binding_or_fallback(bindings, Action::ExportBoardPdfFile, NOT_BOUND_LABEL), + action_label(Action::ExportBoardPdfFile), + ), row( binding_or_fallback(bindings, Action::OpenCaptureFolder, NOT_BOUND_LABEL), action_label(Action::OpenCaptureFolder), diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs index dcf319c7..40294e96 100644 --- a/src/ui/help_overlay/sections/tests.rs +++ b/src/ui/help_overlay/sections/tests.rs @@ -46,6 +46,7 @@ fn canvas_export_rows_remain_visible_when_capture_context_is_disabled() { Action::ExportCanvasClipboard, Action::ExportCanvasFile, Action::ExportCanvasClipboardAndFile, + Action::ExportBoardPdfFile, ] { assert!( rows.contains(&action_label(action)),