diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cf0f7de..3d0dc9b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -10,7 +10,10 @@ use objc2::msg_send; use objc2_app_kit::NSWindow; use crate::clipboard::{copy_image_to_clipboard, copy_text_to_clipboard}; -use crate::image::{copy_screenshot_to_dir, crop_image, render_image_with_effects, save_base64_image, CropRegion, RenderSettings}; +use crate::image::{ + copy_screenshot_to_dir, crop_image, render_image_with_effects, save_base64_image, + stitch_scroll_captures, CropRegion, RenderSettings, +}; use crate::ocr::recognize_text_from_image; use crate::screenshot::{ capture_all_monitors as capture_monitors, capture_primary_monitor, MonitorShot, @@ -445,6 +448,107 @@ pub async fn native_capture_window(save_dir: String) -> Result { Err("Screenshot was cancelled or failed".to_string()) } } +#[tauri::command] +pub async fn native_capture_scroll(save_dir: String) -> Result { + #[cfg(not(target_os = "macos"))] + return Err("Scrolling capture is only supported on macOS.".to_string()); + + #[cfg(target_os = "macos")] + { + let _lock = SCREENCAPTURE_LOCK + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + + if is_screencapture_running() { + return Err("Another screenshot capture is already in progress".to_string()); + } + + check_and_activate_permission().map_err(|e| { + format!("Permission check failed: {}. Please ensure Screen Recording permission is granted in System Settings > Privacy & Security > Screen Recording.", e) + })?; + + let window_id_output = Command::new("osascript") + .arg("-e") + .arg("tell application \"System Events\" to get id of front window of (first process whose frontmost is true)") + .output() + .map_err(|e| format!("Failed to get front window: {}", e))?; + + if !window_id_output.status.success() { + return Err("Could not get front window. Make sure an app window is focused.".to_string()); + } + + let window_id = String::from_utf8_lossy(&window_id_output.stdout).trim().to_string(); + if window_id.is_empty() { + return Err("No front window found. Focus the window you want to capture (e.g. browser), then try again.".to_string()); + } + + let temp_dir = std::env::temp_dir().join(format!("bettershot_scroll_{}", std::process::id())); + std::fs::create_dir_all(&temp_dir).map_err(|e| format!("Failed to create temp dir: {}", e))?; + let _cleanup = TempDirGuard { path: temp_dir.clone() }; + + const MAX_STEPS: u32 = 30; + const OVERLAP_RATIO: f32 = 0.2; + const SCROLL_DELAY_MS: u64 = 400; + + let mut paths = Vec::with_capacity(MAX_STEPS as usize); + for step in 0..MAX_STEPS { + let filename = format!("scroll_step_{}.png", step); + let path = temp_dir.join(&filename); + let path_str = path.to_string_lossy().to_string(); + + let status = Command::new("screencapture") + .arg("-l") + .arg(&window_id) + .arg("-x") + .arg(&path_str) + .status() + .map_err(|e| format!("Failed to run screencapture: {}", e))?; + + if !status.success() || !path.exists() { + if step == 0 { + return Err("Failed to capture window. Ensure the window is visible and Screen Recording is allowed.".to_string()); + } + break; + } + paths.push(path.clone()); + + if step + 1 >= MAX_STEPS { + break; + } + + let _ = Command::new("osascript") + .arg("-e") + .arg("tell application \"System Events\" to key code 121") + .output(); + std::thread::sleep(std::time::Duration::from_millis(SCROLL_DELAY_MS)); + } + + if paths.len() < 2 { + return Err("Scrolling capture needs at least 2 frames. Scroll the content a bit and try again.".to_string()); + } + + let save_path = PathBuf::from(&save_dir); + std::fs::create_dir_all(&save_path).map_err(|e| format!("Failed to create save dir: {}", e))?; + + let out_path = stitch_scroll_captures(&paths, OVERLAP_RATIO, &save_dir) + .map_err(|e| format!("Failed to stitch: {}", e))?; + + play_screenshot_sound().await.ok(); + Ok(out_path) + } +} + +#[cfg(target_os = "macos")] +struct TempDirGuard { + path: std::path::PathBuf, +} + +#[cfg(target_os = "macos")] +impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} /// Capture region and perform OCR, copying text to clipboard #[tauri::command] diff --git a/src-tauri/src/image.rs b/src-tauri/src/image.rs index 727c9d0..2bfb75c 100644 --- a/src-tauri/src/image.rs +++ b/src-tauri/src/image.rs @@ -109,6 +109,69 @@ pub fn save_base64_image(image_data: &str, save_dir: &str, prefix: &str) -> AppR Ok(file_path.to_string_lossy().into_owned()) } +pub fn stitch_scroll_captures( + image_paths: &[std::path::PathBuf], + overlap_ratio: f32, + save_dir: &str, +) -> AppResult { + if image_paths.is_empty() { + return Err("No images to stitch".into()); + } + if overlap_ratio < 0.0 || overlap_ratio >= 1.0 { + return Err("overlap_ratio must be in [0, 1)".into()); + } + + let mut images: Vec = Vec::with_capacity(image_paths.len()); + let (width, first_height) = { + let img = image::open(&image_paths[0]) + .map_err(|e| format!("Failed to open first image: {}", e))?; + let (w, h) = (img.width(), img.height()); + images.push(img); + (w, h) + }; + + for path in image_paths.iter().skip(1) { + let img = image::open(path).map_err(|e| format!("Failed to open image: {}", e))?; + if img.width() != width { + return Err(format!( + "Image width mismatch: expected {}, got {}", + width, + img.width() + )); + } + images.push(img); + } + + let overlap_px = (first_height as f32 * overlap_ratio).round() as u32; + let append_height = first_height.saturating_sub(overlap_px); + let total_height = first_height + + append_height + .checked_mul(images.len().saturating_sub(1) as u32) + .unwrap_or(0); + + let mut result = RgbaImage::new(width, total_height); + let first = images[0].to_rgba8(); + image::imageops::replace(&mut result, &first, 0, 0); + + let mut y = first_height as i64; + for img in images.iter().skip(1) { + let rgba = img.to_rgba8(); + if overlap_px >= rgba.height() { + return Err("overlap_ratio is too large for image height".into()); + } + + let crop_y = overlap_px; + let crop_h = rgba.height() - crop_y; + let cropped_img = image::imageops::crop_imm(&rgba, 0, crop_y, width, crop_h).to_image(); + if y + crop_h as i64 <= total_height as i64 { + image::imageops::replace(&mut result, &cropped_img, 0, y as u32); + } + y += crop_h as i64; + } + + let final_img = DynamicImage::ImageRgba8(result); + save_image(&final_img, save_dir, "scroll") +} /// Copy a screenshot file to a destination directory pub fn copy_screenshot_to_dir(source_path: &str, save_dir: &str) -> AppResult { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 327b0c7..4fb1d51 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,6 +178,9 @@ pub fn run() { let capture_ocr_item = MenuItemBuilder::with_id("capture_ocr", "OCR Region").build(app)?; + let capture_scroll_item = + MenuItemBuilder::with_id("capture_scroll", "Capture Scroll").build(app)?; + let preferences_item = MenuItemBuilder::with_id("preferences", "Preferences...") .accelerator("CommandOrControl+,") @@ -194,6 +197,7 @@ pub fn run() { &capture_region_item, &capture_screen_item, &capture_window_item, + &capture_scroll_item, &capture_ocr_item, &PredefinedMenuItem::separator(app)?, &preferences_item, @@ -224,6 +228,9 @@ pub fn run() { "capture_ocr" => { let _ = app.emit("capture-ocr", ()); } + "capture_scroll" => { + let _ = app.emit("capture-scroll", ()); + } "preferences" => { if let Err(e) = show_main_window(app) { eprintln!("Failed to show window: {}", e); @@ -252,6 +259,7 @@ pub fn run() { native_capture_interactive, native_capture_fullscreen, native_capture_window, + native_capture_scroll, native_capture_ocr_region, play_screenshot_sound, get_mouse_position, diff --git a/src/App.tsx b/src/App.tsx index 052c392..cb35e1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,7 @@ import { } from "@tauri-apps/api/window"; import { register, unregister } from "@tauri-apps/plugin-global-shortcut"; import { Store } from "@tauri-apps/plugin-store"; -import { AppWindowMac, Crop, Monitor, ScanText } from "lucide-react"; +import { AppWindowMac, Crop, Monitor, ScanText, ScrollText } from "lucide-react"; import { toast } from "sonner"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import type { KeyboardShortcut } from "./components/preferences/KeyboardShortcutManager"; @@ -27,7 +27,7 @@ const OnboardingFlow = lazy(() => import("./components/onboarding/OnboardingFlow const PreferencesPage = lazy(() => import("./components/preferences/PreferencesPage").then(m => ({ default: m.PreferencesPage }))); type AppMode = "main" | "editing" | "preferences"; -type CaptureMode = "region" | "fullscreen" | "window" | "ocr"; +type CaptureMode = "region" | "fullscreen" | "window" | "scroll" | "ocr"; // Loading fallback for lazy loaded components function LoadingFallback() { @@ -48,6 +48,7 @@ const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [ { id: "region", action: "Capture Region", shortcut: "CommandOrControl+Shift+2", enabled: true }, { id: "fullscreen", action: "Capture Screen", shortcut: "CommandOrControl+Shift+F", enabled: false }, { id: "window", action: "Capture Window", shortcut: "CommandOrControl+Shift+D", enabled: false }, + { id: "scroll", action: "Capture Scroll", shortcut: "CommandOrControl+Shift+S", enabled: false }, { id: "ocr", action: "OCR Region", shortcut: "CommandOrControl+Shift+O", enabled: false }, ]; @@ -434,6 +435,7 @@ function App() { region: "native_capture_interactive", fullscreen: "native_capture_fullscreen", window: "native_capture_window", + scroll: "native_capture_scroll", }; const screenshotPath = await invoke(commandMap[captureMode], { @@ -535,6 +537,7 @@ function App() { "Capture Region": "region", "Capture Screen": "fullscreen", "Capture Window": "window", + "Capture Scroll": "scroll", "OCR Region": "ocr", }; @@ -580,6 +583,7 @@ function App() { let unlisten2: (() => void) | null = null; let unlisten3: (() => void) | null = null; let unlisten4: (() => void) | null = null; + let unlisten4b: (() => void) | null = null; let unlisten5: (() => void) | null = null; let unlisten6: (() => void) | null = null; let unlisten7: (() => void) | null = null; @@ -600,6 +604,9 @@ function App() { unlisten4 = await listen("capture-ocr", () => { if (mounted) handleCaptureRef.current("ocr"); }); + unlisten4b = await listen("capture-scroll", () => { + if (mounted) handleCaptureRef.current("scroll"); + }); unlisten5 = await listen("open-preferences", () => { if (mounted) setMode("preferences"); }); @@ -641,6 +648,7 @@ function App() { unlisten2?.(); unlisten3?.(); unlisten4?.(); + unlisten4b?.(); unlisten5?.(); unlisten6?.(); unlisten7?.(); @@ -815,6 +823,16 @@ function App() {