Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -445,6 +448,107 @@ pub async fn native_capture_window(save_dir: String) -> Result<String, String> {
Err("Screenshot was cancelled or failed".to_string())
}
}
#[tauri::command]
pub async fn native_capture_scroll(save_dir: String) -> Result<String, String> {
#[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]
Expand Down
63 changes: 63 additions & 0 deletions src-tauri/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<DynamicImage> = 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<String> {
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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+,")
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 26 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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() {
Expand All @@ -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 },
];

Expand Down Expand Up @@ -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<string>(commandMap[captureMode], {
Expand Down Expand Up @@ -535,6 +537,7 @@ function App() {
"Capture Region": "region",
"Capture Screen": "fullscreen",
"Capture Window": "window",
"Capture Scroll": "scroll",
"OCR Region": "ocr",
};

Expand Down Expand Up @@ -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;
Expand All @@ -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");
});
Expand Down Expand Up @@ -641,6 +648,7 @@ function App() {
unlisten2?.();
unlisten3?.();
unlisten4?.();
unlisten4b?.();
unlisten5?.();
unlisten6?.();
unlisten7?.();
Expand Down Expand Up @@ -815,6 +823,16 @@ function App() {
<AppWindowMac className="size-4" aria-hidden="true" />
Window
</Button>
<Button
onClick={() => handleCapture("scroll")}
disabled={isCapturing}
variant="cta"
size="lg"
className="py-3 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
>
<ScrollText className="size-4" aria-hidden="true" />
Scroll capture
</Button>
</div>

{/* Quick Toggle for Auto-apply */}
Expand Down Expand Up @@ -883,6 +901,12 @@ function App() {
{getShortcutDisplay("window")}
</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Scroll capture</span>
<kbd className="px-2 py-1 bg-secondary border border-border rounded text-foreground font-mono text-xs tabular-nums">
{getShortcutDisplay("scroll")}
</kbd>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Cancel</span>
<kbd className="px-2 py-1 bg-secondary border border-border rounded text-foreground font-mono text-xs tabular-nums">Esc</kbd>
Expand Down
1 change: 1 addition & 0 deletions src/components/preferences/KeyboardShortcutManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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 },
];

Expand Down