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:
co-authored by
Claude Fable 5.1
parent
1367bd8591
commit
2bed8b7fac
+142
-26
@@ -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) {
|
fn move_cursor(&mut self, distance: isize) {
|
||||||
let new_cursor = self.cursor as isize + distance;
|
let new_cursor = self.cursor as isize + distance;
|
||||||
|
let len = self.len() as isize;
|
||||||
if new_cursor < 0 {
|
if new_cursor < 0 {
|
||||||
self.cursor = 0;
|
self.cursor = 0;
|
||||||
return;
|
} else if new_cursor > len {
|
||||||
} else if new_cursor > self.input.len() as isize {
|
self.cursor = len as usize;
|
||||||
self.cursor = self.input.len()
|
|
||||||
} else {
|
} else {
|
||||||
self.cursor = new_cursor as usize;
|
self.cursor = new_cursor as usize;
|
||||||
}
|
}
|
||||||
@@ -110,7 +116,7 @@ impl Prompt {
|
|||||||
|
|
||||||
fn set(&mut self, v: &str) {
|
fn set(&mut self, v: &str) {
|
||||||
self.input = v.to_string();
|
self.input = v.to_string();
|
||||||
self.cursor = self.input.len();
|
self.cursor = self.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append(&mut self, v: &str) {
|
fn append(&mut self, v: &str) {
|
||||||
@@ -128,13 +134,14 @@ impl Prompt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
||||||
if self.cursor == indices.len() {
|
if self.cursor >= indices.len() {
|
||||||
self.input += &v;
|
self.cursor = indices.len();
|
||||||
|
self.input += v;
|
||||||
} else {
|
} else {
|
||||||
let index_at = indices[self.cursor].0;
|
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) {
|
fn backspace(&mut self) {
|
||||||
@@ -168,6 +175,7 @@ impl Prompt {
|
|||||||
fn clear_right(&mut self) {
|
fn clear_right(&mut self) {
|
||||||
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
||||||
if !indices.is_empty() {
|
if !indices.is_empty() {
|
||||||
|
self.cursor = min(self.cursor, indices.len());
|
||||||
self.input = indices[0..self.cursor]
|
self.input = indices[0..self.cursor]
|
||||||
.iter()
|
.iter()
|
||||||
.fold("".into(), |acc, el| acc + el.1);
|
.fold("".into(), |acc, el| acc + el.1);
|
||||||
@@ -179,6 +187,7 @@ impl Prompt {
|
|||||||
fn clear_left(&mut self) {
|
fn clear_left(&mut self) {
|
||||||
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
|
||||||
if !indices.is_empty() {
|
if !indices.is_empty() {
|
||||||
|
self.cursor = min(self.cursor, indices.len());
|
||||||
self.input = indices[self.cursor..indices.len()]
|
self.input = indices[self.cursor..indices.len()]
|
||||||
.iter()
|
.iter()
|
||||||
.fold("".into(), |acc, el| acc + el.1);
|
.fold("".into(), |acc, el| acc + el.1);
|
||||||
@@ -193,11 +202,32 @@ impl Prompt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn end(&mut self) {
|
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 {
|
trait InterfaceWidget {
|
||||||
|
/// Number of selectable rows (used to clamp the selection).
|
||||||
|
fn rows(&self) -> usize;
|
||||||
fn trigger(&mut self, intf: &mut InnerInterface);
|
fn trigger(&mut self, intf: &mut InnerInterface);
|
||||||
fn draw(
|
fn draw(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -258,23 +288,26 @@ impl Launcher {
|
|||||||
} else {
|
} else {
|
||||||
cmd.arg(cmdline);
|
cmd.arg(cmdline);
|
||||||
}
|
}
|
||||||
cmd.spawn().unwrap();
|
match cmd.spawn() {
|
||||||
exit(0);
|
Ok(_) => exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("wldash: failed to launch {:?}: {}", cmdline, e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InterfaceWidget for Launcher {
|
impl InterfaceWidget for Launcher {
|
||||||
|
fn rows(&self) -> usize {
|
||||||
|
self.matches.len()
|
||||||
|
}
|
||||||
|
|
||||||
fn trigger(&mut self, intf: &mut InnerInterface) {
|
fn trigger(&mut self, intf: &mut InnerInterface) {
|
||||||
if self.matches.len() > intf.selection {
|
if self.matches.len() > intf.selection {
|
||||||
let d = &self.matches[intf.selection];
|
let d = &self.matches[intf.selection];
|
||||||
if let Some(exec) = &d.exec {
|
if let Some(exec) = &d.exec {
|
||||||
let exec = exec
|
self.exec(&strip_field_codes(exec));
|
||||||
.replace("%f", "")
|
|
||||||
.replace("%F", "")
|
|
||||||
.replace("%u", "")
|
|
||||||
.replace("%U", "");
|
|
||||||
|
|
||||||
self.exec(&exec);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -345,8 +378,9 @@ impl InterfaceWidget for Launcher {
|
|||||||
.fuzzy_indices(&m.name.to_lowercase(), &intf.prompt.input.to_lowercase())
|
.fuzzy_indices(&m.name.to_lowercase(), &intf.prompt.input.to_lowercase())
|
||||||
.unwrap_or((0, vec![]));
|
.unwrap_or((0, vec![]));
|
||||||
|
|
||||||
let mut colors = Vec::with_capacity(m.name.len());
|
let nchars = m.name.chars().count();
|
||||||
for pos in 0..m.name.len() {
|
let mut colors = Vec::with_capacity(nchars);
|
||||||
|
for pos in 0..nchars {
|
||||||
if indices.contains(&pos) {
|
if indices.contains(&pos) {
|
||||||
colors.push(Color::LIGHTORANGE);
|
colors.push(Color::LIGHTORANGE);
|
||||||
} else {
|
} else {
|
||||||
@@ -394,12 +428,21 @@ impl Shell {
|
|||||||
if let Some(token) = &self.next_token {
|
if let Some(token) = &self.next_token {
|
||||||
cmd.env("XDG_ACTIVATION_TOKEN", token);
|
cmd.env("XDG_ACTIVATION_TOKEN", token);
|
||||||
}
|
}
|
||||||
cmd.args(&args[1..]).spawn().unwrap();
|
match cmd.args(&args[1..]).spawn() {
|
||||||
exit(0);
|
Ok(_) => exit(0),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("wldash: failed to run {:?}: {}", args, e);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InterfaceWidget for Shell {
|
impl InterfaceWidget for Shell {
|
||||||
|
fn rows(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
|
||||||
fn trigger(&mut self, intf: &mut InnerInterface) {
|
fn trigger(&mut self, intf: &mut InnerInterface) {
|
||||||
let args = vec![
|
let args = vec![
|
||||||
"/bin/sh".to_string(),
|
"/bin/sh".to_string(),
|
||||||
@@ -495,6 +538,11 @@ impl Calc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InterfaceWidget for 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) {
|
fn trigger(&mut self, intf: &mut InnerInterface) {
|
||||||
let mut old = self.old.lock().unwrap();
|
let mut old = self.old.lock().unwrap();
|
||||||
if intf.selection == 0 {
|
if intf.selection == 0 {
|
||||||
@@ -641,7 +689,14 @@ impl Interface {
|
|||||||
fn pointer_input(&mut self, event: &PointerEvent) {
|
fn pointer_input(&mut self, event: &PointerEvent) {
|
||||||
if let PointerButton::Left = event.button {
|
if let PointerButton::Left = event.button {
|
||||||
let line_height = self.inner.size.ceil() as u32;
|
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 lines = height / line_height;
|
||||||
|
|
||||||
let offset = height % line_height;
|
let offset = height % line_height;
|
||||||
@@ -650,7 +705,12 @@ impl Interface {
|
|||||||
} else {
|
} else {
|
||||||
return;
|
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;
|
self.inner.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,7 +772,10 @@ impl Interface {
|
|||||||
keysyms::KEY_Left => self.inner.prompt.move_cursor(-1),
|
keysyms::KEY_Left => self.inner.prompt.move_cursor(-1),
|
||||||
keysyms::KEY_Right => self.inner.prompt.move_cursor(1),
|
keysyms::KEY_Right => self.inner.prompt.move_cursor(1),
|
||||||
keysyms::KEY_Up => {
|
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 => {
|
keysyms::KEY_Down => {
|
||||||
if self.inner.selection > 0 {
|
if self.inner.selection > 0 {
|
||||||
@@ -721,7 +784,7 @@ impl Interface {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if let Some(utf8) = &event.utf8 {
|
if let Some(utf8) = &event.utf8 {
|
||||||
self.inner.prompt.append(&utf8);
|
self.inner.prompt.append(utf8);
|
||||||
self.inner.selection = 0;
|
self.inner.selection = 0;
|
||||||
widget.update(&self.inner);
|
widget.update(&self.inner);
|
||||||
} else {
|
} 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user