launcher: make the prompt cursor consistently grapheme-based

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 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-10-03 09:15:53 +00:00
co-authored by Claude Fable 5.1
parent 1367bd8591
commit 2bed8b7fac
+142 -26
View File
@@ -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");
}
}