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
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ windows = { workspace = true, features = [
"Win32_System_Power",
"Win32_System_Threading",
"Win32_System_WinRT",
"Win32_UI_Input_KeyboardAndMouse",
"Win32_UI_WindowsAndMessaging",
"Win32_Graphics_Gdi",
] }
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ pub struct GeneralSettingsStore {
pub camera_blur_disabled_by_crash: Option<String>,
#[serde(default)]
pub update_channel: UpdateChannel,
#[serde(default)]
pub ocr_keep_screenshot: bool,
#[serde(default)]
pub ocr_show_notification: bool,
}

fn default_enable_native_camera_preview() -> bool {
Expand Down Expand Up @@ -350,6 +354,8 @@ impl Default for GeneralSettingsStore {
previous_recordings_paths: Vec::new(),
camera_blur_disabled_by_crash: None,
update_channel: UpdateChannel::Stable,
ocr_keep_screenshot: false,
ocr_show_notification: false,
}
}
}
Expand Down
85 changes: 81 additions & 4 deletions apps/desktop/src-tauri/src/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,17 @@ pub enum HotkeyAction {
ScreenshotDisplay,
ScreenshotWindow,
ScreenshotArea,
OcrArea,
ScrollingCaptureWindow,
#[serde(other)]
Other,
}

#[derive(Serialize, Deserialize, Type, Default)]
pub struct HotkeysStore {
hotkeys: HashMap<HotkeyAction, Hotkey>,
#[serde(default)]
seeded: Vec<HotkeyAction>,
}

