mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-06-03 03:54:42 +02:00
feat: export boards as PDF
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -568,6 +568,7 @@ Press <kbd>F1</kbd>/<kbd>F10</kbd> for help or <kbd>Shift+F1</kbd> for quick ref
|
||||
| <kbd>Ctrl+Alt+O</kbd> | 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -106,6 +106,7 @@ impl KeybindingField {
|
||||
Self::ExportCanvasFile,
|
||||
Self::ExportCanvasClipboard,
|
||||
Self::ExportCanvasClipboardAndFile,
|
||||
Self::ExportBoardPdfFile,
|
||||
Self::OpenCaptureFolder,
|
||||
Self::ToggleFrozenMode,
|
||||
Self::ZoomIn,
|
||||
|
||||
@@ -93,6 +93,7 @@ pub enum KeybindingField {
|
||||
ExportCanvasFile,
|
||||
ExportCanvasClipboard,
|
||||
ExportCanvasClipboardAndFile,
|
||||
ExportBoardPdfFile,
|
||||
OpenCaptureFolder,
|
||||
ToggleFrozenMode,
|
||||
ZoomIn,
|
||||
|
||||
@@ -108,6 +108,7 @@ impl KeybindingField {
|
||||
| Self::ExportCanvasFile
|
||||
| Self::ExportCanvasClipboard
|
||||
| Self::ExportCanvasClipboardAndFile
|
||||
| Self::ExportBoardPdfFile
|
||||
| Self::OpenCaptureFolder
|
||||
| Self::ToggleFrozenMode
|
||||
| Self::ZoomIn
|
||||
|
||||
@@ -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()]
|
||||
);
|
||||
}
|
||||
|
||||
+5
-2
@@ -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"]
|
||||
|
||||
@@ -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(" - ")
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+168
-11
@@ -16,6 +16,23 @@ pub struct CanvasExportSnapshot {
|
||||
pub render_profile: Option<RenderColorProfile>,
|
||||
}
|
||||
|
||||
#[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<CanvasPageExportSnapshot>,
|
||||
}
|
||||
|
||||
#[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<Vec<u8>, 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::<u8>::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::<Vec<u8>>().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();
|
||||
|
||||
+18
-2
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
};
|
||||
|
||||
+65
-9
@@ -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<CaptureDependencies>,
|
||||
) -> Result<CaptureResult, CaptureError> {
|
||||
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<dyn CaptureFileSaver>,
|
||||
image_data: Vec<u8>,
|
||||
bytes: Vec<u8>,
|
||||
config: FileSaveConfig,
|
||||
) -> Result<PathBuf, CaptureError> {
|
||||
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)))?
|
||||
}
|
||||
|
||||
@@ -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<u8>) -> RenderedImage {
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_pdf(bytes: Vec<u8>) -> 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()));
|
||||
|
||||
@@ -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<u8>) -> RenderedImage {
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_pdf(bytes: Vec<u8>) -> 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()));
|
||||
|
||||
@@ -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<ImageFormatMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedDocument {
|
||||
pub bytes: Vec<u8>,
|
||||
pub extension: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DocumentDeliveryRequest {
|
||||
pub document: RenderedDocument,
|
||||
pub destination: CaptureDestination,
|
||||
pub save_config: Option<crate::capture::file::FileSaveConfig>,
|
||||
pub operation: ImageOperationKind,
|
||||
}
|
||||
|
||||
/// Type of screenshot capture to perform.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CaptureType {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -148,6 +148,7 @@ pub enum Action {
|
||||
ExportCanvasFile,
|
||||
ExportCanvasClipboard,
|
||||
ExportCanvasClipboardAndFile,
|
||||
ExportBoardPdfFile,
|
||||
OpenCaptureFolder,
|
||||
ToggleFrozenMode,
|
||||
ZoomIn,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct CaptureKeybindingsConfig {
|
||||
#[serde(default = "default_export_canvas_clipboard_and_file")]
|
||||
pub export_canvas_clipboard_and_file: Vec<String>,
|
||||
|
||||
#[serde(default = "default_export_board_pdf_file")]
|
||||
pub export_board_pdf_file: Vec<String>,
|
||||
|
||||
#[serde(default = "default_open_capture_folder")]
|
||||
pub open_capture_folder: Vec<String>,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ pub(crate) fn default_export_canvas_clipboard_and_file() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(crate) fn default_export_board_pdf_file() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(crate) fn default_open_capture_folder() -> Vec<String> {
|
||||
vec!["Ctrl+Alt+O".to_string()]
|
||||
}
|
||||
|
||||
@@ -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::<ActionFixture>("action = \"export_board_pdf_file\"")
|
||||
.unwrap()
|
||||
.action,
|
||||
Action::ExportBoardPdfFile
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -316,6 +316,7 @@ pub(crate) struct PendingClipboardFallback {
|
||||
pub enum PendingBackendAction {
|
||||
Screenshot(Action),
|
||||
CanvasExport(Action),
|
||||
BoardPdfExport(Action),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -227,6 +227,7 @@ impl InputState {
|
||||
match fallback.operation {
|
||||
ImageOperationKind::Screenshot => "Screenshot saved",
|
||||
ImageOperationKind::CanvasExport => "Canvas exported",
|
||||
ImageOperationKind::BoardPdfExport => "Board exported",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user