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
36 changes: 29 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,44 @@ All user-facing strings must use i18next translations. ESLint enforces this (no

**Adding new text:**

1. Add key to `src/i18n/locales/en/translation.json`
2. Use in component: `const { t } = useTranslation(); t('key.path')`
1. Add key to `src/i18n/locales/en/translation.json` (the source of truth)
2. Add the same key to **every** other locale file under `src/i18n/locales/`. New keys must land in all languages in the same PR — translations do not ship separately.
3. Run `bun run check:translations` to verify no locale is missing keys (this is the gate; CI/review will catch missing keys).
4. Use in component: `const { t } = useTranslation(); t('key.path')`

When translating, mirror the tone and terminology of nearby existing keys in each locale file rather than translating in isolation — the existing translations have already settled on conventions for recurring terms (e.g. "clipboard", "shortcut", "transcription").

**File structure:**

```
src/i18n/
├── index.ts # i18n setup
├── languages.ts # Language metadata
├── languages.ts # Language metadata (canonical list of supported locales)
└── locales/
├── en/translation.json # English (source)
├── es/translation.json # Spanish
├── fr/translation.json # French
└── vi/translation.json # Vietnamese
├── en/translation.json # English (source of truth)
├── zh/translation.json # Simplified Chinese
├── zh-TW/translation.json # Traditional Chinese
├── es/translation.json # Spanish
├── fr/translation.json # French
├── de/translation.json # German
├── ja/translation.json # Japanese
├── ko/translation.json # Korean
├── vi/translation.json # Vietnamese
├── pl/translation.json # Polish
├── it/translation.json # Italian
├── ru/translation.json # Russian
├── uk/translation.json # Ukrainian
├── pt/translation.json # Portuguese
├── cs/translation.json # Czech
├── tr/translation.json # Turkish
├── ar/translation.json # Arabic (RTL)
├── he/translation.json # Hebrew (RTL)
├── sv/translation.json # Swedish
└── bg/translation.json # Bulgarian
```

`src/i18n/languages.ts` is the canonical source for the supported locale set — if it disagrees with this list, trust `languages.ts` and update this section.

## Code Style

**Rust:**
Expand Down
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ sha2 = "0.10"
transcribe-rs = { version = "0.3.8", features = ["whisper-cpp", "onnx"] }
handy-keys = "0.2.4"
ferrous-opencc = "0.2.3"
arboard = { version = "3.6", features = ["image-data"] }
clap = { version = "4", features = ["derive"] }
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
Expand Down
134 changes: 120 additions & 14 deletions src-tauri/src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use crate::input::{self, EnigoState};
#[cfg(target_os = "linux")]
use crate::settings::TypingTool;
use crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMethod};
use crate::settings::{
get_settings, AutoSubmitKey, ClipboardHandling, ClipboardRestoreMode, PasteMethod,
};
use arboard::Clipboard as ArboardClipboard;
use enigo::{Direction, Enigo, Key, Keyboard};
use log::info;
use log::{info, warn};
use std::borrow::Cow;
use std::process::Command;
use std::time::Duration;
use tauri::{AppHandle, Manager};
Expand All @@ -12,16 +16,126 @@ use tauri_plugin_clipboard_manager::ClipboardExt;
#[cfg(target_os = "linux")]
use crate::utils::{is_kde_wayland, is_wayland};

enum SavedClipboard {
Files(Vec<std::path::PathBuf>),
Image {
rgba: Vec<u8>,
width: usize,
height: usize,
},
Html {
html: String,
alt_text: Option<String>,
},
Text(String),
Empty,
}

fn save_clipboard() -> SavedClipboard {
let mut clipboard = match ArboardClipboard::new() {
Ok(c) => c,
Err(e) => {
warn!(
"clipboard save: arboard init failed ({}), nothing to restore",
e
);
return SavedClipboard::Empty;
}
};

if let Ok(files) = clipboard.get().file_list() {
if !files.is_empty() {
return SavedClipboard::Files(files);
}
}
if let Ok(image) = clipboard.get().image() {
return SavedClipboard::Image {
rgba: image.bytes.into_owned(),
width: image.width,
height: image.height,
};
}
if let Ok(html) = clipboard.get().html() {
if !html.is_empty() {
// Preserve the text/plain alternative so destination apps that prefer
// plain text (terminals, plain-paste modes, etc.) get the original
// rather than a synthesized-from-HTML version.
let alt = clipboard.get().text().ok();
return SavedClipboard::Html {
html,
alt_text: alt,
};
}
}
match clipboard.get().text() {
Ok(text) if !text.is_empty() => SavedClipboard::Text(text),
_ => SavedClipboard::Empty,
}
}

