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::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),
}
}

View file

@ -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,

View file

@ -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<u64> {
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(),
}
}

View file

@ -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").

View file

@ -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 = () => {
<SettingsGroup title="Advanced">
<StartHidden descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<PasteMethodSetting descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<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 const PasteMethodSchema = z.enum(["ctrl_v", "direct"]);
export type PasteMethod = z.infer<typeof PasteMethodSchema>;
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({

View file

@ -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<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>;
refreshSettings: () => Promise<void>;
refreshAudioDevices: () => Promise<void>;
@ -47,7 +50,7 @@ const DEFAULT_SETTINGS: Partial<Settings> = {
const DEFAULT_AUDIO_DEVICE: AudioDevice = {
index: "default",
name: "Default",
is_default: true
is_default: true,
};
export const useSettingsStore = create<SettingsStore>()(
@ -63,7 +66,7 @@ export const useSettingsStore = create<SettingsStore>()(
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<SettingsStore>()(
// 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<SettingsStore>()(
// 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<SettingsStore>()(
},
// 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 updateKey = String(key);
const originalValue = settings?.[key];
@ -171,20 +185,30 @@ export const useSettingsStore = create<SettingsStore>()(
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<SettingsStore>()(
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<SettingsStore>()(
// Initialize everything
initialize: async () => {
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } = get();
const { refreshSettings, refreshAudioDevices, refreshOutputDevices } =
get();
await Promise.all([
refreshSettings(),
refreshAudioDevices(),
refreshOutputDevices(),
]);
},
}))
})),
);