From 1432bac30feb051b8167b357c5d56df84f31ad61 Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 8 Oct 2025 12:32:04 -0700 Subject: [PATCH] add support for changing paste method (#187) * add support for changing paste method * rename/refactor of methods --- src-tauri/src/clipboard.rs | 32 ++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/settings.rs | 20 ++++++ src-tauri/src/shortcut.rs | 18 ++++- src/components/settings/AdvancedSettings.tsx | 2 + src/components/settings/PasteMethod.tsx | 43 ++++++++++++ src/lib/types.ts | 4 ++ src/stores/settingsStore.ts | 70 ++++++++++++++------ 8 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 src/components/settings/PasteMethod.tsx diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index c928351..9ee188c 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -1,3 +1,4 @@ +use crate::settings::{get_settings, PasteMethod}; use enigo::Enigo; use enigo::Key; use enigo::Keyboard; @@ -38,14 +39,29 @@ fn send_paste() -> Result<(), String> { Ok(()) } -pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { +/// Pastes text directly using the enigo text method. +/// This tries to use system input methods if possible, otherwise simulates keystrokes one by one. +fn paste_via_direct_input(text: &str) -> Result<(), String> { + let mut enigo = Enigo::new(&Settings::default()) + .map_err(|e| format!("Failed to initialize Enigo: {}", e))?; + + enigo + .text(text) + .map_err(|e| format!("Failed to send text directly: {}", e))?; + + Ok(()) +} + +/// Pastes text using the clipboard method (Ctrl+V/Cmd+V). +/// Saves the current clipboard, writes the text, sends paste command, then restores the clipboard. +fn paste_via_clipboard(text: &str, app_handle: &AppHandle) -> Result<(), String> { let clipboard = app_handle.clipboard(); // get the current clipboard content let clipboard_content = clipboard.read_text().unwrap_or_default(); clipboard - .write_text(&text) + .write_text(text) .map_err(|e| format!("Failed to write to clipboard: {}", e))?; // small delay to ensure the clipboard content has been written to @@ -62,3 +78,15 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { Ok(()) } + +pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { + let settings = get_settings(&app_handle); + let paste_method = settings.paste_method; + + println!("Using paste method: {:?}", paste_method); + + match paste_method { + PasteMethod::CtrlV => paste_via_clipboard(&text, &app_handle), + PasteMethod::Direct => paste_via_direct_input(&text), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 324ff8c..9a26962 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ pub fn run() { shortcut::change_overlay_position_setting, shortcut::change_debug_mode_setting, shortcut::change_word_correction_threshold_setting, + shortcut::change_paste_method_setting, shortcut::update_custom_words, shortcut::suspend_binding, shortcut::resume_binding, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 30991bb..1427c1f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -33,12 +33,29 @@ pub enum ModelUnloadTimeout { Sec5, // Debug mode only } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PasteMethod { + CtrlV, + Direct, +} + impl Default for ModelUnloadTimeout { fn default() -> Self { ModelUnloadTimeout::Never } } +impl Default for PasteMethod { + fn default() -> Self { + // Default to CtrlV for macOS and Windows, Direct for Linux + #[cfg(target_os = "linux")] + return PasteMethod::Direct; + #[cfg(not(target_os = "linux"))] + return PasteMethod::CtrlV; + } +} + impl ModelUnloadTimeout { pub fn to_minutes(self) -> Option { match self { @@ -93,6 +110,8 @@ pub struct AppSettings { pub model_unload_timeout: ModelUnloadTimeout, #[serde(default = "default_word_correction_threshold")] pub word_correction_threshold: f64, + #[serde(default)] + pub paste_method: PasteMethod, } fn default_model() -> String { @@ -167,6 +186,7 @@ pub fn get_default_settings() -> AppSettings { custom_words: Vec::new(), model_unload_timeout: ModelUnloadTimeout::Never, word_correction_threshold: default_word_correction_threshold(), + paste_method: PasteMethod::default(), } } diff --git a/src-tauri/src/shortcut.rs b/src-tauri/src/shortcut.rs index f51db0f..cda0a4e 100644 --- a/src-tauri/src/shortcut.rs +++ b/src-tauri/src/shortcut.rs @@ -5,7 +5,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState}; use crate::actions::ACTION_MAP; use crate::settings::ShortcutBinding; -use crate::settings::{self, get_settings, OverlayPosition}; +use crate::settings::{self, get_settings, OverlayPosition, PasteMethod}; use crate::ManagedToggleState; pub fn init_shortcuts(app: &App) { @@ -210,6 +210,22 @@ pub fn change_word_correction_threshold_setting( Ok(()) } +#[tauri::command] +pub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> { + let mut settings = settings::get_settings(&app); + let parsed = match method.as_str() { + "ctrl_v" => PasteMethod::CtrlV, + "direct" => PasteMethod::Direct, + other => { + eprintln!("Invalid paste method '{}', defaulting to ctrl_v", other); + PasteMethod::CtrlV + } + }; + settings.paste_method = parsed; + settings::write_settings(&app, settings); + Ok(()) +} + /// Determine whether a shortcut string contains at least one non-modifier key. /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow /// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). diff --git a/src/components/settings/AdvancedSettings.tsx b/src/components/settings/AdvancedSettings.tsx index 8795e7e..4a92072 100644 --- a/src/components/settings/AdvancedSettings.tsx +++ b/src/components/settings/AdvancedSettings.tsx @@ -4,6 +4,7 @@ import { TranslateToEnglish } from "./TranslateToEnglish"; import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; import { CustomWords } from "./CustomWords"; import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; +import { PasteMethodSetting } from "./PasteMethod"; import { SettingsGroup } from "../ui/SettingsGroup"; import { StartHidden } from "./StartHidden"; @@ -13,6 +14,7 @@ export const AdvancedSettings: React.FC = () => { + diff --git a/src/components/settings/PasteMethod.tsx b/src/components/settings/PasteMethod.tsx new file mode 100644 index 0000000..21c4c0c --- /dev/null +++ b/src/components/settings/PasteMethod.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Dropdown } from "../ui/Dropdown"; +import { SettingContainer } from "../ui/SettingContainer"; +import { useSettings } from "../../hooks/useSettings"; +import type { PasteMethod } from "../../lib/types"; + +interface PasteMethodProps { + descriptionMode?: "inline" | "tooltip"; + grouped?: boolean; +} + +const pasteMethodOptions = [ + { value: "ctrl_v", label: "Clipboard (Ctrl+V)" }, + { value: "direct", label: "Direct" }, +]; + +export const PasteMethodSetting: React.FC = React.memo(({ + descriptionMode = "tooltip", + grouped = false, +}) => { + const { getSetting, updateSetting, isUpdating } = useSettings(); + + const selectedMethod = (getSetting("paste_method") || + "ctrl_v") as PasteMethod; + + return ( + + + updateSetting("paste_method", value as PasteMethod) + } + disabled={isUpdating("paste_method")} + /> + + ); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 68000dd..28bcac7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,9 @@ export const ModelUnloadTimeoutSchema = z.enum([ ]); export type ModelUnloadTimeout = z.infer; +export const PasteMethodSchema = z.enum(["ctrl_v", "direct"]); +export type PasteMethod = z.infer; + export const SettingsSchema = z.object({ bindings: ShortcutBindingsMapSchema, push_to_talk: z.boolean(), @@ -50,6 +53,7 @@ export const SettingsSchema = z.object({ custom_words: z.array(z.string()).optional().default([]), model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"), word_correction_threshold: z.number().optional().default(0.18), + paste_method: PasteMethodSchema.optional().default("ctrl_v"), }); export const BindingResponseSchema = z.object({ diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index da5c806..31b4ffe 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -1,7 +1,7 @@ -import { create } from 'zustand'; -import { subscribeWithSelector } from 'zustand/middleware'; -import { invoke } from '@tauri-apps/api/core'; -import { Settings, AudioDevice } from '../lib/types'; +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import { invoke } from "@tauri-apps/api/core"; +import { Settings, AudioDevice } from "../lib/types"; interface SettingsStore { settings: Settings | null; @@ -12,7 +12,10 @@ interface SettingsStore { // Actions initialize: () => Promise; - updateSetting: (key: K, value: Settings[K]) => Promise; + updateSetting: ( + key: K, + value: Settings[K], + ) => Promise; resetSetting: (key: keyof Settings) => Promise; refreshSettings: () => Promise; refreshAudioDevices: () => Promise; @@ -47,7 +50,7 @@ const DEFAULT_SETTINGS: Partial = { const DEFAULT_AUDIO_DEVICE: AudioDevice = { index: "default", name: "Default", - is_default: true + is_default: true, }; export const useSettingsStore = create()( @@ -63,7 +66,7 @@ export const useSettingsStore = create()( setLoading: (isLoading) => set({ isLoading }), setUpdating: (key, updating) => set((state) => ({ - isUpdating: { ...state.isUpdating, [key]: updating } + isUpdating: { ...state.isUpdating, [key]: updating }, })), setAudioDevices: (audioDevices) => set({ audioDevices }), setOutputDevices: (outputDevices) => set({ outputDevices }), @@ -114,10 +117,14 @@ export const useSettingsStore = create()( // Load audio devices refreshAudioDevices: async () => { try { - const devices: AudioDevice[] = await invoke("get_available_microphones"); + const devices: AudioDevice[] = await invoke( + "get_available_microphones", + ); const devicesWithDefault = [ DEFAULT_AUDIO_DEVICE, - ...devices.filter((d) => d.name !== "Default" && d.name !== "default"), + ...devices.filter( + (d) => d.name !== "Default" && d.name !== "default", + ), ]; set({ audioDevices: devicesWithDefault }); } catch (error) { @@ -129,10 +136,14 @@ export const useSettingsStore = create()( // Load output devices refreshOutputDevices: async () => { try { - const devices: AudioDevice[] = await invoke("get_available_output_devices"); + const devices: AudioDevice[] = await invoke( + "get_available_output_devices", + ); const devicesWithDefault = [ DEFAULT_AUDIO_DEVICE, - ...devices.filter((d) => d.name !== "Default" && d.name !== "default"), + ...devices.filter( + (d) => d.name !== "Default" && d.name !== "default", + ), ]; set({ outputDevices: devicesWithDefault }); } catch (error) { @@ -142,7 +153,10 @@ export const useSettingsStore = create()( }, // Update a specific setting - updateSetting: async (key: K, value: Settings[K]) => { + updateSetting: async ( + key: K, + value: Settings[K], + ) => { const { settings, setUpdating, refreshSettings } = get(); const updateKey = String(key); const originalValue = settings?.[key]; @@ -171,20 +185,30 @@ export const useSettingsStore = create()( break; case "selected_microphone": const micDeviceName = value === "Default" ? "default" : value; - await invoke("set_selected_microphone", { deviceName: micDeviceName }); + await invoke("set_selected_microphone", { + deviceName: micDeviceName, + }); break; case "selected_output_device": const outputDeviceName = value === "Default" ? "default" : value; - await invoke("set_selected_output_device", { deviceName: outputDeviceName }); + await invoke("set_selected_output_device", { + deviceName: outputDeviceName, + }); break; case "translate_to_english": - await invoke("change_translate_to_english_setting", { enabled: value }); + await invoke("change_translate_to_english_setting", { + enabled: value, + }); break; case "selected_language": - await invoke("change_selected_language_setting", { language: value }); + await invoke("change_selected_language_setting", { + language: value, + }); break; case "overlay_position": - await invoke("change_overlay_position_setting", { position: value }); + await invoke("change_overlay_position_setting", { + position: value, + }); break; case "debug_mode": await invoke("change_debug_mode_setting", { enabled: value }); @@ -193,7 +217,12 @@ export const useSettingsStore = create()( await invoke("update_custom_words", { words: value }); break; case "word_correction_threshold": - await invoke("change_word_correction_threshold_setting", { threshold: value }); + await invoke("change_word_correction_threshold_setting", { + threshold: value, + }); + break; + case "paste_method": + await invoke("change_paste_method_setting", { method: value }); break; case "bindings": case "selected_model": @@ -293,12 +322,13 @@ export const useSettingsStore = create()( // Initialize everything initialize: async () => { - const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get(); + const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = + get(); await Promise.all([ refreshSettings(), refreshAudioDevices(), refreshOutputDevices(), ]); }, - })) + })), );