feat: export boards as PDF

This commit is contained in:
devmobasa
2026-05-31 08:05:45 +02:00
parent 7796839645
commit 0656e49151
36 changed files with 938 additions and 34 deletions
+1 -1
View File
@@ -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"
+1
View File
@@ -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.
---
+1
View File
@@ -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
View File
@@ -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(" - ")
+5 -4
View File
@@ -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,
},
+200
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)))?
}
+115 -1
View File
@@ -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()));
+170 -3
View File
@@ -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()));
+34
View File
@@ -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 {
+11
View File
@@ -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",
+2
View File
@@ -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,
+1
View File
@@ -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()]
}
+11
View File
@@ -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();
+1
View File
@@ -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();
+1
View File
@@ -227,6 +227,7 @@ impl InputState {
match fallback.operation {
ImageOperationKind::Screenshot => "Screenshot saved",
ImageOperationKind::CanvasExport => "Canvas exported",
ImageOperationKind::BoardPdfExport => "Board exported",
},
);
}
+1
View File
@@ -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),
+1
View File
@@ -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)),