From 81850b5c35379d9cbaaead626e548475fd9b5c1c Mon Sep 17 00:00:00 2001 From: kas Date: Wed, 4 Feb 2026 17:07:26 -0600 Subject: [PATCH] Add manual breakpoints for preview overlay --- src/bin/ears-dictation.rs | 508 ++++++++++++++++++++++++++++++++++---- src/config.rs | 6 + src/gtk_overlay.rs | 114 ++++----- src/preview_buffer.rs | 62 +++++ 4 files changed, 566 insertions(+), 124 deletions(-) diff --git a/src/bin/ears-dictation.rs b/src/bin/ears-dictation.rs index 29959b7e..04cb04e0 100644 --- a/src/bin/ears-dictation.rs +++ b/src/bin/ears-dictation.rs @@ -693,6 +693,8 @@ async fn main() -> Result<()> { let (commit_tx, commit_rx) = bounded::<()>(1); let (respawn_overlay_tx, respawn_overlay_rx) = bounded::<()>(1); let (discard_tx, discard_rx) = bounded::<()>(1); + let (breakpoint_tx, breakpoint_rx) = bounded::<()>(1); + let breakpoint_in_progress = Arc::new(AtomicBool::new(false)); let (accuracy_toggle_tx, accuracy_toggle_rx) = unbounded::<()>(); let accuracy_enabled = Arc::new(AtomicBool::new(args.accuracy_enabled)); let preview_autocommit_secs = parse_env_u64("EARS_PREVIEW_AUTOCOMMIT_SECS", AUTO_COMMIT_PAUSE_SECS); @@ -708,6 +710,8 @@ async fn main() -> Result<()> { let preview_window_width = config.dictation.preview.window_width; #[cfg(feature = "preview-overlay")] let preview_window_height = config.dictation.preview.window_height; + #[cfg(feature = "preview-overlay")] + let breakpoint_review = config.dictation.preview.breakpoint_review; // Store paste hotkey for clipboard operations #[cfg(feature = "preview-overlay")] @@ -826,6 +830,29 @@ async fn main() -> Result<()> { }); } + // SIGRTMIN+2 triggers breakpoint (correct + checkpoint, continue) + #[cfg(feature = "preview-overlay")] + { + let sig_breakpoint_tx = breakpoint_tx.clone(); + let sig_breakpoint_flag = breakpoint_in_progress.clone(); + tokio::spawn(async move { + let mut sigrtmin2 = signal(SignalKind::from_raw(libc::SIGRTMIN() + 2)) + .expect("failed to register SIGRTMIN+2 handler"); + loop { + sigrtmin2.recv().await; + if sig_breakpoint_flag + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + eprintln!("SIGRTMIN+2: breakpoint requested"); + let _ = sig_breakpoint_tx.send(()); + } else { + eprintln!("SIGRTMIN+2: breakpoint already in progress, ignoring"); + } + } + }); + } + let hotkey_running = running.clone(); let hotkey_capturing = capturing.clone(); let hotkey_grace = capture_grace_until.clone(); @@ -848,6 +875,10 @@ async fn main() -> Result<()> { let hotkey_commit_tx = commit_tx.clone(); let hotkey_accuracy_tx = accuracy_toggle_tx.clone(); #[cfg(feature = "preview-overlay")] + let hotkey_breakpoint_tx = breakpoint_tx.clone(); + #[cfg(feature = "preview-overlay")] + let hotkey_breakpoint_flag = breakpoint_in_progress.clone(); + #[cfg(feature = "preview-overlay")] let preview_config_hotkeys = config.dictation.preview.clone(); let _hotkey_preview_mode = preview_mode; #[cfg(feature = "preview-overlay")] @@ -879,6 +910,15 @@ async fn main() -> Result<()> { } else { (false, false, false, rdev::Key::Unknown(0)) }; + #[cfg(feature = "preview-overlay")] + let (bp_ctrl, bp_shift, bp_alt, bp_key) = if hotkey_preview_mode { + let combo = parse_combo(&preview_config_hotkeys.breakpoint_hotkey); + eprintln!("Breakpoint hotkey: {} -> ctrl:{} shift:{} alt:{} key:{:?}", + preview_config_hotkeys.breakpoint_hotkey, combo.0, combo.1, combo.2, combo.3); + combo + } else { + (false, false, false, rdev::Key::Unknown(0)) + }; if let Err(e) = listen(move |event| -> () { static mut CTRL: bool = false; @@ -998,6 +1038,24 @@ async fn main() -> Result<()> { eprintln!("Commit hotkey pressed"); let _ = hotkey_commit_tx.send(()); } + + // Preview mode: breakpoint hotkey + if hotkey_preview_mode + && CTRL == bp_ctrl + && SHIFT == bp_shift + && ALT == bp_alt + && k == bp_key + { + eprintln!("Breakpoint hotkey pressed"); + if hotkey_breakpoint_flag + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + let _ = hotkey_breakpoint_tx.send(()); + } else { + eprintln!("Breakpoint already in progress, ignoring"); + } + } } // Suppress unused warnings when preview-overlay is not enabled #[cfg(not(feature = "preview-overlay"))] @@ -1269,11 +1327,11 @@ async fn main() -> Result<()> { eprintln!("Preview overlay spawned"); let accuracy_on = should_run_accuracy(&args, accuracy_enabled.load(Ordering::Relaxed)); let info = format!( - "profile: {} | accuracy: {} ({}) | model: {}", + "profile: {} | correction: {} | accuracy: {} ({})", overlay_profile_label, + overlay_model_label, if accuracy_on { "on" } else { "off" }, overlay_accuracy_model_label, - overlay_model_label, ); let _ = handle.set_info(info); overlay_handle = Some(handle); @@ -1301,11 +1359,86 @@ async fn main() -> Result<()> { recv(checkpoint_rx) -> _ => { #[cfg(feature = "preview-overlay")] if let Some(ref handle) = overlay_handle { - // Drain any duplicate checkpoint signals - while checkpoint_rx.try_recv().is_ok() {} - eprintln!("[CHECKPOINT] Processing..."); + if breakpoint_in_progress.load(Ordering::Relaxed) { + while checkpoint_rx.try_recv().is_ok() {} + eprintln!("[CHECKPOINT] Breakpoint in progress, skipping"); + } else { + // Drain any duplicate checkpoint signals + while checkpoint_rx.try_recv().is_ok() {} + eprintln!("[CHECKPOINT] Processing..."); - // Drain in-flight words from WebSocket before checkpointing + // Drain in-flight words from WebSocket before checkpointing + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + loop { + match tokio::time::timeout( + std::time::Duration::from_millis(100), + read.next() + ).await { + Ok(Some(Ok(Message::Text(text)))) => { + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(word) = json.get("word").and_then(|v| v.as_str()) { + let has_alphanumeric = word.chars().any(|c| c.is_alphanumeric()); + if !word.is_empty() && has_alphanumeric { + let _ = handle.send_word(word.to_string()); + #[cfg(feature = "llm-correct")] + { + correction_buffer.add_word(word); + overlay_word_count += 1; + } + } + } + } + } + _ => break, + } + } + + if let Err(e) = handle.checkpoint() { + eprintln!("[CHECKPOINT ERROR] {}", e); + } + // Reset correction state — checkpointed text is pasted, start fresh + // Must clear paragraph too, otherwise auto-commit re-pastes + // the checkpointed text from committed_sections + #[cfg(feature = "llm-correct")] + { + correction_buffer.take_paragraph(); + overlay_word_count = 0; + } + } + } + } + // Preview mode: commit (paste all, close overlay, pause) + recv(commit_rx) -> _ => { + #[cfg(feature = "preview-overlay")] + if let Some(ref handle) = overlay_handle { + if breakpoint_in_progress.load(Ordering::Relaxed) { + while commit_rx.try_recv().is_ok() {} + eprintln!("[COMMIT] Breakpoint in progress, skipping"); + } else { + // Drain any duplicate commit signals + while commit_rx.try_recv().is_ok() {} + eprintln!("[COMMIT] Processing..."); + if let Err(e) = handle.commit() { + eprintln!("[COMMIT ERROR] {}", e); + } + #[cfg(feature = "llm-correct")] + { overlay_word_count = 0; } + // Also pause capturing + *capturing.lock().unwrap() = false; + eprintln!("[COMMIT] Paused capturing"); + } + } + } + // Preview mode: breakpoint (correct + checkpoint, continue) + recv(breakpoint_rx) -> _ => { + #[cfg(feature = "preview-overlay")] + { + if preview_mode { + // Drain any duplicate breakpoint signals + while breakpoint_rx.try_recv().is_ok() {} + eprintln!("[BREAKPOINT] Processing..."); + + // Drain in-flight words from WebSocket before snapshot tokio::time::sleep(std::time::Duration::from_millis(100)).await; loop { match tokio::time::timeout( @@ -1317,12 +1450,12 @@ async fn main() -> Result<()> { if let Some(word) = json.get("word").and_then(|v| v.as_str()) { let has_alphanumeric = word.chars().any(|c| c.is_alphanumeric()); if !word.is_empty() && has_alphanumeric { - let _ = handle.send_word(word.to_string()); - #[cfg(feature = "llm-correct")] - { - correction_buffer.add_word(word); - overlay_word_count += 1; + last_word_time = Instant::now(); + if let Some(ref handle) = overlay_handle { + let _ = handle.send_word(word.to_string()); } + correction_buffer.add_word(word); + overlay_word_count += 1; } } } @@ -1331,34 +1464,296 @@ async fn main() -> Result<()> { } } - if let Err(e) = handle.checkpoint() { - eprintln!("[CHECKPOINT ERROR] {}", e); + let pre_breakpoint_word_count = overlay_word_count; + overlay_word_count = 0; + let mut final_text: Option = None; + + if correction_buffer.paragraph_len() < 2 { + let (paragraph, _word_count, _char_count) = correction_buffer.take_paragraph(); + if !paragraph.is_empty() || pre_breakpoint_word_count > 0 { + final_text = Some(paragraph); + } + } else { + let (paragraph, _word_count, _char_count) = correction_buffer.take_paragraph(); + correction_buffer.reset_chunk(); + + let accuracy_samples: Vec = { + let mut buf = accuracy_buffer.lock().unwrap(); + buf.drain(..).collect() + }; + + if let Some(ref handle) = overlay_handle { + let _ = handle.breakpoint_start(); + } + write_status("processing"); + + let profile: CorrectionProfile = args.correction_profile.into(); + let accuracy_on = should_run_accuracy(&args, accuracy_enabled.load(Ordering::Relaxed)); + + let mut llm_candidate: Option = None; + let mut llm_candidate_review: Option = None; + let mut llm_candidate_safe = false; + let mut accuracy_candidate: Option = None; + let mut accuracy_candidate_review: Option = None; + let mut accuracy_candidate_safe = false; + + let mut llm_result: Option> = None; + let mut accuracy_result: Option>> = None; + + if !breakpoint_review { + let mut correction_fut = tokio::time::timeout( + std::time::Duration::from_secs(15), + corrector.correct_paragraph(¶graph), + ); + tokio::pin!(correction_fut); + let mut accuracy_fut = if accuracy_on { + Some(Box::pin(tokio::time::timeout( + std::time::Duration::from_secs(15), + run_accuracy_pass_from_samples(accuracy_samples, &args), + ))) + } else { + None + }; + let mut llm_done = false; + let mut accuracy_done = !accuracy_on; + + loop { + if llm_done && accuracy_done { + break; + } + tokio::select! { + result = &mut correction_fut, if !llm_done => { + llm_done = true; + match result { + Ok(inner) => llm_result = Some(inner), + Err(e) => eprintln!("[BREAKPOINT LLM TIMEOUT] {}", e), + } + } + result = async { + if let Some(fut) = &mut accuracy_fut { + fut.as_mut().await + } else { + std::future::pending::>, tokio::time::error::Elapsed>>().await + } + }, if !accuracy_done => { + accuracy_done = true; + match result { + Ok(inner) => accuracy_result = Some(inner), + Err(e) => eprintln!("[BREAKPOINT ACCURACY TIMEOUT] {}", e), + } + } + msg = read.next() => { + if let Some(Ok(Message::Text(text))) = msg { + if let Ok(json) = serde_json::from_str::(&text) { + if let Some(word) = json.get("word").and_then(|v| v.as_str()) { + let has_alphanumeric = word.chars().any(|c| c.is_alphanumeric()); + if !word.is_empty() && has_alphanumeric { + last_word_time = Instant::now(); + if let Some(ref handle) = overlay_handle { + let _ = handle.send_word(word.to_string()); + } + correction_buffer.add_word(word); + overlay_word_count += 1; + } + } + } + } + } + } + } + } else { + match tokio::time::timeout( + std::time::Duration::from_secs(15), + corrector.correct_paragraph(¶graph), + ) + .await + { + Ok(inner) => llm_result = Some(inner), + Err(e) => eprintln!("[BREAKPOINT LLM TIMEOUT] {}", e), + } + + if accuracy_on { + match tokio::time::timeout( + std::time::Duration::from_secs(15), + run_accuracy_pass_from_samples(accuracy_samples, &args), + ) + .await + { + Ok(inner) => accuracy_result = Some(inner), + Err(e) => eprintln!("[BREAKPOINT ACCURACY TIMEOUT] {}", e), + } + } + } + + if let Some(result) = llm_result { + match result { + Ok(corrected) if corrected != paragraph => { + let processed = postprocess_candidate(profile, &corrected); + if processed != paragraph { + llm_candidate_review = Some(processed.clone()); + } + if !is_safe_correction(¶graph, &processed, profile) { + eprintln!("[BREAKPOINT LLM SKIP] low similarity"); + } else { + eprintln!("[BREAKPOINT LLM CANDIDATE] '{}' -> '{}'", paragraph, processed); + llm_candidate = Some(processed); + llm_candidate_safe = true; + } + } + Ok(_) => {} + Err(e) => eprintln!("[BREAKPOINT LLM ERROR] {}", e), + } + } + + if let Some(result) = accuracy_result { + match result { + Ok(Some(accuracy_text)) => { + let normalized = normalize_accuracy_text(¶graph, &accuracy_text); + let processed = postprocess_candidate(profile, &normalized); + if processed != paragraph { + accuracy_candidate_review = Some(processed.clone()); + } + if processed != paragraph + && is_safe_correction(¶graph, &processed, profile) + { + eprintln!("[BREAKPOINT ACCURACY CANDIDATE] '{}' -> '{}'", paragraph, processed); + accuracy_candidate = Some(processed); + accuracy_candidate_safe = true; + } else { + eprintln!("[BREAKPOINT ACCURACY] No changes needed"); + } + } + Ok(None) => {} + Err(e) => eprintln!("[BREAKPOINT ACCURACY ERROR] {}", e), + } + } + + if breakpoint_review { + if let Some(ref handle) = overlay_handle { + let mut options: Vec = Vec::new(); + options.push(ReviewOption { + choice: ReviewChoice::Raw, + text: paragraph.clone(), + safe: true, + }); + + let mut final_index: Option = None; + if let Some(ref text) = llm_candidate_review { + options.push(ReviewOption { + choice: ReviewChoice::Final, + text: text.clone(), + safe: llm_candidate_safe, + }); + final_index = Some(options.len() - 1); + } + + let mut accuracy_index: Option = None; + if let Some(ref text) = accuracy_candidate_review { + options.push(ReviewOption { + choice: ReviewChoice::Accuracy, + text: text.clone(), + safe: accuracy_candidate_safe, + }); + accuracy_index = Some(options.len() - 1); + } + + options.push(ReviewOption { + choice: ReviewChoice::Cancel, + text: "Cancel (do not paste)".to_string(), + safe: true, + }); + + let selected = if let Some(idx) = accuracy_index { + idx + } else if let Some(idx) = final_index { + idx + } else { + 0 + }; + let _ = handle.show_review(options, selected); + + let raw_for_log = paragraph.clone(); + let llm_for_log = llm_candidate_review.clone(); + let accuracy_for_log = accuracy_candidate_review.clone(); + let model_for_log = overlay_model_label.clone(); + loop { + if let Some(response) = handle.try_recv() { + match response { + OverlayResponse::ReviewSelection { choice, text, .. } => { + log_review_decision( + profile, + accuracy_on, + &model_for_log, + &raw_for_log, + llm_for_log.as_deref(), + llm_candidate_safe, + accuracy_for_log.as_deref(), + accuracy_candidate_safe, + choice, + &text, + ); + final_text = Some(text); + break; + } + OverlayResponse::Cancel => { + log_review_decision( + profile, + accuracy_on, + &model_for_log, + &raw_for_log, + llm_for_log.as_deref(), + llm_candidate_safe, + accuracy_for_log.as_deref(), + accuracy_candidate_safe, + ReviewChoice::Cancel, + "", + ); + final_text = Some(paragraph.clone()); + break; + } + _ => {} + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } else { + final_text = Some(paragraph.clone()); + } + } else { + if let Some((chosen_text, source)) = + choose_best_correction(¶graph, llm_candidate.clone(), accuracy_candidate.clone()) + { + if chosen_text != paragraph { + eprintln!( + "[BREAKPOINT APPLY] source={:?} '{}' -> '{}'", + source, + paragraph, + chosen_text + ); + final_text = Some(chosen_text); + } + } + if final_text.is_none() { + final_text = Some(paragraph.clone()); + } + } } - // Reset correction state — checkpointed text is pasted, start fresh - // Must clear paragraph too, otherwise auto-commit re-pastes - // the checkpointed text from committed_sections - #[cfg(feature = "llm-correct")] - { - correction_buffer.take_paragraph(); - overlay_word_count = 0; + + if let Some(text) = final_text { + if let Some(ref handle) = overlay_handle { + if !text.is_empty() || pre_breakpoint_word_count > 0 { + let _ = handle.breakpoint_complete(text, pre_breakpoint_word_count); + } + } } - } - } - // Preview mode: commit (paste all, close overlay, pause) - recv(commit_rx) -> _ => { - #[cfg(feature = "preview-overlay")] - if let Some(ref handle) = overlay_handle { - // Drain any duplicate commit signals - while commit_rx.try_recv().is_ok() {} - eprintln!("[COMMIT] Processing..."); - if let Err(e) = handle.commit() { - eprintln!("[COMMIT ERROR] {}", e); + if let Some(ref handle) = overlay_handle { + let _ = handle.set_status(OverlayStatus::Listening); } - #[cfg(feature = "llm-correct")] - { overlay_word_count = 0; } - // Also pause capturing - *capturing.lock().unwrap() = false; - eprintln!("[COMMIT] Paused capturing"); + write_status("listening"); + } + + breakpoint_in_progress.store(false, Ordering::Release); + last_word_time = Instant::now(); } } // Respawn overlay when toggle turns on and overlay is closed @@ -1368,11 +1763,11 @@ async fn main() -> Result<()> { if let Some(ref handle) = overlay_handle { let accuracy_on = should_run_accuracy(&args, accuracy_enabled.load(Ordering::Relaxed)); let info = format!( - "profile: {} | accuracy: {} ({}) | model: {}", + "profile: {} | correction: {} | accuracy: {} ({})", overlay_profile_label, + overlay_model_label, if accuracy_on { "on" } else { "off" }, overlay_accuracy_model_label, - overlay_model_label, ); let _ = handle.set_info(info); let _ = handle.set_status(OverlayStatus::Listening); @@ -1384,11 +1779,11 @@ async fn main() -> Result<()> { eprintln!("Preview overlay respawned"); let accuracy_on = should_run_accuracy(&args, accuracy_enabled.load(Ordering::Relaxed)); let info = format!( - "profile: {} | accuracy: {} ({}) | model: {}", + "profile: {} | correction: {} | accuracy: {} ({})", overlay_profile_label, + overlay_model_label, if accuracy_on { "on" } else { "off" }, overlay_accuracy_model_label, - overlay_model_label, ); let _ = handle.set_info(info); let _ = handle.set_status(OverlayStatus::Listening); @@ -1428,11 +1823,11 @@ async fn main() -> Result<()> { #[cfg(feature = "preview-overlay")] if let Some(ref handle) = overlay_handle { let info = format!( - "profile: {} | accuracy: {} ({}) | model: {}", + "profile: {} | correction: {} | accuracy: {} ({})", overlay_profile_label, + overlay_model_label, if accuracy_on { "on" } else { "off" }, overlay_accuracy_model_label, - overlay_model_label, ); let _ = handle.set_info(info); } @@ -2795,16 +3190,7 @@ fn should_run_accuracy(args: &Args, accuracy_enabled: bool) -> bool { } #[cfg(feature = "llm-correct")] -async fn run_accuracy_pass( - accuracy_buffer: &Arc>>, - args: &Args, -) -> Result> { - let samples: Vec = { - let mut buf = accuracy_buffer.lock().unwrap(); - let samples: Vec = buf.iter().copied().collect(); - buf.clear(); - samples - }; +async fn run_accuracy_pass_from_samples(samples: Vec, args: &Args) -> Result> { if samples.is_empty() { return Ok(None); } @@ -2843,6 +3229,20 @@ async fn run_accuracy_pass( Ok(None) } +#[cfg(feature = "llm-correct")] +async fn run_accuracy_pass( + accuracy_buffer: &Arc>>, + args: &Args, +) -> Result> { + let samples: Vec = { + let mut buf = accuracy_buffer.lock().unwrap(); + let samples: Vec = buf.iter().copied().collect(); + buf.clear(); + samples + }; + run_accuracy_pass_from_samples(samples, args).await +} + #[cfg(feature = "llm-correct")] fn f32_to_i16_bytes(samples: &[f32]) -> Vec { let mut out = Vec::with_capacity(samples.len() * 2); @@ -2920,7 +3320,9 @@ fn parse_combo(s: &str) -> (bool, bool, bool, rdev::Key) { // Special keys "return" | "enter" => key = rdev::Key::Return, "kpadd" | "kp_add" | "numpadplus" => key = rdev::Key::KpPlus, - "kpminus" | "kp_minus" | "numpadminus" => key = rdev::Key::KpMinus, + "kpminus" | "kp_minus" | "kpsubtract" | "kp_subtract" | "numpadminus" | "numpadsubtract" => { + key = rdev::Key::KpMinus + } "kpenter" | "kp_enter" | "numpadenter" => key = rdev::Key::KpReturn, "space" => key = rdev::Key::Space, "tab" => key = rdev::Key::Tab, diff --git a/src/config.rs b/src/config.rs index e126dfc1..c060682e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,10 @@ pub struct PreviewConfig { pub commit_hotkey: String, /// Hotkey to checkpoint (paste current buffer, continue) (e.g., "kp_add") pub checkpoint_hotkey: String, + /// Hotkey to trigger breakpoint (correct + checkpoint, continue) + pub breakpoint_hotkey: String, + /// Show review popup on breakpoint (false = auto-apply best correction) + pub breakpoint_review: bool, /// Hotkey to paste from clipboard (e.g., "ctrl+v") pub paste_hotkey: String, /// Window width in pixels @@ -102,6 +106,8 @@ impl Default for PreviewConfig { enabled: false, commit_hotkey: "ctrl+shift+Return".to_string(), checkpoint_hotkey: "KpAdd".to_string(), + breakpoint_hotkey: "KpSubtract".to_string(), + breakpoint_review: false, paste_hotkey: "ctrl+shift+v".to_string(), window_width: 400, window_height: 300, diff --git a/src/gtk_overlay.rs b/src/gtk_overlay.rs index 63dd6f9b..c76f33d9 100644 --- a/src/gtk_overlay.rs +++ b/src/gtk_overlay.rs @@ -35,6 +35,10 @@ pub enum OverlayCommand { Checkpoint, /// Commit requested (paste all, close) Commit, + /// Breakpoint started (set status to correcting) + BreakpointStart, + /// Breakpoint completed (commit corrected text, preserve new words) + BreakpointComplete { corrected: String, boundary: usize }, /// Close the overlay without committing Close, /// Show the overlay window @@ -65,7 +69,7 @@ impl OverlayStatus { OverlayStatus::Listening => "listening", OverlayStatus::Correcting => "correcting...", OverlayStatus::Paused => "paused", - OverlayStatus::Review => "review (↑/↓ select, → paste)", + OverlayStatus::Review => "review (↑/↓ select, → accept, P mode)", } } } @@ -157,6 +161,20 @@ impl OverlayHandle { .map_err(|e| anyhow::anyhow!("Failed to send commit to overlay: {}", e)) } + /// Request a breakpoint start (set status to correcting) + pub fn breakpoint_start(&self) -> Result<()> { + self.command_tx + .send_blocking(OverlayCommand::BreakpointStart) + .map_err(|e| anyhow::anyhow!("Failed to send breakpoint start to overlay: {}", e)) + } + + /// Complete a breakpoint (commit corrected text, preserve new words) + pub fn breakpoint_complete(&self, corrected: String, boundary: usize) -> Result<()> { + self.command_tx + .send_blocking(OverlayCommand::BreakpointComplete { corrected, boundary }) + .map_err(|e| anyhow::anyhow!("Failed to send breakpoint complete to overlay: {}", e)) + } + /// Update the status indicator pub fn set_status(&self, status: OverlayStatus) -> Result<()> { self.command_tx @@ -316,8 +334,7 @@ struct OverlayState { review_active: bool, review_options: Vec, review_selected: usize, - pending_selection: Option, - pending_type_output: bool, + review_type_output: bool, response_tx: Sender, scrolled: ScrolledWindow, content_box: GtkBox, @@ -335,13 +352,11 @@ impl OverlayState { if self.review_active { self.update_review_display(); - if self.pending_selection.is_some() && self.pending_type_output { - self.word_count_label - .set_label("release shift to type"); - } else { - self.word_count_label - .set_label("↑/↓ select · → paste · Esc cancel"); - } + let mode_label = if self.review_type_output { "type" } else { "paste" }; + self.word_count_label.set_label(&format!( + "↑/↓ select · → accept · P mode: {} · Esc cancel", + mode_label + )); return; } @@ -573,8 +588,7 @@ fn build_window( review_active: false, review_options: Vec::new(), review_selected: 0, - pending_selection: None, - pending_type_output: false, + review_type_output: false, response_tx, scrolled: scrolled.clone(), content_box, @@ -592,17 +606,13 @@ fn build_window( let window_key = window.clone(); let key_controller = gtk4::EventControllerKey::new(); let response_tx_key = response_tx_clone.clone(); - key_controller.connect_key_pressed(move |_, key, _, modifiers| { + key_controller.connect_key_pressed(move |_, key, _, _| { let mut state = state_key.borrow_mut(); if !state.review_active { return glib::Propagation::Proceed; } - if state.pending_selection.is_some() && state.pending_type_output { - return glib::Propagation::Stop; - } let option_count = state.review_options.len(); - let type_output = modifiers.contains(gdk::ModifierType::SHIFT_MASK); match key { gdk::Key::Up => { if option_count > 0 { @@ -618,19 +628,16 @@ fn build_window( } glib::Propagation::Stop } + gdk::Key::p | gdk::Key::P => { + state.review_type_output = !state.review_type_output; + state.update_display(); + glib::Propagation::Stop + } gdk::Key::Right | gdk::Key::Return | gdk::Key::KP_Enter => { let selected = state.review_options.get(state.review_selected).cloned(); - if type_output { - state.pending_selection = selected; - state.pending_type_output = true; - state.update_display(); - return glib::Propagation::Stop; - } - + let type_output = state.review_type_output; state.review_active = false; state.review_options.clear(); - state.pending_selection = None; - state.pending_type_output = false; state.status = OverlayStatus::Paused; state.update_status_display(); state.update_display(); @@ -659,8 +666,6 @@ fn build_window( gdk::Key::Escape => { state.review_active = false; state.review_options.clear(); - state.pending_selection = None; - state.pending_type_output = false; state.status = OverlayStatus::Paused; state.update_status_display(); state.update_display(); @@ -676,49 +681,6 @@ fn build_window( _ => glib::Propagation::Proceed, } }); - let state_release = state.clone(); - let window_release = window.clone(); - let response_tx_release = response_tx_clone.clone(); - key_controller.connect_key_released(move |_, key, _, modifiers| { - let mut state = state_release.borrow_mut(); - if !state.review_active || state.pending_selection.is_none() { - return glib::Propagation::Proceed; - } - if !matches!(key, gdk::Key::Shift_L | gdk::Key::Shift_R) { - return glib::Propagation::Stop; - } - if modifiers.contains(gdk::ModifierType::SHIFT_MASK) { - return glib::Propagation::Stop; - } - let selected = state.pending_selection.take(); - state.pending_type_output = false; - state.review_active = false; - state.review_options.clear(); - state.status = OverlayStatus::Paused; - state.update_status_display(); - state.update_display(); - drop(state); - - window_release.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::None); - window_release.set_visible(false); - center_window(&window_release, default_width, default_height); - - if let Some(option) = selected { - match option.choice { - ReviewChoice::Cancel => { - let _ = response_tx_release.send(OverlayResponse::Cancel); - } - _ => { - let _ = response_tx_release.send(OverlayResponse::ReviewSelection { - choice: option.choice, - text: option.text, - type_output: true, - }); - } - } - } - glib::Propagation::Stop - }); window.add_controller(key_controller); // Handle commands from main thread using spawn_local @@ -757,6 +719,16 @@ fn build_window( drop(state); window_clone.set_visible(false); } + OverlayCommand::BreakpointStart => { + state.status = OverlayStatus::Correcting; + state.update_status_display(); + } + OverlayCommand::BreakpointComplete { corrected, boundary } => { + state.buffer.breakpoint_complete(corrected, boundary); + state.status = OverlayStatus::Listening; + state.update_status_display(); + state.update_display(); + } OverlayCommand::Close => { state.review_active = false; state.review_options.clear(); diff --git a/src/preview_buffer.rs b/src/preview_buffer.rs index ff161671..d5a22c92 100644 --- a/src/preview_buffer.rs +++ b/src/preview_buffer.rs @@ -114,6 +114,21 @@ impl PreviewBuffer { Some(text) } + /// Complete a breakpoint: push corrected text to committed, preserve post-breakpoint words. + /// `boundary` = number of active_words that existed when breakpoint fired. + /// Words added after the breakpoint (during correction) are preserved in active_words. + pub fn breakpoint_complete(&mut self, corrected: String, boundary: usize) -> Option { + if corrected.is_empty() && boundary == 0 { + return None; + } + self.committed_sections.push(corrected.clone()); + let actual_boundary = boundary.min(self.active_words.len()); + self.active_words.drain(..actual_boundary); + self.corrected_active = None; + self.correction_pending = false; + Some(corrected) + } + /// Commit: get all text (committed + active), clear everything pub fn commit(&mut self) -> Option { let mut parts = Vec::new(); @@ -285,4 +300,51 @@ mod tests { assert_eq!(buffer.active_text(), "i cannot go"); assert_eq!(buffer.active_word_count(), 3); } + + #[test] + fn test_breakpoint_complete_preserves_new_words() { + let mut buffer = PreviewBuffer::new(); + for w in ["alpha", "beta", "gamma", "delta"] { + buffer.add_word(w.to_string()); + } + // Simulate new words arriving during correction + buffer.add_word("epsilon".to_string()); + buffer.add_word("zeta".to_string()); + + let result = buffer.breakpoint_complete("alpha beta gamma delta".to_string(), 4); + assert_eq!(result, Some("alpha beta gamma delta".to_string())); + assert_eq!(buffer.active_text(), "epsilon zeta"); + assert_eq!(buffer.committed_sections.len(), 1); + } + + #[test] + fn test_breakpoint_complete_empty_buffer() { + let mut buffer = PreviewBuffer::new(); + let result = buffer.breakpoint_complete(String::new(), 0); + assert_eq!(result, None); + assert!(buffer.active_words.is_empty()); + assert!(buffer.committed_sections.is_empty()); + } + + #[test] + fn test_breakpoint_complete_boundary_exceeds_len() { + let mut buffer = PreviewBuffer::new(); + buffer.add_word("one".to_string()); + buffer.add_word("two".to_string()); + let result = buffer.breakpoint_complete("one two".to_string(), 5); + assert_eq!(result, Some("one two".to_string())); + assert!(buffer.active_words.is_empty()); + assert_eq!(buffer.committed_sections.len(), 1); + } + + #[test] + fn test_breakpoint_complete_zero_boundary() { + let mut buffer = PreviewBuffer::new(); + buffer.add_word("keep".to_string()); + buffer.add_word("these".to_string()); + let result = buffer.breakpoint_complete("committed".to_string(), 0); + assert_eq!(result, Some("committed".to_string())); + assert_eq!(buffer.active_text(), "keep these"); + assert_eq!(buffer.committed_sections.len(), 1); + } }