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
203 changes: 203 additions & 0 deletions docs/research/issue-1238.md

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2667,6 +2667,7 @@ struct HistoryProviderAttribution {
}

struct CoreEditObservationSink {
session_id: SessionId,
settings: crate::shared_types::VocabularyLearningSettings,
expected_generation: u64,
generation: Arc<AtomicU64>,
Expand All @@ -2676,6 +2677,14 @@ struct CoreEditObservationSink {
}

impl EditObservationSink for CoreEditObservationSink {
fn observation_id(&self) -> Option<u64> {
Some(self.expected_generation)
}

fn session_id(&self) -> Option<SessionId> {
Some(self.session_id)
}

fn observation_duration(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.settings.observation_seconds as u64)
}
Expand Down Expand Up @@ -2751,6 +2760,7 @@ fn queue_pending_correction_state(
}
let suggestion = PendingCorrection {
id: uuid::Uuid::new_v4().to_string(),
observation_id: generation.map(|(_, expected)| expected),
expires_at_ms: now + i64::from(suggestion_seconds.clamp(5, 60)) * 1000,
pattern,
replacement,
Expand Down Expand Up @@ -7342,6 +7352,7 @@ impl OpenLessBackend {
}
let expected_generation = self.edit_observation_generation.load(Ordering::Acquire);
let sink = Arc::new(CoreEditObservationSink {
session_id,
settings: self
.get_preferences()
.vocabulary_learning_settings
Expand Down Expand Up @@ -9517,6 +9528,10 @@ mod tests {
assert_eq!(prefs.vocabulary_learning_settings.observation_seconds, 10);
assert_eq!(prefs.vocabulary_learning_settings.suggestion_seconds, 60);
assert_eq!(prefs.vocabulary_learning_settings.max_phrase_chars, 32);
assert_eq!(
prefs.vocabulary_learning_settings.suggestion_position,
crate::shared_types::VocabularySuggestionPosition::NearInput
);
let restored: UserPreferences =
serde_json::from_value(serde_json::to_value(&prefs).unwrap()).unwrap();
assert_eq!(
Expand All @@ -9525,6 +9540,100 @@ mod tests {
);
}

#[test]
fn vocabulary_suggestion_position_roundtrips_and_defaults_for_old_settings() {
use crate::shared_types::{VocabularyLearningSettings, VocabularySuggestionPosition};
let old: VocabularyLearningSettings = serde_json::from_value(serde_json::json!({
"observationSeconds": 20, "suggestionSeconds": 30, "maxPhraseChars": 16
}))
.unwrap();
assert_eq!(
old.suggestion_position,
VocabularySuggestionPosition::NearInput
);
for (value, expected) in [
("nearInput", VocabularySuggestionPosition::NearInput),
("bottomRight", VocabularySuggestionPosition::BottomRight),
] {
let prefs: UserPreferences = serde_json::from_value(serde_json::json!({
"vocabularyLearningSettings": { "suggestionPosition": value }
}))
.unwrap();
assert_eq!(
prefs.vocabulary_learning_settings.suggestion_position,
expected
);
let encoded = serde_json::to_value(&prefs).unwrap();
assert_eq!(
encoded["vocabularyLearningSettings"]["suggestionPosition"],
value
);
let restored: UserPreferences = serde_json::from_value(encoded).unwrap();
assert_eq!(
restored.vocabulary_learning_settings.suggestion_position,
expected
);
}
}

#[test]
fn changing_only_vocabulary_card_position_invalidates_pending_and_stale_sink() {
use crate::shared_types::VocabularySuggestionPosition;
let (backend, _) = backend();
let mut prefs = backend.get_preferences();
prefs.vocabulary_learning_enabled = true;
backend
.update_settings(
prefs,
crate::SettingsUpdateOptions::STRICT,
&crate::NoopSettingsRuntime,
)
.unwrap();
let before = backend.get_preferences();
let sink = CoreEditObservationSink {
session_id: SessionId::new(),
settings: before.vocabulary_learning_settings,
expected_generation: backend.edit_observation_generation.load(Ordering::Acquire),
generation: Arc::clone(&backend.edit_observation_generation),
typed_text: "ZIP".into(),
pending: Arc::clone(&backend.pending_corrections),
events: Arc::clone(&backend.events),
};
let edit = crate::host_document::minimal_edit("ZIP", "VIP").unwrap();
assert!(sink.publish(edit.clone()));
assert!(!backend.pending_corrections().is_empty());
let mut prefs = before.clone();
prefs.vocabulary_learning_settings.suggestion_position =
VocabularySuggestionPosition::BottomRight;
backend
.update_settings(
prefs,
crate::SettingsUpdateOptions::STRICT,
&crate::NoopSettingsRuntime,
)
.unwrap();
assert!(backend.pending_corrections().is_empty());
assert!(!sink.publish(edit));
let saved = backend.get_preferences();
assert!(saved.vocabulary_learning_enabled);
assert_eq!(
saved.vocabulary_learning_settings.suggestion_position,
VocabularySuggestionPosition::BottomRight
);
assert_eq!(
saved.vocabulary_learning_settings.observation_seconds,
before.vocabulary_learning_settings.observation_seconds
);
assert_eq!(
saved.vocabulary_learning_settings.suggestion_seconds,
before.vocabulary_learning_settings.suggestion_seconds
);
assert_eq!(
saved.vocabulary_learning_settings.max_phrase_chars,
before.vocabulary_learning_settings.max_phrase_chars
);
}

#[test]
fn vocabulary_learning_config_change_invalidates_pending_and_stale_sink() {
let (backend, _) = backend();
Expand All @@ -9541,6 +9650,7 @@ mod tests {
)
.unwrap();
let sink = CoreEditObservationSink {
session_id: SessionId::new(),
settings: backend.get_preferences().vocabulary_learning_settings,
expected_generation: backend.edit_observation_generation.load(Ordering::Acquire),
generation: Arc::clone(&backend.edit_observation_generation),
Expand Down Expand Up @@ -9589,6 +9699,7 @@ mod tests {
fn observed_spelling_correction_saves_only_the_complete_confirmed_word() {
let (backend, _) = backend();
let sink = CoreEditObservationSink {
session_id: SessionId::new(),
settings: backend.get_preferences().vocabulary_learning_settings,
expected_generation: backend.edit_observation_generation.load(Ordering::Acquire),
generation: Arc::clone(&backend.edit_observation_generation),
Expand All @@ -9601,6 +9712,8 @@ mod tests {
));
let suggestions = backend.pending_corrections();
assert_eq!(suggestions.len(), 2);
assert_eq!(suggestions[0].observation_id, sink.observation_id());
assert_eq!(suggestions[1].observation_id, sink.observation_id());
assert_eq!(suggestions[0].pattern, "ZIP");
assert_eq!(suggestions[0].replacement, "VIP");
assert_eq!(suggestions[1].pattern, "codx");
Expand Down Expand Up @@ -12063,6 +12176,8 @@ mod tests {
.queue_pending_correction("扣的爱思".into(), "Codex".into())
.unwrap()
.unwrap();
assert_eq!(first.observation_id, None);
assert!(serde_json::to_value(&first).unwrap().get("observationId").is_none());
assert!(backend
.queue_pending_correction("扣的爱思".into(), "Codex".into())
.unwrap()
Expand Down
8 changes: 8 additions & 0 deletions openless-all/app/crates/openless-core/src/ports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,14 @@ pub trait TextInsertionSession: Send + Sync {
/// document baseline. Keeping that decision here prevents macOS AX code from
/// owning vocabulary policy or accepting a report from a stale generation.
pub trait EditObservationSink: Send + Sync {
fn observation_id(&self) -> Option<u64> {
None
}

fn session_id(&self) -> Option<SessionId> {
None
}

fn observation_duration(&self) -> std::time::Duration {
std::time::Duration::from_secs(60)
}
Expand Down
15 changes: 15 additions & 0 deletions openless-all/app/crates/openless-core/src/shared_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ pub use crate::types::{CorrectionRule, RuleSource};
#[serde(rename_all = "camelCase")]
pub struct PendingCorrection {
pub id: String,
/// Host presentation correlation only; absent for manually queued suggestions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation_id: Option<u64>,
/// Absolute deadline shared by native and web confirmation surfaces.
pub expires_at_ms: i64,
/// The (wrong) pre-correction spelling. Only shown on the card so the
Expand Down Expand Up @@ -319,13 +322,23 @@ pub const INSERT_FALLBACK_REASON_INSERT_FAILED: &str = "insertFailed";
/// having no reject list.
pub const VOCAB_SUGGESTION_TTL_MS: u64 = 10_000;

/// Windows suggestion-card placement; other platforms retain their native presentation.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum VocabularySuggestionPosition {
#[default]
NearInput,
BottomRight,
}

/// Local-only tuning. Consent and sensitive-field protections remain separate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct VocabularyLearningSettings {
pub observation_seconds: u32,
pub suggestion_seconds: u32,
pub max_phrase_chars: u32,
pub suggestion_position: VocabularySuggestionPosition,
}

impl Default for VocabularyLearningSettings {
Expand All @@ -334,6 +347,7 @@ impl Default for VocabularyLearningSettings {
observation_seconds: 60,
suggestion_seconds: 10,
max_phrase_chars: 12,
suggestion_position: VocabularySuggestionPosition::default(),
}
}
}
Expand All @@ -344,6 +358,7 @@ impl VocabularyLearningSettings {
observation_seconds: self.observation_seconds.clamp(10, 60),
suggestion_seconds: self.suggestion_seconds.clamp(5, 60),
max_phrase_chars: self.max_phrase_chars.clamp(2, 32),
suggestion_position: self.suggestion_position,
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Exercise production geometry and observation ownership without Tauri or native ASR DLLs.
#[path = "../../src/vocab_card_placement.rs"]
mod vocab_card_placement;
62 changes: 51 additions & 11 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,22 @@ const VOCAB_CARD_EDGE_MARGIN: f64 = 24.0;
/// So the window shrinks to the card's actual size while shown, and is
/// restored when hidden.
fn show_vocab_suggestion_card(inner: &Arc<Inner>) {
let pending = inner.backend.pending_corrections();
if pending.is_empty() {
return;
}
let epoch = inner.vocab_card_epoch.next();
let Some(capsule) = inner.host.capsule_window() else {
return;
};
let height = VOCAB_CARD_CHROME_HEIGHT + VOCAB_CARD_ROW_HEIGHT * pending.len() as f64;
let inner_for_main = Arc::clone(inner);
let _ = capsule.run_on_main_thread(move |capsule| {
let inner = inner_for_main;
if !inner.vocab_card_epoch.is_current(epoch) {
return;
}
let pending = inner.backend.pending_corrections();
if pending.is_empty() {
return;
}
let height = VOCAB_CARD_CHROME_HEIGHT + VOCAB_CARD_ROW_HEIGHT * pending.len() as f64;
let observation_ids: Vec<_> = pending.iter().map(|item| item.observation_id).collect();
// Last gate: never show the card while dictation is not Idle.
//
// Upstream checks (observer generation, non-empty `pending_corrections`)
Expand All @@ -125,17 +130,34 @@ fn show_vocab_suggestion_card(inner: &Arc<Inner>) {
#[cfg(not(mobile))]
if let Err(e) = capsule.set_card_hit_test_mode() {
log::warn!("[vocab-card] enabling card hit testing failed: {e}");
hide_vocab_suggestion_card(&inner);
return;
}
#[cfg(not(target_os = "windows"))]
if let Err(e) = capsule.set_size(VOCAB_CARD_WIDTH, height) {
log::warn!("[vocab-card] resize failed: {e}");
hide_vocab_suggestion_card(&inner);
return;
}
if let Err(e) =
capsule.position_vocab_card(VOCAB_CARD_WIDTH, height, VOCAB_CARD_EDGE_MARGIN)
{
if let Err(e) = capsule.position_vocab_card(
VOCAB_CARD_WIDTH,
height,
VOCAB_CARD_EDGE_MARGIN,
&observation_ids,
inner
.backend
.get_preferences()
.vocabulary_learning_settings
.suggestion_position,
) {
log::warn!("[vocab-card] position failed: {e}");
hide_vocab_suggestion_card(&inner);
return;
}
if let Err(e) = capsule.refresh_card_hit_test() {
log::warn!("[vocab-card] refreshing card hit testing failed: {e}");
hide_vocab_suggestion_card(&inner);
return;
}
// Positioning ditto: the dedup cache in `maybe_position_capsule_bottom_center`
// only tracks "monitor + translation state" and knows nothing about
Expand All @@ -144,7 +166,14 @@ fn show_vocab_suggestion_card(inner: &Arc<Inner>) {
// and leave the capsule in the bottom-right corner the card moved it
// to.
capsule.invalidate_layout();
capsule.show_for_recording(true);
if !capsule.show_vocab_card() {
log::warn!("[vocab-card] no-activate show failed; suppressing this presentation");
let _ = capsule.hide();
#[cfg(not(mobile))]
let _ = capsule.restore_capsule_hit_test_mode();
inner.vocab_card_visible.store(false, Ordering::SeqCst);
inner.backend.dismiss_pending_corrections();
}
#[cfg(target_os = "macos")]
capsule.restore_main_window_key_if_active();
});
Expand All @@ -160,14 +189,21 @@ fn show_vocab_suggestion_card(inner: &Arc<Inner>) {
/// the same window — the capsule flickers and the user thinks the hotkey is
/// broken.
fn hide_vocab_suggestion_card(inner: &Arc<Inner>) {
let epoch = inner
.vocab_card_epoch
.request_hide(&inner.vocab_card_visible);
inner.backend.dismiss_pending_corrections();
if !inner.vocab_card_visible.swap(false, Ordering::SeqCst) {
let Some(epoch) = epoch else {
return;
}
};
let Some(capsule) = inner.host.capsule_window() else {
return;
};
let inner = Arc::clone(inner);
let _ = capsule.run_on_main_thread(move |capsule| {
if !inner.vocab_card_epoch.is_current(epoch) {
return;
}
// Hide before changing geometry: restoring size and position while the
// window is visible can composite a frame with the card stretched wide
// and flying across half the screen.
Expand All @@ -184,6 +220,7 @@ fn hide_vocab_suggestion_card(inner: &Arc<Inner>) {
if let Err(e) = capsule.restore_capsule_geometry() {
log::warn!("[vocab-card] restoring capsule geometry failed: {e}");
}
inner.vocab_card_visible.store(false, Ordering::SeqCst);
});
}

Expand Down Expand Up @@ -533,6 +570,7 @@ struct Inner {
/// every dictation start would hide the capsule window and race
/// `emit_capsule`'s show for the same window.
vocab_card_visible: AtomicBool,
vocab_card_epoch: crate::vocab_card_placement::PresentationEpoch,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Arc<Mutex<HotkeyStatus>>,
/// Webview fallback only: pairs one raw keydown/up edge with the same Core press id.
Expand Down Expand Up @@ -728,6 +766,7 @@ impl Coordinator {
overlay_qa_handoff: tokio::sync::Mutex::new(()),
inserter: TextInserter::new(),
vocab_card_visible: AtomicBool::new(false),
vocab_card_epoch: crate::vocab_card_placement::PresentationEpoch::default(),
hotkey: Mutex::new(None),
hotkey_status,
window_hotkey_press_id: AtomicU64::new(0),
Expand Down Expand Up @@ -868,6 +907,7 @@ impl Coordinator {
overlay_qa_handoff: tokio::sync::Mutex::new(()),
inserter: TextInserter::new(),
vocab_card_visible: AtomicBool::new(false),
vocab_card_epoch: crate::vocab_card_placement::PresentationEpoch::default(),
hotkey: Mutex::new(None),
hotkey_status,
window_hotkey_press_id: AtomicU64::new(0),
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2515,6 +2515,7 @@ pub(crate) mod less_computer_test_support {
overlay_qa_handoff: tokio::sync::Mutex::new(()),
inserter: TextInserter::new(),
vocab_card_visible: AtomicBool::new(false),
vocab_card_epoch: crate::vocab_card_placement::PresentationEpoch::default(),
hotkey: Mutex::new(None),
hotkey_status: Arc::new(Mutex::new(HotkeyStatus::default())),
window_hotkey_press_id: AtomicU64::new(0),
Expand Down
Loading
Loading