From 2bed8b7fac5b18539fd6817240f1c61fa2351f9e Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sat, 3 Oct 2026 09:15:53 +0000 Subject: [PATCH] launcher: make the prompt cursor consistently grapheme-based MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set(), end() and move_cursor() used the byte length of the input as the cursor bound while append/backspace/delete/clear_* index a grapheme table. Any non-ASCII input (æøå, emoji) followed by End or typing past the end indexed out of bounds and, with panic = "abort" in release, killed wldash. While here: - clamp Up so the selection can't run past the last row (every widget now reports its row count), and make mouse selection underflow-safe - strip all desktop-entry field codes from Exec (%i %c %k ... and %% -> %), not just %f/%F/%u/%U - report a failed spawn instead of panicking - size the highlight colour table by chars, matching the drawing code Adds unit tests for the prompt and the field-code stripping. Co-Authored-By: Claude Fable 5.1 --- src/widgets/launcher.rs | 168 +++++++++++++++++++++++++++++++++------- 1 file changed, 142 insertions(+), 26 deletions(-) diff --git a/src/widgets/launcher.rs b/src/widgets/launcher.rs index 322d5e3..fe24e9e 100644 --- a/src/widgets/launcher.rs +++ b/src/widgets/launcher.rs @@ -96,13 +96,19 @@ impl Prompt { } } + /// Length of the input in graphemes. The cursor is a grapheme index, not + /// a byte offset, so this (and never `input.len()`) is the upper bound. + fn len(&self) -> usize { + self.input.graphemes(true).count() + } + fn move_cursor(&mut self, distance: isize) { let new_cursor = self.cursor as isize + distance; + let len = self.len() as isize; if new_cursor < 0 { self.cursor = 0; - return; - } else if new_cursor > self.input.len() as isize { - self.cursor = self.input.len() + } else if new_cursor > len { + self.cursor = len as usize; } else { self.cursor = new_cursor as usize; } @@ -110,7 +116,7 @@ impl Prompt { fn set(&mut self, v: &str) { self.input = v.to_string(); - self.cursor = self.input.len(); + self.cursor = self.len(); } fn append(&mut self, v: &str) { @@ -128,13 +134,14 @@ impl Prompt { } } let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); - if self.cursor == indices.len() { - self.input += &v; + if self.cursor >= indices.len() { + self.cursor = indices.len(); + self.input += v; } else { let index_at = indices[self.cursor].0; - self.input.insert_str(index_at, &v); + self.input.insert_str(index_at, v); } - self.cursor += 1; + self.cursor += v.graphemes(true).count(); } fn backspace(&mut self) { @@ -168,6 +175,7 @@ impl Prompt { fn clear_right(&mut self) { let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); if !indices.is_empty() { + self.cursor = min(self.cursor, indices.len()); self.input = indices[0..self.cursor] .iter() .fold("".into(), |acc, el| acc + el.1); @@ -179,6 +187,7 @@ impl Prompt { fn clear_left(&mut self) { let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); if !indices.is_empty() { + self.cursor = min(self.cursor, indices.len()); self.input = indices[self.cursor..indices.len()] .iter() .fold("".into(), |acc, el| acc + el.1); @@ -193,11 +202,32 @@ impl Prompt { } fn end(&mut self) { - self.cursor = self.input.len(); + self.cursor = self.len(); } } +/// Strip desktop-entry field codes (%f, %u, %i, %c, ...) from an Exec line and +/// unescape `%%`. We never pass files/URLs, so the codes expand to nothing. +fn strip_field_codes(exec: &str) -> String { + let mut out = String::with_capacity(exec.len()); + let mut chars = exec.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + match chars.next() { + Some('%') => out.push('%'), + Some(_) => (), + None => (), + } + } + out +} + trait InterfaceWidget { + /// Number of selectable rows (used to clamp the selection). + fn rows(&self) -> usize; fn trigger(&mut self, intf: &mut InnerInterface); fn draw( &mut self, @@ -258,23 +288,26 @@ impl Launcher { } else { cmd.arg(cmdline); } - cmd.spawn().unwrap(); - exit(0); + match cmd.spawn() { + Ok(_) => exit(0), + Err(e) => { + eprintln!("wldash: failed to launch {:?}: {}", cmdline, e); + exit(1); + } + } } } impl InterfaceWidget for Launcher { + fn rows(&self) -> usize { + self.matches.len() + } + fn trigger(&mut self, intf: &mut InnerInterface) { if self.matches.len() > intf.selection { let d = &self.matches[intf.selection]; if let Some(exec) = &d.exec { - let exec = exec - .replace("%f", "") - .replace("%F", "") - .replace("%u", "") - .replace("%U", ""); - - self.exec(&exec); + self.exec(&strip_field_codes(exec)); } } } @@ -345,8 +378,9 @@ impl InterfaceWidget for Launcher { .fuzzy_indices(&m.name.to_lowercase(), &intf.prompt.input.to_lowercase()) .unwrap_or((0, vec![])); - let mut colors = Vec::with_capacity(m.name.len()); - for pos in 0..m.name.len() { + let nchars = m.name.chars().count(); + let mut colors = Vec::with_capacity(nchars); + for pos in 0..nchars { if indices.contains(&pos) { colors.push(Color::LIGHTORANGE); } else { @@ -394,12 +428,21 @@ impl Shell { if let Some(token) = &self.next_token { cmd.env("XDG_ACTIVATION_TOKEN", token); } - cmd.args(&args[1..]).spawn().unwrap(); - exit(0); + match cmd.args(&args[1..]).spawn() { + Ok(_) => exit(0), + Err(e) => { + eprintln!("wldash: failed to run {:?}: {}", args, e); + exit(1); + } + } } } impl InterfaceWidget for Shell { + fn rows(&self) -> usize { + 1 + } + fn trigger(&mut self, intf: &mut InnerInterface) { let args = vec![ "/bin/sh".to_string(), @@ -495,6 +538,11 @@ impl Calc { } impl InterfaceWidget for Calc { + fn rows(&self) -> usize { + // The live result plus the history entries. + self.old.lock().unwrap().len() + 1 + } + fn trigger(&mut self, intf: &mut InnerInterface) { let mut old = self.old.lock().unwrap(); if intf.selection == 0 { @@ -641,7 +689,14 @@ impl Interface { fn pointer_input(&mut self, event: &PointerEvent) { if let PointerButton::Left = event.button { let line_height = self.inner.size.ceil() as u32; - let height = self.inner.geometry.height - line_height - 8; + if line_height == 0 { + return; + } + let height = self + .inner + .geometry + .height + .saturating_sub(line_height + 8); let lines = height / line_height; let offset = height % line_height; @@ -650,7 +705,12 @@ impl Interface { } else { return; }; - self.inner.selection = (lines - pos / line_height - 1) as usize; + let row = pos / line_height; + if row >= lines { + // Click landed on the prompt line or below. + return; + } + self.inner.selection = (lines - row - 1) as usize; self.inner.dirty = true; } } @@ -712,7 +772,10 @@ impl Interface { keysyms::KEY_Left => self.inner.prompt.move_cursor(-1), keysyms::KEY_Right => self.inner.prompt.move_cursor(1), keysyms::KEY_Up => { - self.inner.selection += 1; + let max = widget.rows().saturating_sub(1); + if self.inner.selection < max { + self.inner.selection += 1; + } } keysyms::KEY_Down => { if self.inner.selection > 0 { @@ -721,7 +784,7 @@ impl Interface { } _ => { if let Some(utf8) = &event.utf8 { - self.inner.prompt.append(&utf8); + self.inner.prompt.append(utf8); self.inner.selection = 0; widget.update(&self.inner); } else { @@ -781,3 +844,56 @@ impl Widget for Interface { } } } + +#[cfg(test)] +mod tests { + use super::{strip_field_codes, Prompt, PromptMode}; + + #[test] + fn prompt_cursor_is_grapheme_based() { + let mut p = Prompt::new(); + p.append("ø"); + p.append("å"); + assert_eq!(p.cursor, 2); + p.end(); + assert_eq!(p.cursor, 2); + // Used to index past the grapheme table and panic. + p.append("a"); + assert_eq!(p.input, "øåa"); + p.move_cursor(-10); + assert_eq!(p.cursor, 0); + p.move_cursor(100); + assert_eq!(p.cursor, 3); + p.backspace(); + assert_eq!(p.input, "øå"); + p.home(); + p.delete(); + assert_eq!(p.input, "å"); + p.set("blå"); + assert_eq!(p.cursor, 3); + p.move_cursor(-1); + p.clear_right(); + assert_eq!(p.input, "bl"); + p.clear_left(); + assert_eq!(p.input, ""); + assert_eq!(p.cursor, 0); + } + + #[test] + fn prompt_prefix_switches_mode() { + let mut p = Prompt::new(); + p.append("="); + assert!(matches!(p.mode, PromptMode::Calc)); + assert_eq!(p.input, ""); + p.backspace(); + assert!(matches!(p.mode, PromptMode::Normal)); + } + + #[test] + fn field_codes_are_stripped() { + assert_eq!(strip_field_codes("firefox %u"), "firefox "); + assert_eq!(strip_field_codes("app --icon %i %c %k"), "app --icon "); + assert_eq!(strip_field_codes("echo 100%% %F"), "echo 100% "); + assert_eq!(strip_field_codes("plain"), "plain"); + } +}