From ee254980556e36c74c1d5bfba3d8dab14a3bf7de Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 16:27:24 +0000 Subject: [PATCH 1/2] Leave the empty suggestion row blank The keyboard wrote a note across its suggestion row whenever the row was empty: Prediction loading, No suggestions, Type for suggestions and so on. Nothing is written across the keyboard any more; the row keeps its reserved slots and stays blank until suggestions arrive, so the panel never changes size. The Status tile role and the row label go with it, along with the held and tracking flags that only fed the note. If prediction fails, the Retry predictions key still appears. Closes #1218 Co-Authored-By: Claude Fable 5.1 --- .../src/content/docs/using/word-prediction.md | 2 +- src-tauri/src/point_workflow.rs | 4 - src-tauri/src/prediction/mod.rs | 2 - src-tauri/src/scan_keyboard.rs | 143 +++++------------- src-tauri/src/scan_menu.rs | 1 - src-tauri/src/scan_mouse.rs | 5 - src-tauri/src/scan_panel.rs | 30 ---- src-tauri/src/scan_tile.rs | 1 - src-tauri/src/scanning.rs | 1 - src/settings/ScannerPreferences.tsx | 2 +- 10 files changed, 38 insertions(+), 153 deletions(-) diff --git a/site/src/content/docs/using/word-prediction.md b/site/src/content/docs/using/word-prediction.md index ea3a2a24..1c0378bb 100644 --- a/site/src/content/docs/using/word-prediction.md +++ b/site/src/content/docs/using/word-prediction.md @@ -17,7 +17,7 @@ phrases follow once the on-device model has started. Only slots with a suggestion are shown. An empty slot leaves a gap, so the other suggestions stay in the same place and the scan skips the gap. -When there are no suggestions at all, the row says why instead, for example **Type for suggestions**, **No suggestions** or **Prediction loading**. The scan skips the row. +When there are no suggestions at all, the row stays blank and the scan skips it. Nothing is written across the keyboard. If prediction fails, a **Retry predictions** key appears on the keyboard; typing still works. Suggestions only change when they need to, so the scan does not restart under you. diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index 03346ee7..b171f6e6 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -3976,7 +3976,6 @@ mod tests { !matches!( style.role, crate::scanning::TileRole::Panel - | crate::scanning::TileRole::Status | crate::scanning::TileRole::MenuBar | crate::scanning::TileRole::Title ) @@ -4327,9 +4326,6 @@ mod tests { // The message is shown clear of the panel, which has no status line. let frame = w.frame(); assert!(frame.tiles.iter().all(|tile| !tile.selected)); - assert!(frame.tiles.iter().all(|tile| { - tile.style.map(|style| style.role) != Some(crate::scanning::TileRole::Status) - })); let label = frame.label.unwrap(); assert_eq!(label.text, format!("{message}\nSelect to return")); assert!(label.hud.is_some()); diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index c94e170f..fe4a1abf 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -566,7 +566,6 @@ impl Service { status == database::Status::Unavailable, ); keyboard.prediction_loading = status == database::Status::Loading; - keyboard.prediction_tracking(tracking); } /// A reply missed its deadline. One stall should not cost a switch user a /// trip to Retry, so the worker and its private text context are killed @@ -834,7 +833,6 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign SERVICE.with(|slot| { let mut s = slot.borrow_mut(); s.observe(activity::snapshot()); - keyboard.prediction_held = s.held; let case = ( keyboard.prediction_shift(), keyboard.caps, diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 905a5d85..9b940374 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -288,8 +288,6 @@ pub struct Keyboard { prediction_failed: bool, pub(crate) prediction_loading: bool, /// Suggestions wait for the next word after context was lost inside one. - pub(crate) prediction_held: bool, - prediction_tracking: bool, predictions: Option, queued_predictions: Option, prefer_predictions: bool, @@ -341,8 +339,6 @@ impl Keyboard { prediction_enabled: false, prediction_failed: false, prediction_loading: false, - prediction_held: false, - prediction_tracking: true, predictions: None, queued_predictions: None, prefer_predictions: true, @@ -468,38 +464,9 @@ impl Keyboard { } } } - pub fn prediction_tracking(&mut self, tracking: bool) { - self.prediction_tracking = tracking; - } - fn prediction_note(&self) -> Option { - if !self.prediction_enabled { - return None; - } - Some(if self.prediction_failed { - "Prediction unavailable".into() - } else if self.prediction_loading { - "Prediction loading".into() - } else if !self.prediction_tracking { - "Prediction paused".into() - } else if self.prediction_held { - "Suggestions resume next word".into() - } else if self.queued_predictions.is_some() { - "Suggestions updating".into() - } else { - match self.predictions.as_ref() { - Some(batch) if batch.words.iter().flatten().count() == 1 => "1 suggestion".into(), - Some(batch) if batch.words.iter().any(Option::is_some) => { - format!("{} suggestions", batch.words.iter().flatten().count()) - } - Some(_) => "No suggestions".into(), - None => "Type for suggestions".into(), - } - }) - } fn retry_predictions(&mut self) { self.prediction_failed = false; self.prediction_loading = true; - self.prediction_tracking = true; self.predictions = None; self.queued_predictions = None; self.rebuild_rows(); @@ -1082,10 +1049,6 @@ impl Keyboard { } else { None }; - let empty_suggestions = self.rows.first().is_some_and(|row| { - row.iter() - .all(|&key| matches!(key, Key::Prediction(_)) && self.label(key).is_empty()) - }); crate::scan_panel::Panel { key_width: if self.positioning { 128.0 } else { 68.0 }, size: self.scan.options.panel_size, @@ -1112,11 +1075,6 @@ impl Keyboard { }) .collect(), message, - // With no suggestions to show, their row says why instead. - row_label: empty_suggestions - .then(|| self.prediction_note()) - .flatten() - .map(|note| (0, note)), dock: self.dock, selected: (repeating.is_none() && !self.scan.suspended && !self.disabled()) .then(|| self.scan.highlight(&self.rows)), @@ -1259,25 +1217,23 @@ mod tests { width: 1920.0, height: 1080.0, }; - // The note is drawn across the suggestion row only while that row is - // empty; it never floats as a message and never sits beside the keys. - let badge = |keyboard: &Keyboard| { + // Nothing is written across the keyboard while the suggestion row is + // empty: no note, no floating message, and the reserved slots stay + // hidden so the other keys never move. + let silent = |keyboard: &Keyboard| { let frame = keyboard.frame(screen, 1.0, ScannerColor::default()); assert!(frame.label.is_none()); - let last = frame.tiles.last().unwrap(); - (last.style.map(|style| style.role) == Some(crate::scanning::TileRole::Status)).then( - || { - assert!(!last.selected); - assert!( - last.rect.y > frame.tiles[0].rect.y + crate::scan_panel::MENU_BAR_HEIGHT - ); - last.text.clone() - }, - ) + assert!(frame.tiles.iter().all(|tile| !tile + .text + .to_lowercase() + .contains("suggestion") + && !tile.text.starts_with("Prediction"))); + frame }; - assert_eq!(badge(&keyboard), Some("Prediction loading".into())); + let keys = |keyboard: &Keyboard| silent(keyboard).tiles.len(); + let loading = keys(&keyboard); keyboard.predictions(None, false); - assert_eq!(badge(&keyboard), Some("Type for suggestions".into())); + assert_eq!(keys(&keyboard), loading); keyboard.predictions( Some(crate::prediction::worker::Batch { refined: false, @@ -1286,7 +1242,7 @@ mod tests { }), false, ); - assert_eq!(badge(&keyboard), Some("No suggestions".into())); + assert_eq!(keys(&keyboard), loading); keyboard.predictions( Some(crate::prediction::worker::Batch { refined: false, @@ -1295,9 +1251,13 @@ mod tests { }), false, ); - assert_eq!(badge(&keyboard), None, "suggestions speak for themselves"); - keyboard.prediction_tracking(false); - assert_eq!(badge(&keyboard), None); + let frame = keyboard.frame(screen, 1.0, ScannerColor::default()); + assert!(frame.tiles.iter().any(|tile| tile.text == "water")); + assert_eq!( + frame.tiles.len(), + loading + 1, + "one suggestion fills one slot" + ); } #[test] @@ -1307,10 +1267,6 @@ mod tests { keyboard.scan.suspended = true; keyboard.predictions(None, true); assert!(keyboard.suspended()); - assert_eq!( - keyboard.prediction_note().as_deref(), - Some("Prediction unavailable") - ); assert_eq!( keyboard.rows.last().unwrap().last(), Some(&Key::RetryPrediction) @@ -1319,10 +1275,8 @@ mod tests { keyboard.choose(Key::RetryPrediction), Some(Output::RetryPrediction) ); - assert_eq!( - keyboard.prediction_note().as_deref(), - Some("Prediction loading") - ); + assert!(keyboard.prediction_loading); + assert!(!keyboard.prediction_failed); assert!(!keyboard .rows .last() @@ -2578,10 +2532,10 @@ mod tests { ); let before = keyboard.frame(screen, 1.0, ScannerColor::default()); assert_eq!(empty.tiles[0].rect, before.tiles[0].rect); - // The empty row carries one label in place of its slots. + // The empty row draws nothing in place of its slots. assert_eq!( before.tiles.len() - empty.tiles.len(), - crate::prediction::SLOTS - 1 + crate::prediction::SLOTS ); keyboard.predictions( Some(crate::prediction::worker::Batch { @@ -2696,28 +2650,7 @@ mod tests { } } #[test] - fn a_hold_is_explained_unless_something_more_pressing_is() { - let mut k = Keyboard::new(true); - k.enable_predictions(true); - k.predictions(None, false); - k.prediction_held = true; - assert_eq!( - k.prediction_note().as_deref(), - Some("Suggestions resume next word") - ); - k.prediction_loading = true; - assert_eq!(k.prediction_note().as_deref(), Some("Prediction loading")); - k.predictions(None, true); - assert_eq!( - k.prediction_note().as_deref(), - Some("Prediction unavailable") - ); - k.predictions(None, false); - k.prediction_held = false; - assert_eq!(k.prediction_note().as_deref(), Some("Type for suggestions")); - } - #[test] - fn an_empty_suggestion_row_says_why() { + fn an_empty_suggestion_row_stays_blank() { let screen = Rect { x: 0.0, y: 0.0, @@ -2728,22 +2661,18 @@ mod tests { k.enable_predictions(true); k.predictions(None, false); let frame = k.frame(screen, 1.0, ScannerColor::Blue); - let labels = frame + // The row under the menu bar holds nothing: no note, no key, and + // no floating message either. + let background = frame.tiles[0].rect; + let first_row_top = background.y + crate::scan_panel::MENU_BAR_HEIGHT; + assert!(frame .tiles .iter() - .filter(|tile| tile.text == "Type for suggestions") - .collect::>(); - // Across the row under the menu bar, never highlighted, and not a - // floating message. - let [label] = labels.as_slice() else { - panic!("expected one label, found {}", labels.len()); - }; - assert!(!label.selected); - let background = frame.tiles[0].rect; - assert!(label.rect.y > background.y + crate::scan_panel::MENU_BAR_HEIGHT); - assert!(label.rect.width > background.width * 0.9); + .skip(1) + .filter(|tile| !tile.is_menu_bar() && tile.text != crate::scan_panel::MENU_BAR_TITLE) + .all(|tile| tile.rect.y > first_row_top + 40.0)); assert!(frame.label.is_none()); - // An empty batch says so too. + // An empty batch is just as quiet. k.predictions( Some(crate::prediction::worker::Batch { refined: true, @@ -2753,7 +2682,7 @@ mod tests { false, ); let frame = k.frame(screen, 1.0, ScannerColor::Blue); - assert!(frame.tiles.iter().any(|tile| tile.text == "No suggestions")); + assert!(!frame.tiles.iter().any(|tile| tile.text == "No suggestions")); // With suggestions, the row shows them and nothing counts them. k.restart(); k.handle(Action::Next); diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index a848493d..fba596d3 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -217,7 +217,6 @@ impl Menu { // Nothing can be chosen while an error or pause waits for Select. selected: (!failed && !self.suspended()).then(|| self.scan.highlight(&self.rows)), row_scan: self.scan.row_scan() || self.scan.group_scan() || escaping, - row_label: None, thickness: options.thickness, appearance: options.panel_theme.appearance(), } diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index 691df336..bdcda7e0 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -303,7 +303,6 @@ impl MousePanel { dock: self.dock, selected: scanning.then(|| self.scan.highlight(&self.rows)), row_scan: highlight_row, - row_label: None, thickness: self.scan.options.thickness, appearance: self.scan.options.panel_theme.appearance(), } @@ -643,10 +642,6 @@ mod tests { frame.label.is_none(), "nothing to say while scanning normally" ); - assert!(frame - .tiles - .iter() - .all(|tile| tile.style.map(|style| style.role) != Some(TileRole::Status))); let close = frame.tiles.last().unwrap(); assert_eq!(close.text, "Switch to Point"); assert_eq!(close.style.unwrap().role, TileRole::Toolbar); diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs index 53a1ef78..75e37d69 100644 --- a/src-tauri/src/scan_panel.rs +++ b/src-tauri/src/scan_panel.rs @@ -237,9 +237,6 @@ pub struct Panel { /// key may be chosen. pub selected: Option<(usize, Option>)>, pub row_scan: bool, - /// Passive text drawn across a row, such as an empty suggestion row whose - /// keys are all hidden; never a scan target. - pub row_label: Option<(usize, String)>, pub thickness: Thickness, pub appearance: Appearance, } @@ -373,27 +370,6 @@ impl Panel { c += 1; } } - if let Some((row, text)) = &self.row_label { - frame.tiles.push(FrameTile { - thickness: self.thickness, - color, - appearance: self.appearance, - text: text.clone(), - style: Some(TileStyle { - role: TileRole::Status, - active: false, - row_scan: false, - }), - rect: Rect { - x, - y: y + *row as f64 * row_height, - width, - height: (row_height - gap).max(1.0), - }, - scale, - selected: false, - }); - } if let Some(message) = &self.message { frame.label = Some(message_label(message, frame.tiles[0].rect, screen, scale)); } @@ -467,7 +443,6 @@ mod tests { dock: Dock::default(), selected: Some((0, Some(2..3))), row_scan: false, - row_label: None, thickness: Thickness::Thick, appearance: Appearance::Dark, } @@ -500,7 +475,6 @@ mod tests { dock: Dock::default(), selected: Some((1, None)), row_scan: true, - row_label: None, thickness: Thickness::Thick, appearance: Appearance::Dark, }; @@ -569,7 +543,6 @@ mod tests { dock: Dock { column: 1, row: 0 }, selected: Some((0, None)), row_scan: true, - row_label: None, thickness: Thickness::default(), appearance: Appearance::Dark, }; @@ -613,7 +586,6 @@ mod tests { dock: Dock::default(), selected: None, row_scan: false, - row_label: None, thickness: Thickness::default(), appearance: Appearance::Dark, } @@ -771,7 +743,6 @@ mod tests { dock: Dock::default(), selected: None, row_scan: false, - row_label: None, thickness: Thickness::default(), appearance: Appearance::Dark, } @@ -804,7 +775,6 @@ mod tests { dock, selected: None, row_scan: false, - row_label: None, thickness: Thickness::default(), appearance: Appearance::Dark, }; diff --git a/src-tauri/src/scan_tile.rs b/src-tauri/src/scan_tile.rs index 46cd6772..350025c4 100644 --- a/src-tauri/src/scan_tile.rs +++ b/src-tauri/src/scan_tile.rs @@ -140,7 +140,6 @@ pub fn keyboard_font_size(tile: &FrameTile) -> f64 { { 24.0 } - Some(crate::scanning::TileRole::Status) => 17.0, Some(crate::scanning::TileRole::MenuBar | crate::scanning::TileRole::Title) => 14.0, _ => 16.0, } diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index c4537c31..ec43d21d 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -262,7 +262,6 @@ pub enum TileRole { Character, Utility, Toolbar, - Status, /// The Settings item on the bar along the top of a panel. It is clicked /// with the mouse, never scanned. MenuBar, diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 855a21af..c596d3a2 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -208,7 +208,7 @@ export function ScannerPreferences({ controller, settings: appSettings, onSettin } {area === 'keyboard' && update('wordPrediction', value)} /> -