fn restore_clipboard(saved: SavedClipboard) {
// Text restore on Wayland uses wl-copy for compatibility (especially with umlauts).
#[cfg(target_os = "linux")]
if let SavedClipboard::Text(ref text) = saved {
if is_wayland() && is_wl_copy_available() {
let _ = write_clipboard_via_wl_copy(text);
return;
}
}

let mut clipboard = match ArboardClipboard::new() {
Ok(c) => c,
Err(e) => {
warn!(
"clipboard restore: arboard init failed ({}), original clipboard not restored",
e
);
return;
}
};

match saved {
SavedClipboard::Empty => {}
SavedClipboard::Text(text) => {
let _ = clipboard.set_text(&text);
}
SavedClipboard::Image {
rgba,
width,
height,
} => {
let _ = clipboard.set_image(arboard::ImageData {
bytes: Cow::Owned(rgba),
width,
height,
});
}
SavedClipboard::Html { html, alt_text } => {
let _ = clipboard.set_html(&html, alt_text.as_ref());
}
SavedClipboard::Files(files) => {
let _ = clipboard.set().file_list(&files);
}
}
}

/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard.
fn paste_via_clipboard(
enigo: &mut Enigo,
text: &str,
app_handle: &AppHandle,
paste_method: &PasteMethod,
paste_delay_ms: u64,
restore_mode: ClipboardRestoreMode,
) -> Result<(), String> {
let clipboard = app_handle.clipboard();
let clipboard_content = clipboard.read_text().unwrap_or_default();
let saved = match restore_mode {
ClipboardRestoreMode::TextOnly => {
// Legacy path: text only, bit-for-bit compatible with pre-multiformat behavior.
SavedClipboard::Text(clipboard.read_text().unwrap_or_default())
}
ClipboardRestoreMode::AllFormats => save_clipboard(),
};

// Write text to clipboard first
// On Wayland, prefer wl-copy for better compatibility (especially with umlauts)
Expand Down Expand Up @@ -63,17 +177,8 @@ fn paste_via_clipboard(

std::thread::sleep(std::time::Duration::from_millis(50));

// Restore original clipboard content
// On Wayland, prefer wl-copy for better compatibility
#[cfg(target_os = "linux")]
if is_wayland() && is_wl_copy_available() {
let _ = write_clipboard_via_wl_copy(&clipboard_content);
} else {
let _ = clipboard.write_text(&clipboard_content);
}

#[cfg(not(target_os = "linux"))]
let _ = clipboard.write_text(&clipboard_content);
// Restore original clipboard content (all formats: text, image, HTML, files)
restore_clipboard(saved);

Ok(())
}
Expand Down Expand Up @@ -634,6 +739,7 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> {
&app_handle,
&paste_method,
paste_delay_ms,
settings.clipboard_restore_mode,
)?
}
PasteMethod::ExternalScript => {
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ pub fn run(cli_args: CliArgs) {
shortcut::change_typing_tool_setting,
shortcut::change_external_script_path_setting,
shortcut::change_clipboard_handling_setting,
shortcut::change_clipboard_restore_mode_setting,
shortcut::change_auto_submit_setting,
shortcut::change_auto_submit_key_setting,
shortcut::change_post_process_enabled_setting,
Expand Down
21 changes: 21 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ pub enum ClipboardHandling {
CopyToClipboard,
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum ClipboardRestoreMode {
/// Save and restore plain text only (legacy behavior).
TextOnly,
/// Save and restore text, HTML, images, and file lists via arboard.
/// Experimental — may behave inconsistently on some platforms.
AllFormats,
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
pub enum AutoSubmitKey {
Expand Down Expand Up @@ -201,6 +211,14 @@ impl Default for ClipboardHandling {
}
}

impl Default for ClipboardRestoreMode {
fn default() -> Self {
// Conservative default: behave exactly like the legacy code path.
// Users who want multi-format restore must opt in via experimental settings.
ClipboardRestoreMode::TextOnly
}
}

impl Default for AutoSubmitKey {
fn default() -> Self {
AutoSubmitKey::Enter
Expand Down Expand Up @@ -383,6 +401,8 @@ pub struct AppSettings {
pub paste_method: PasteMethod,
#[serde(default)]
pub clipboard_handling: ClipboardHandling,
#[serde(default)]
pub clipboard_restore_mode: ClipboardRestoreMode,
#[serde(default = "default_auto_submit")]
pub auto_submit: bool,
#[serde(default)]
Expand Down Expand Up @@ -780,6 +800,7 @@ pub fn get_default_settings() -> AppSettings {
recording_retention_period: default_recording_retention_period(),
paste_method: PasteMethod::default(),
clipboard_handling: ClipboardHandling::default(),
clipboard_restore_mode: ClipboardRestoreMode::default(),
auto_submit: default_auto_submit(),
auto_submit_key: AutoSubmitKey::default(),
post_process_enabled: default_post_process_enabled(),
Expand Down
26 changes: 23 additions & 3 deletions src-tauri/src/shortcut/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use tauri_plugin_autostart::ManagerExt;
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
use crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID;
use crate::settings::{
self, get_settings, AutoSubmitKey, ClipboardHandling, KeyboardImplementation, LLMPrompt,
OverlayPosition, PasteMethod, ShortcutBinding, SoundTheme, TypingTool,
APPLE_INTELLIGENCE_PROVIDER_ID,
self, get_settings, AutoSubmitKey, ClipboardHandling, ClipboardRestoreMode,
KeyboardImplementation, LLMPrompt, OverlayPosition, PasteMethod, ShortcutBinding, SoundTheme,
TypingTool, APPLE_INTELLIGENCE_PROVIDER_ID,
};
use crate::tray;

Expand Down Expand Up @@ -765,6 +765,26 @@ pub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Re
Ok(())
}

#[tauri::command]
#[specta::specta]
pub fn change_clipboard_restore_mode_setting(app: AppHandle, mode: String) -> Result<(), String> {
let mut settings = settings::get_settings(&app);
let parsed = match mode.as_str() {
"text_only" => ClipboardRestoreMode::TextOnly,
"all_formats" => ClipboardRestoreMode::AllFormats,
other => {
warn!(
"Invalid clipboard restore mode '{}', defaulting to text_only",
other
);
ClipboardRestoreMode::TextOnly
}
};
settings.clipboard_restore_mode = parsed;
settings::write_settings(&app, settings);
Ok(())
}

#[tauri::command]
#[specta::specta]
pub fn change_auto_submit_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
Expand Down
20 changes: 19 additions & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ async changeClipboardHandlingSetting(handling: string) : Promise<Result<null, st
else return { status: "error", error: e as any };
}
},
async changeClipboardRestoreModeSetting(mode: string) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_clipboard_restore_mode_setting", { mode }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async changeAutoSubmitSetting(enabled: boolean) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("change_auto_submit_setting", { enabled }) };
Expand Down Expand Up @@ -827,12 +835,22 @@ historyUpdatePayload: "history-update-payload"

/** user-defined types **/

export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number }
export type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; clipboard_restore_mode?: ClipboardRestoreMode; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: SecretMap; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; whisper_gpu_device?: number; extra_recording_buffer_ms?: number }
export type AudioDevice = { index: string; name: string; is_default: boolean }
export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter"
export type AvailableAccelerators = { whisper: string[]; ort: string[]; gpu_devices: GpuDeviceOption[] }
export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }
export type ClipboardHandling = "dont_modify" | "copy_to_clipboard"
export type ClipboardRestoreMode =
/**
* Save and restore plain text only (legacy behavior).
*/
"text_only" |
/**
* Save and restore text, HTML, images, and file lists via arboard.
* Experimental — may behave inconsistently on some platforms.
*/
"all_formats"
export type CustomSounds = { start: boolean; stop: boolean }
export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" | "Cohere"
export type GpuDeviceOption = { id: number; name: string; total_vram_mb: number }
Expand Down
Loading