add support for changing paste method (#187)

* add support for changing paste method

* rename/refactor of methods
This commit is contained in:
CJ Pais 2025-10-08 12:32:04 -07:00 committed by GitHub
parent 03289f84d7
commit 1432bac30f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 167 additions and 23 deletions

View file

@ -1,3 +1,4 @@
use crate::settings::{get_settings, PasteMethod};
use enigo::Enigo; use enigo::Enigo;
use enigo::Key; use enigo::Key;
use enigo::Keyboard; use enigo::Keyboard;
@ -38,14 +39,29 @@ fn send_paste() -> Result<(), String> {
Ok(()) 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(); let clipboard = app_handle.clipboard();
// get the current clipboard content // get the current clipboard content
let clipboard_content = clipboard.read_text().unwrap_or_default(); let clipboard_content = clipboard.read_text().unwrap_or_default();
clipboard clipboard
.write_text(&text) .write_text(text)
.map_err(|e| format!("Failed to write to clipboard: {}", e))?; .map_err(|e| format!("Failed to write to clipboard: {}", e))?;
// small delay to ensure the clipboard content has been written to // 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(()) 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),
}
}

View file

@ -210,6 +210,7 @@ pub fn run() {
shortcut::change_overlay_position_setting, shortcut::change_overlay_position_setting,
shortcut::change_debug_mode_setting, shortcut::change_debug_mode_setting,
shortcut::change_word_correction_threshold_setting, shortcut::change_word_correction_threshold_setting,
shortcut::change_paste_method_setting,
shortcut::update_custom_words, shortcut::update_custom_words,
shortcut::suspend_binding, shortcut::suspend_binding,
shortcut::resume_binding, shortcut::resume_binding,

View file

@ -33,12 +33,29 @@ pub enum ModelUnloadTimeout {
Sec5, // Debug mode only 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 { impl Default for ModelUnloadTimeout {
fn default() -> Self { fn default() -> Self {
ModelUnloadTimeout::Never 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 { impl ModelUnloadTimeout {
pub fn to_minutes(self) -> Option<u64> { pub fn to_minutes(self) -> Option<u64> {
match self { match self {
@ -93,6 +110,8 @@ pub struct AppSettings {
pub model_unload_timeout: ModelUnloadTimeout, pub model_unload_timeout: ModelUnloadTimeout,
#[serde(default = "default_word_correction_threshold")] #[serde(default = "default_word_correction_threshold")]
pub word_correction_threshold: f64, pub word_correction_threshold: f64,
#[serde(default)]
pub paste_method: PasteMethod,
} }
fn default_model() -> String { fn default_model() -> String {
@ -167,6 +186,7 @@ pub fn get_default_settings() -> AppSettings {
custom_words: Vec::new(), custom_words: Vec::new(),
model_unload_timeout: ModelUnloadTimeout::Never, model_unload_timeout: ModelUnloadTimeout::Never,
word_correction_threshold: default_word_correction_threshold(), word_correction_threshold: default_word_correction_threshold(),
paste_method: PasteMethod::default(),
} }
} }

View file

@ -5,7 +5,7 @@ use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
use crate::actions::ACTION_MAP; use crate::actions::ACTION_MAP;
use crate::settings::ShortcutBinding; use crate::settings::ShortcutBinding;
use crate::settings::{self, get_settings, OverlayPosition}; use crate::settings::{self, get_settings, OverlayPosition, PasteMethod};
use crate::ManagedToggleState; use crate::ManagedToggleState;
pub fn init_shortcuts(app: &App) { pub fn init_shortcuts(app: &App) {
@ -210,6 +210,22 @@ pub fn change_word_correction_threshold_setting(
Ok(()) 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. /// 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 /// We allow single non-modifier keys (e.g. "f5" or "space") but disallow
/// modifier-only combos (e.g. "ctrl" or "ctrl+shift"). /// modifier-only combos (e.g. "ctrl" or "ctrl+shift").

View file

@ -4,6 +4,7 @@ import { TranslateToEnglish } from "./TranslateToEnglish";
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout"; import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
import { CustomWords } from "./CustomWords"; import { CustomWords } from "./CustomWords";
import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone"; import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
import { PasteMethodSetting } from "./PasteMethod";
import { SettingsGroup } from "../ui/SettingsGroup"; import { SettingsGroup } from "../ui/SettingsGroup";
import { StartHidden } from "./StartHidden"; import { StartHidden } from "./StartHidden";
@ -13,6 +14,7 @@ export const AdvancedSettings: React.FC = () => {
<SettingsGroup title="Advanced"> <SettingsGroup title="Advanced">
<StartHidden descriptionMode="tooltip" grouped={true} /> <StartHidden descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} /> <ShowOverlay descriptionMode="tooltip" grouped={true} />
<PasteMethodSetting descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} /> <TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} /> <ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<CustomWords descriptionMode="tooltip" grouped /> <CustomWords descriptionMode="tooltip" grouped />

View file

@ -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<PasteMethodProps> = React.memo(({
descriptionMode = "tooltip",
grouped = false,
}) => {
const { getSetting, updateSetting, isUpdating } = useSettings();
const selectedMethod = (getSetting("paste_method") ||
"ctrl_v") as PasteMethod;
return (
<SettingContainer
title="Paste Method"
description="Clipboard (Ctrl+V) simulates Ctrl/Cmd+V keystrokes to paste from your clipboard. Direct tries to use system input methods if possible, otherwise inputs keystrokes one by one into the text field."
descriptionMode={descriptionMode}
grouped={grouped}
>
<Dropdown
options={pasteMethodOptions}
selectedValue={selectedMethod}
onSelect={(value) =>
updateSetting("paste_method", value as PasteMethod)
}
disabled={isUpdating("paste_method")}
/>
</SettingContainer>
);
});

View file

@ -34,6 +34,9 @@ export const ModelUnloadTimeoutSchema = z.enum([
]); ]);
export type ModelUnloadTimeout = z.infer<typeof ModelUnloadTimeoutSchema>; export type ModelUnloadTimeout = z.infer<typeof ModelUnloadTimeoutSchema>;
export const PasteMethodSchema = z.enum(["ctrl_v", "direct"]);
export type PasteMethod = z.infer<typeof PasteMethodSchema>;
export const SettingsSchema = z.object({ export const SettingsSchema = z.object({
bindings: ShortcutBindingsMapSchema, bindings: ShortcutBindingsMapSchema,
push_to_talk: z.boolean(), push_to_talk: z.boolean(),
@ -50,6 +53,7 @@ export const SettingsSchema = z.object({
custom_words: z.array(z.string()).optional().default([]), custom_words: z.array(z.string()).optional().default([]),
model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"), model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"),
word_correction_threshold: z.number().optional().default(0.18), word_correction_threshold: z.number().optional().default(0.18),
paste_method: PasteMethodSchema.optional().default("ctrl_v"),
}); });
export const BindingResponseSchema = z.object({ export const BindingResponseSchema = z.object({

View file

@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from "zustand";
import { subscribeWithSelector } from 'zustand/middleware'; import { subscribeWithSelector } from "zustand/middleware";
import { invoke } from '@tauri-apps/api/core'; import { invoke } from "@tauri-apps/api/core";
import { Settings, AudioDevice } from '../lib/types'; import { Settings, AudioDevice } from "../lib/types";
interface SettingsStore { interface SettingsStore {
settings: Settings | null; settings: Settings | null;
@ -12,7 +12,10 @@ interface SettingsStore {
// Actions // Actions
initialize: () => Promise<void>; initialize: () => Promise<void>;
updateSetting: <K extends keyof Settings>(key: K, value: Settings[K]) => Promise<void>; updateSetting: <K extends keyof Settings>(
key: K,
value: Settings[K],
) => Promise<void>;
resetSetting: (key: keyof Settings) => Promise<void>; resetSetting: (key: keyof Settings) => Promise<void>;
refreshSettings: () => Promise<void>; refreshSettings: () => Promise<void>;
refreshAudioDevices: () => Promise<void>; refreshAudioDevices: () => Promise<void>;
@ -47,7 +50,7 @@ const DEFAULT_SETTINGS: Partial<Settings> = {
const DEFAULT_AUDIO_DEVICE: AudioDevice = { const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default", index: "default",
name: "Default", name: "Default",
is_default: true is_default: true,
}; };
export const useSettingsStore = create<SettingsStore>()( export const useSettingsStore = create<SettingsStore>()(
@ -63,7 +66,7 @@ export const useSettingsStore = create<SettingsStore>()(
setLoading: (isLoading) => set({ isLoading }), setLoading: (isLoading) => set({ isLoading }),
setUpdating: (key, updating) => setUpdating: (key, updating) =>
set((state) => ({ set((state) => ({
isUpdating: { ...state.isUpdating, [key]: updating } isUpdating: { ...state.isUpdating, [key]: updating },
})), })),
setAudioDevices: (audioDevices) => set({ audioDevices }), setAudioDevices: (audioDevices) => set({ audioDevices }),
setOutputDevices: (outputDevices) => set({ outputDevices }), setOutputDevices: (outputDevices) => set({ outputDevices }),
@ -114,10 +117,14 @@ export const useSettingsStore = create<SettingsStore>()(
// Load audio devices // Load audio devices
refreshAudioDevices: async () => { refreshAudioDevices: async () => {
try { try {
const devices: AudioDevice[] = await invoke("get_available_microphones"); const devices: AudioDevice[] = await invoke(
"get_available_microphones",
);
const devicesWithDefault = [ const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE, DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"), ...devices.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
]; ];
set({ audioDevices: devicesWithDefault }); set({ audioDevices: devicesWithDefault });
} catch (error) { } catch (error) {
@ -129,10 +136,14 @@ export const useSettingsStore = create<SettingsStore>()(
// Load output devices // Load output devices
refreshOutputDevices: async () => { refreshOutputDevices: async () => {
try { try {
const devices: AudioDevice[] = await invoke("get_available_output_devices"); const devices: AudioDevice[] = await invoke(
"get_available_output_devices",
);
const devicesWithDefault = [ const devicesWithDefault = [
DEFAULT_AUDIO_DEVICE, DEFAULT_AUDIO_DEVICE,
...devices.filter((d) => d.name !== "Default" && d.name !== "default"), ...devices.filter(
(d) => d.name !== "Default" && d.name !== "default",
),
]; ];
set({ outputDevices: devicesWithDefault }); set({ outputDevices: devicesWithDefault });
} catch (error) { } catch (error) {
@ -142,7 +153,10 @@ export const useSettingsStore = create<SettingsStore>()(
}, },
// Update a specific setting // Update a specific setting
updateSetting: async <K extends keyof Settings>(key: K, value: Settings[K]) => { updateSetting: async <K extends keyof Settings>(
key: K,
value: Settings[K],
) => {
const { settings, setUpdating, refreshSettings } = get(); const { settings, setUpdating, refreshSettings } = get();
const updateKey = String(key); const updateKey = String(key);
const originalValue = settings?.[key]; const originalValue = settings?.[key];
@ -171,20 +185,30 @@ export const useSettingsStore = create<SettingsStore>()(
break; break;
case "selected_microphone": case "selected_microphone":
const micDeviceName = value === "Default" ? "default" : value; const micDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_microphone", { deviceName: micDeviceName }); await invoke("set_selected_microphone", {
deviceName: micDeviceName,
});
break; break;
case "selected_output_device": case "selected_output_device":
const outputDeviceName = value === "Default" ? "default" : value; const outputDeviceName = value === "Default" ? "default" : value;
await invoke("set_selected_output_device", { deviceName: outputDeviceName }); await invoke("set_selected_output_device", {
deviceName: outputDeviceName,
});
break; break;
case "translate_to_english": case "translate_to_english":
await invoke("change_translate_to_english_setting", { enabled: value }); await invoke("change_translate_to_english_setting", {
enabled: value,
});
break; break;
case "selected_language": case "selected_language":
await invoke("change_selected_language_setting", { language: value }); await invoke("change_selected_language_setting", {
language: value,
});
break; break;
case "overlay_position": case "overlay_position":
await invoke("change_overlay_position_setting", { position: value }); await invoke("change_overlay_position_setting", {
position: value,
});
break; break;
case "debug_mode": case "debug_mode":
await invoke("change_debug_mode_setting", { enabled: value }); await invoke("change_debug_mode_setting", { enabled: value });
@ -193,7 +217,12 @@ export const useSettingsStore = create<SettingsStore>()(
await invoke("update_custom_words", { words: value }); await invoke("update_custom_words", { words: value });
break; break;
case "word_correction_threshold": 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; break;
case "bindings": case "bindings":
case "selected_model": case "selected_model":
@ -293,12 +322,13 @@ export const useSettingsStore = create<SettingsStore>()(
// Initialize everything // Initialize everything
initialize: async () => { initialize: async () => {
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get(); const { refreshSettings, refreshAudioDevices, refreshOutputDevices } =
get();
await Promise.all([ await Promise.all([
refreshSettings(), refreshSettings(),
refreshAudioDevices(), refreshAudioDevices(),
refreshOutputDevices(), refreshOutputDevices(),
]); ]);
}, },
})) })),
); );