Uses an offline English word database and an on-device language model. Suggestions appear as you type and after a space. The keyboard shows when suggestions are loading, unavailable, or have no match. If prediction fails, choose Retry predictions on the keyboard; typing still works. Your typing never leaves this computer.

+

Uses an offline English word database and an on-device language model. Suggestions appear as you type and after a space. If prediction fails, a Retry predictions key appears on the keyboard; typing still works. Your typing never leaves this computer.

{appSettings && onSettingsChange && }
} From 238b6e8447c8d76eb49adee986f6bfbc9a37e3eb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 16:31:01 +0000 Subject: [PATCH 2/2] Drop a stale field comment and rename the empty-slot test Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/scan_keyboard.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 9b940374..d703caf9 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -287,7 +287,6 @@ pub struct Keyboard { prediction_enabled: bool, prediction_failed: bool, pub(crate) prediction_loading: bool, - /// Suggestions wait for the next word after context was lost inside one. predictions: Option, queued_predictions: Option, prefer_predictions: bool, @@ -1208,7 +1207,7 @@ mod tests { } #[test] - fn prediction_badge_explains_empty_slots_without_replacing_scan_prompt() { + fn empty_slots_draw_nothing_and_never_replace_the_scan_prompt() { let mut keyboard = Keyboard::new(false); keyboard.enable_predictions(true); let screen = Rect {