diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ea56914..779224e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod injection; pub mod models; pub mod settings; +pub mod shortcuts; pub mod transcription; pub mod tray; pub mod ui_commands; @@ -14,8 +15,10 @@ use app_state::ManagedAppState; pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(shortcuts::plugin()) .setup(|app| { tray::setup(app)?; + shortcuts::setup(app)?; Ok(()) }) .manage(ManagedAppState::default()) diff --git a/src-tauri/src/shortcuts.rs b/src-tauri/src/shortcuts.rs new file mode 100644 index 0000000..b74417f --- /dev/null +++ b/src-tauri/src/shortcuts.rs @@ -0,0 +1,20 @@ +use tauri::{App, Emitter, Runtime}; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; + +pub const TOGGLE_RECORDING_EVENT: &str = "transcript://toggle-recording"; + +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, _shortcut, event| { + if event.state() == ShortcutState::Pressed { + let _ = app.emit(TOGGLE_RECORDING_EVENT, ()); + } + }) + .build() +} + +pub fn setup(app: &App) -> Result<(), Box> { + let shortcut = Shortcut::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Space); + app.global_shortcut().register(shortcut)?; + Ok(()) +} diff --git a/src/app.ts b/src/app.ts index c72767f..7e8ea8e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,251 +1,300 @@ -import { invoke as tauriInvoke } from '@tauri-apps/api/core'; +import { invoke as tauriInvoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; -export type RecordingStatus = 'idle' | 'recording'; +export type RecordingStatus = "idle" | "recording"; export interface AppSettings { - transcription: { - mode: 'localFirst' | 'apiOnly'; - modelId: string; - language: string | null; - openaiCompatible: { - endpoint: string; - apiKeyConfigured: boolean; - model: string; - }; - }; - insertion: { - method: 'clipboardPaste' | 'keyTyping' | 'manualCopy'; - restoreClipboard: boolean; - waylandManualFallback: boolean; - }; - storage: { - keepAudio: boolean; - audioFormat: 'wav' | 'ogg' | 'mp3'; - }; + transcription: { + mode: "localFirst" | "apiOnly"; + modelId: string; + language: string | null; + openaiCompatible: { + endpoint: string; + apiKeyConfigured: boolean; + model: string; + }; + }; + insertion: { + method: "clipboardPaste" | "keyTyping" | "manualCopy"; + restoreClipboard: boolean; + waylandManualFallback: boolean; + }; + storage: { + keepAudio: boolean; + audioFormat: "wav" | "ogg" | "mp3"; + }; } export interface ModelWithStatus { - model: { - id: string; - displayName: string; - repository: string; - filename: string; - quantization: string; - sizeBytes: number; - languages: string[]; - recommendedForSimplePc: boolean; - }; - status: 'notInstalled' | 'ready' | { error: string }; + model: { + id: string; + displayName: string; + repository: string; + filename: string; + quantization: string; + sizeBytes: number; + languages: string[]; + recommendedForSimplePc: boolean; + }; + status: "notInstalled" | "ready" | { error: string }; } export interface TranscriptDraft { - text: string; - lastMessage: string | null; + text: string; + lastMessage: string | null; } export interface AppSnapshot { - settings: AppSettings; - recordingStatus: RecordingStatus; - transcript: TranscriptDraft; - models: ModelWithStatus[]; + settings: AppSettings; + recordingStatus: RecordingStatus; + transcript: TranscriptDraft; + models: ModelWithStatus[]; } export interface NativeClient { - invoke(command: string, args?: Record): Promise; + invoke(command: string, args?: Record): Promise; } const fallbackSnapshot: AppSnapshot = { - recordingStatus: 'idle', - transcript: { - text: '', - lastMessage: 'Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.', - }, - settings: { - transcription: { - mode: 'localFirst', - modelId: 'parakeet-tdt-0.6b-v3-q8_0', - language: null, - openaiCompatible: { - endpoint: 'https://api.openai.com/v1/audio/transcriptions', - apiKeyConfigured: false, - model: 'whisper-1', - }, - }, - insertion: { - method: 'clipboardPaste', - restoreClipboard: true, - waylandManualFallback: true, - }, - storage: { - keepAudio: false, - audioFormat: 'wav', - }, - }, - models: [ - { - model: { - id: 'parakeet-tdt-0.6b-v3-q8_0', - displayName: 'Parakeet TDT 0.6B v3 Q8', - repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF', - filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf', - quantization: 'Q8_0', - sizeBytes: 745_121_600, - languages: ['pt', 'en', 'es', 'fr', 'de'], - recommendedForSimplePc: true, - }, - status: 'notInstalled', - }, - ], + recordingStatus: "idle", + transcript: { + text: "", + lastMessage: + "Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.", + }, + settings: { + transcription: { + mode: "localFirst", + modelId: "parakeet-tdt-0.6b-v3-q8_0", + language: null, + openaiCompatible: { + endpoint: "https://api.openai.com/v1/audio/transcriptions", + apiKeyConfigured: false, + model: "whisper-1", + }, + }, + insertion: { + method: "clipboardPaste", + restoreClipboard: true, + waylandManualFallback: true, + }, + storage: { + keepAudio: false, + audioFormat: "wav", + }, + }, + models: [ + { + model: { + id: "parakeet-tdt-0.6b-v3-q8_0", + displayName: "Parakeet TDT 0.6B v3 Q8", + repository: "cstr/parakeet-tdt-0.6b-v3-GGUF", + filename: "parakeet-tdt-0.6b-v3-q8_0.gguf", + quantization: "Q8_0", + sizeBytes: 745_121_600, + languages: ["pt", "en", "es", "fr", "de"], + recommendedForSimplePc: true, + }, + status: "notInstalled", + }, + ], }; function createNativeClient(): NativeClient { - return { - invoke: (command: string, args?: Record) => tauriInvoke(command, args), - }; + return { + invoke: (command: string, args?: Record) => + tauriInvoke(command, args), + }; } function element( - tag: K, - className?: string, - text?: string, + tag: K, + className?: string, + text?: string, ): HTMLElementTagNameMap[K] { - const node = document.createElement(tag); - if (className) node.className = className; - if (text !== undefined) node.textContent = text; - return node; + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; } function formatBytes(bytes: number): string { - const mib = bytes / 1024 / 1024; - return `${mib.toFixed(0)} MiB`; + const mib = bytes / 1024 / 1024; + return `${mib.toFixed(0)} MiB`; } async function safeSnapshot(client: NativeClient): Promise { - try { - return await client.invoke('get_app_snapshot'); - } catch { - return fallbackSnapshot; - } + try { + return await client.invoke("get_app_snapshot"); + } catch { + return fallbackSnapshot; + } } export function createTranscriptionApp( - mount: HTMLElement, - client: NativeClient = createNativeClient(), + mount: HTMLElement, + client: NativeClient = createNativeClient(), ): void { - mount.textContent = ''; + mount.textContent = ""; - let snapshot = fallbackSnapshot; + let snapshot = fallbackSnapshot; - const shell = element('section', 'modal-shell'); - shell.setAttribute('aria-labelledby', 'app-title'); + const shell = element("section", "modal-shell"); + shell.setAttribute("aria-labelledby", "app-title"); - const topbar = element('header', 'topbar'); - const titleGroup = element('div', 'title-group'); - const eyebrow = element('p', 'eyebrow', 'Transcript Desktop'); - const title = element('h1', undefined, 'Transcrição'); - title.id = 'app-title'; - const subtitle = element('p', 'subtitle'); - titleGroup.append(eyebrow, title, subtitle); + const topbar = element("header", "topbar"); + const titleGroup = element("div", "title-group"); + const eyebrow = element("p", "eyebrow", "Transcript Desktop"); + const title = element("h1", undefined, "Transcrição"); + title.id = "app-title"; + const subtitle = element("p", "subtitle"); + titleGroup.append(eyebrow, title, subtitle); - const status = element('span', 'status-pill'); - topbar.append(titleGroup, status); + const status = element("span", "status-pill"); + topbar.append(titleGroup, status); - const editorLabel = element('label', 'editor-label', 'Texto transcrito'); - editorLabel.setAttribute('for', 'transcript-editor'); + const editorLabel = element("label", "editor-label", "Texto transcrito"); + editorLabel.setAttribute("for", "transcript-editor"); - const editor = element('textarea', 'transcript-editor') as HTMLTextAreaElement; - editor.id = 'transcript-editor'; - editor.placeholder = 'A transcrição aparecerá aqui para revisão antes da inserção.'; - editor.rows = 10; + const editor = element( + "textarea", + "transcript-editor", + ) as HTMLTextAreaElement; + editor.id = "transcript-editor"; + editor.placeholder = + "A transcrição aparecerá aqui para revisão antes da inserção."; + editor.rows = 10; - const message = element('p', 'message'); - message.setAttribute('role', 'status'); + const message = element("p", "message"); + message.setAttribute("role", "status"); - const actions = element('div', 'actions'); - const recordButton = element('button', 'primary-button', 'Iniciar gravação') as HTMLButtonElement; - const continueButton = element('button', 'secondary-button', '+ Continuar') as HTMLButtonElement; - const copyButton = element('button', 'secondary-button', 'Copiar') as HTMLButtonElement; - const insertButton = element('button', 'secondary-button', 'Inserir no app') as HTMLButtonElement; - const saveButton = element('button', 'ghost-button', 'Salvar texto') as HTMLButtonElement; - actions.append(recordButton, continueButton, copyButton, insertButton, saveButton); + const actions = element("div", "actions"); + const recordButton = element( + "button", + "primary-button", + "Iniciar gravação", + ) as HTMLButtonElement; + const continueButton = element( + "button", + "secondary-button", + "+ Continuar", + ) as HTMLButtonElement; + const copyButton = element( + "button", + "secondary-button", + "Copiar", + ) as HTMLButtonElement; + const insertButton = element( + "button", + "secondary-button", + "Inserir no app", + ) as HTMLButtonElement; + const saveButton = element( + "button", + "ghost-button", + "Salvar texto", + ) as HTMLButtonElement; + actions.append( + recordButton, + continueButton, + copyButton, + insertButton, + saveButton, + ); - const modelPanel = element('aside', 'model-panel'); - const modelTitle = element('h2', undefined, 'Modelo offline'); - const modelDetails = element('p'); - modelPanel.append(modelTitle, modelDetails); + const modelPanel = element("aside", "model-panel"); + const modelTitle = element("h2", undefined, "Modelo offline"); + const modelDetails = element("p"); + modelPanel.append(modelTitle, modelDetails); - shell.append(topbar, editorLabel, editor, message, actions, modelPanel); - mount.append(shell); + shell.append(topbar, editorLabel, editor, message, actions, modelPanel); + mount.append(shell); - const render = (next: AppSnapshot): void => { - snapshot = next; - const isRecording = snapshot.recordingStatus === 'recording'; - status.textContent = isRecording ? 'Gravando' : 'Pronto'; - status.dataset.state = snapshot.recordingStatus; - subtitle.textContent = `${snapshot.settings.transcription.mode === 'localFirst' ? 'Offline primeiro' : 'API'} · idioma ${snapshot.settings.transcription.language ?? 'automático'}`; - editor.value = snapshot.transcript.text; - message.textContent = snapshot.transcript.lastMessage ?? 'Revise o texto antes de inserir.'; - recordButton.textContent = isRecording ? 'Parar e transcrever' : 'Iniciar gravação'; + const render = (next: AppSnapshot): void => { + snapshot = next; + const isRecording = snapshot.recordingStatus === "recording"; + status.textContent = isRecording ? "Gravando" : "Pronto"; + status.dataset.state = snapshot.recordingStatus; + subtitle.textContent = `${snapshot.settings.transcription.mode === "localFirst" ? "Offline primeiro" : "API"} · idioma ${snapshot.settings.transcription.language ?? "automático"}`; + editor.value = snapshot.transcript.text; + message.textContent = + snapshot.transcript.lastMessage ?? "Revise o texto antes de inserir."; + recordButton.textContent = isRecording + ? "Parar e transcrever" + : "Iniciar gravação"; - const firstModel = snapshot.models[0]?.model; - modelDetails.textContent = firstModel - ? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}` - : 'Nenhum modelo offline homologado encontrado.'; - }; + const firstModel = snapshot.models[0]?.model; + modelDetails.textContent = firstModel + ? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}` + : "Nenhum modelo offline homologado encontrado."; + }; - const refresh = async (): Promise => { - render(await safeSnapshot(client)); - }; + const refresh = async (): Promise => { + render(await safeSnapshot(client)); + }; - recordButton.addEventListener('click', () => { - void (async () => { - const command = snapshot.recordingStatus === 'recording' - ? 'stop_recording_and_transcribe' - : 'start_recording'; - render(await client.invoke(command)); - })().catch((error: unknown) => { - message.textContent = error instanceof Error ? error.message : String(error); - }); - }); + const showError = (error: unknown): void => { + message.textContent = error instanceof Error ? error.message : String(error); + }; - continueButton.addEventListener('click', () => { - void client.invoke('start_recording').then(render).catch((error: unknown) => { - message.textContent = error instanceof Error ? error.message : String(error); - }); - }); + const toggleRecording = async (): Promise => { + const command = + snapshot.recordingStatus === "recording" + ? "stop_recording_and_transcribe" + : "start_recording"; + render(await client.invoke(command)); + }; - copyButton.addEventListener('click', () => { - void navigator.clipboard - .writeText(editor.value) - .then(() => { - message.textContent = 'Texto copiado para a área de transferência.'; - }) - .catch(() => { - message.textContent = 'Não foi possível copiar automaticamente. Selecione o texto manualmente.'; - }); - }); + recordButton.addEventListener("click", () => { + void toggleRecording().catch(showError); + }); - insertButton.addEventListener('click', () => { - void client - .invoke<{ message: string }>('request_insert_text', { text: editor.value }) - .then((result) => { - message.textContent = result.message; - }) - .catch((error: unknown) => { - message.textContent = error instanceof Error ? error.message : String(error); - }); - }); + continueButton.addEventListener("click", () => { + void client + .invoke("start_recording") + .then(render) + .catch(showError); + }); - saveButton.addEventListener('click', () => { - const blob = new Blob([editor.value], { type: 'text/plain;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'transcricao.txt'; - link.click(); - URL.revokeObjectURL(url); - message.textContent = 'Arquivo de texto preparado para download.'; - }); + copyButton.addEventListener("click", () => { + void navigator.clipboard + .writeText(editor.value) + .then(() => { + message.textContent = "Texto copiado para a área de transferência."; + }) + .catch(() => { + message.textContent = + "Não foi possível copiar automaticamente. Selecione o texto manualmente."; + }); + }); - void refresh(); + insertButton.addEventListener("click", () => { + void client + .invoke<{ message: string }>("request_insert_text", { + text: editor.value, + }) + .then((result) => { + message.textContent = result.message; + }) + .catch(showError); + }); + + saveButton.addEventListener("click", () => { + const blob = new Blob([editor.value], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "transcricao.txt"; + link.click(); + URL.revokeObjectURL(url); + message.textContent = "Arquivo de texto preparado para download."; + }); + + void listen("transcript://toggle-recording", () => { + void toggleRecording().catch(showError); + }).catch(() => { + // Browser preview and tests do not expose the Tauri event bridge. + }); + + void refresh(); }