feat(preview): floating transcription overlay with listening→live→cleanup→final phases

Ported the best bits of OpenWhispr's TranscriptionPreviewOverlay into Kon's
window conventions. Off by default — toggle in Settings → Output → "Floating
preview when Kon is unfocused". Opens only when the main window isn't
focused at the start of a recording, so it never adds noise when the user
can already see the transcript in the main surface.

Phase state machine (src/routes/preview/+page.svelte):
- listening — pulsing dot, no text yet
- live      — animated bars + streaming raw Whisper output
- cleanup   — accent bars while the LLM cleanup pass runs
- final     — checkmark + formatted text + 4s auto-hide

Data plumbing: raw segment text is captured before post_process_segments in
live.rs (new raw_text field on LiveResultMessage) and in transcription.rs
(new raw_text in the transcription-result payload). DictationPage forwards
raw_text to the overlay via Tauri global events — preview-listening on
start, preview-append per chunk, preview-cleanup before the LLM pass,
preview-final with the formatted text, preview-hide when a run produced no
transcript (empty recording / cancel).

Window is always_on_top, skip_taskbar, focused=false so it never steals
focus from whatever the user is dictating into. open_preview_window shows
an existing hidden preview or builds it fresh; close_preview_window hides
without destroying so the next open is instant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 08:49:29 +01:00
parent 42b32a4f1a
commit eb60a8bfd3
10 changed files with 403 additions and 0 deletions

View File

@@ -41,6 +41,58 @@ pub async fn open_task_window(app: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
/// Open the always-on-top transcription preview window. Idempotent — an
/// existing window is shown and focused; otherwise a new one is built.
/// The preview is passive: it subscribes to the `transcription-result`
/// event and the cross-window `preview-*` events that DictationPage fires
/// as it moves through the phases of a dictation run.
#[tauri::command]
pub async fn open_preview_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("transcription-preview") {
window.show().map_err(|e| e.to_string())?;
// Intentionally NOT set_focus — the preview window is meant to
// appear next to whatever the user is typing into; stealing focus
// defeats the whole point.
return Ok(());
}
let use_native_decorations = cfg!(target_os = "linux");
let mut builder = WebviewWindowBuilder::new(
&app,
"transcription-preview",
WebviewUrl::App("/preview".into()),
)
.title("Kon — Preview")
.inner_size(420.0, 200.0)
.min_inner_size(360.0, 140.0)
.max_inner_size(520.0, 360.0)
.always_on_top(true)
.skip_taskbar(true)
.focused(false)
.decorations(use_native_decorations)
.resizable(true);
if let Some(script) = app.try_state::<PreferencesScript>() {
if !script.0.is_empty() {
builder = builder.initialization_script(&script.0);
}
}
builder.build().map_err(|e| e.to_string())?;
Ok(())
}
/// Hide the transcription preview window without destroying it so the next
/// open is instant. Returns Ok even when no preview window exists.
#[tauri::command]
pub async fn close_preview_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("transcription-preview") {
window.hide().map_err(|e| e.to_string())?;
}
Ok(())
}
/// Open the transcript viewer window.
#[tauri::command]
pub async fn open_viewer_window(app: tauri::AppHandle) -> Result<(), String> {