impl HotkeysStore {
Expand Down Expand Up @@ -208,15 +212,21 @@ pub fn init(app: &AppHandle) {
)
.unwrap();

let store = match HotkeysStore::get(app) {
Ok(Some(s)) => s,
Ok(None) => HotkeysStore::default(),
let mut store = match HotkeysStore::get(app) {
Ok(Some(s)) => Some(s),
Ok(None) => Some(HotkeysStore::default()),
Err(e) => {
eprintln!("Failed to load hotkeys store: {e}");
HotkeysStore::default()
None
}
};

if let Some(store) = &mut store {
seed_default_hotkeys(app, store);
}

let store = store.unwrap_or_default();

let global_shortcut = app.global_shortcut();
for hotkey in store.hotkeys.values() {
global_shortcut.register(Shortcut::from(*hotkey)).ok();
Expand All @@ -225,6 +235,46 @@ pub fn init(app: &AppHandle) {
app.manage(Mutex::new(store));
}

fn default_hotkey(action: HotkeyAction) -> Option<Hotkey> {
match action {
HotkeyAction::OcrArea => Some(Hotkey {
code: Code::KeyT,
meta: cfg!(target_os = "macos"),
ctrl: !cfg!(target_os = "macos"),
alt: false,
shift: true,
}),
_ => None,
}
}

fn seed_default_hotkeys(app: &AppHandle, store: &mut HotkeysStore) {
let mut changed = false;

for action in [HotkeyAction::OcrArea] {
if store.seeded.contains(&action) || store.hotkeys.contains_key(&action) {
continue;
}

let Some(hotkey) = default_hotkey(action) else {
continue;
};

if !store.hotkeys.values().any(|h| h == &hotkey) {
store.hotkeys.insert(action, hotkey);
}
store.seeded.push(action);
changed = true;
}

if changed && let Ok(s) = app.store("store") {
s.set("hotkeys", serde_json::json!(&*store));
if let Err(e) = s.save() {
eprintln!("Failed to save hotkeys store: {e}");
}
}
}

async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> {
match action {
HotkeyAction::StartStudioRecording => {
Expand Down Expand Up @@ -332,6 +382,33 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin
.emit(&app);
Ok(())
}
HotkeyAction::OcrArea => {
let _ = RequestOpenRecordingPicker {
target_mode: Some(RecordingTargetMode::Ocr),
}
.emit(&app);
Ok(())
}
HotkeyAction::ScrollingCaptureWindow => {
use scap_targets::Window;

let window_id = Window::get_topmost_at_cursor()
.ok_or_else(|| "No window found under cursor".to_string())?
.id();
let target = ScreenCaptureTarget::Window {
id: window_id.clone(),
};

match crate::scrolling_capture::capture_scrolling_window(app.clone(), window_id).await {
Ok(path) => {
if crate::automation::should_open_screenshot_editor(&app, &target) {
let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await;
}
Ok(())
}
Err(e) => Err(format!("Failed to capture scrolling window: {e}")),
}
}
HotkeyAction::Other => Ok(()),
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ mod recording_telemetry;
mod recordings_locations;
mod recovery;
mod screenshot_editor;
mod scrolling_capture;
mod target_select_overlay;
mod telemetry;
mod thumbnails;
Expand Down Expand Up @@ -4853,6 +4854,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
recording::restart_recording,
recording::delete_recording,
recording::take_screenshot,
recording::capture_ocr_text,
scrolling_capture::capture_scrolling_window,
recording::import_current_desktop_background,
recording::list_cameras,
recording::get_camera_formats,
Expand Down Expand Up @@ -4935,6 +4938,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
clip_thumbnails::get_clip_thumbnail,
windows::position_traffic_lights,
windows::set_theme,
windows::show_window_without_activating,
windows::set_teleprompter_window_level,
windows::set_teleprompter_window_opacity,
windows::apply_macos_liquid_glass_background,
Expand Down
157 changes: 126 additions & 31 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2803,26 +2803,71 @@ pub async fn take_screenshot(
app: AppHandle,
target: ScreenCaptureTarget,
) -> Result<PathBuf, String> {
use crate::NewScreenshotAdded;
use crate::notifications;
use crate::{PendingScreenshot, PendingScreenshots};
use cap_recording::screenshot::capture_screenshot;
use image::ImageEncoder;
use std::time::Instant;
let image = capture_screen_image(&app, target.clone()).await?;

let general_settings = GeneralSettingsStore::get(&app).ok().flatten();
let general_settings = general_settings.as_ref();
AppSounds::Notification.play();

let project_name = format_project_name(
general_settings
.and_then(|s| s.default_project_name_template.clone())
.as_deref(),
target.title().as_deref().unwrap_or("Unknown"),
target.kind_str(),
RecordingMode::Screenshot,
None,
);
save_screenshot_project(&app, image, &target, true)
}

#[tauri::command(async)]
#[specta::specta]
#[tracing::instrument(name = "capture_ocr_text", skip(app))]
pub async fn capture_ocr_text(
app: AppHandle,
target: ScreenCaptureTarget,
) -> Result<String, String> {
use tauri_plugin_clipboard_manager::ClipboardExt;

let settings = GeneralSettingsStore::get(&app)
.ok()
.flatten()
.unwrap_or_default();

let image = capture_screen_image(&app, target.clone()).await?;

let text = crate::screenshot_editor::recognize_text_from_dynamic_image(&image).await?;
let text = text.trim().to_string();

if text.is_empty() {
return Err("No text was found in the selected area".to_string());
}

app.clipboard()
.write_text(text.clone())
.map_err(|e| format!("Failed to copy text to clipboard: {e}"))?;

AppSounds::Notification.play();

if settings.ocr_keep_screenshot
&& let Err(e) = save_screenshot_project(&app, image, &target, false)
{
error!("Failed to save OCR screenshot: {e}");
}

if settings.enable_notifications && settings.ocr_show_notification {
use tauri_plugin_notification::NotificationExt;

let preview: String = text.chars().take(120).collect();
app.notification()
.builder()
.title("Text copied to clipboard")
.body(preview)
.show()
.ok();
}

Ok(text)
}

pub async fn capture_screen_image(
app: &AppHandle,
target: ScreenCaptureTarget,
) -> Result<image::DynamicImage, String> {
use crate::windows::show_overlay;
use cap_recording::screenshot::capture_screenshot;

let mut hidden_windows = Vec::new();
let mut hid_any = false;
for (label, window) in app.webview_windows() {
if let Ok(id) = CapWindowId::from_str(&label)
Expand All @@ -2835,22 +2880,70 @@ pub async fn take_screenshot(
| CapWindowId::RecordingsOverlay
)
{
let was_visible = window.is_visible().unwrap_or(false);
hide_overlay(&window);
hid_any = true;
// The target-select overlay's lifecycle is owned by the frontend,
// which hides it before invoking a capture and closes or restores
// it afterwards; re-showing it here would fight that. The occluder
// must keep ignoring cursor events, so it is re-shown without the
// show_overlay cursor-event reset.
if was_visible && !matches!(id, CapWindowId::TargetSelectOverlay { .. }) {
let ignores_cursor = matches!(id, CapWindowId::WindowCaptureOccluder { .. });
hidden_windows.push((window, ignores_cursor));
}
}
}

if hid_any {
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
}

let automation_target = target.clone();

let image = capture_screenshot(target)
let result = capture_screenshot(target)
.await
.map_err(|e| format!("Failed to capture screenshot: {e}"))?;
.map_err(|e| format!("Failed to capture screenshot: {e}"));

AppSounds::Notification.play();
for (window, ignores_cursor) in hidden_windows {
if ignores_cursor {
let _ = window.show();
} else {
show_overlay(&window);
}
}

result
}

/// `run_side_effects` controls whether screenshot automations and the
/// "screenshot saved" notification fire once the project is written. OCR
/// captures skip them: an automation like copy-to-clipboard would overwrite
/// the text that was just copied.
pub fn save_screenshot_project(
app: &AppHandle,
image: image::DynamicImage,
target: &ScreenCaptureTarget,
run_side_effects: bool,
) -> Result<PathBuf, String> {
use crate::NewScreenshotAdded;
use crate::notifications;
use crate::{PendingScreenshot, PendingScreenshots};
use image::ImageEncoder;
use std::time::Instant;

let general_settings = GeneralSettingsStore::get(app).ok().flatten();
let general_settings = general_settings.as_ref();

let project_name = format_project_name(
general_settings
.and_then(|s| s.default_project_name_template.clone())
.as_deref(),
target.title().as_deref().unwrap_or("Unknown"),
target.kind_str(),
RecordingMode::Screenshot,
None,
);

let automation_target = target.clone();

let image_width = image.width();
let image_height = image.height();
Expand Down Expand Up @@ -2975,16 +3068,18 @@ pub async fn take_screenshot(
}
.emit(&app_handle);

crate::automation::run_screenshot_automations(
app_handle.clone(),
image_path_for_emit.clone(),
&automation_target,
);
if run_side_effects {
crate::automation::run_screenshot_automations(
app_handle.clone(),
image_path_for_emit.clone(),
&automation_target,
);

notifications::send_notification(
&app_handle,
notifications::NotificationType::ScreenshotSaved,
);
notifications::send_notification(
&app_handle,
notifications::NotificationType::ScreenshotSaved,
);
}
}
Ok(Err(e)) => {
error!("Failed to encode PNG: {e}");
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/src/recording_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub enum RecordingTargetMode {
Window,
Area,
Camera,
Ocr,
}

#[derive(serde::Serialize, serde::Deserialize, specta::Type, Debug, Clone, Default)]
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/src/screenshot_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,12 @@ pub async fn recognize_screenshot_text(

pub async fn recognize_text_from_image_path(path: &std::path::Path) -> Result<String, String> {
let dynamic = image::open(path).map_err(|e| format!("Failed to open image for OCR: {e}"))?;
recognize_text_from_dynamic_image(&dynamic).await
}

pub async fn recognize_text_from_dynamic_image(
dynamic: &image::DynamicImage,
) -> Result<String, String> {
let rgba = dynamic.to_rgba8();
let width = rgba.width();
let height = rgba.height();
Expand Down
Loading