GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(widget): LineEdit, the model of a one-line field an app draws itself
Moved from cce-browser, where it backs the URL bar and the dialog fields
(HTTP auth, JS prompts): the text, a byte-indexed caret on char
boundaries, a selection, a masked mode for passwords, and the keymap
(editing, arrows/Home/End, Ctrl+A/U, clipboard with copy and cut refused
on a masked field). Enter, Escape and any chord it does not own come back
as an EditOutcome, so the app's own shortcuts still reach it. It is for a
field the app lays out and paints itself; TextBox and its char-indexed
TextEditorState stay what a widget-built UI uses. Tests moved with it.
Also brings CLAUDE.md's ipc.rs line up to date (instance,
read_request_line, focus_window).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 11 +-
src/widget/line_edit.rs | 317 ++++++++++++++++++++++++++++++++++++++++++++++++
src/widget/mod.rs | 2 +
3 files changed, 327 insertions(+), 3 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index a585991..70b4312 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1034,10 +1034,15 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `history.rs` — `History<T>`: the undo/redo snapshot stack (cap, gestures, grouped runs).
The toolkit defines the stack and the routing, never the step — see the trait section.
- `widget/` — `container/` (vbox/hbox/scroll/menu/treelist/…), `input/` (button/slider/text_box/
- dropdown/…), `display/` (label/graph/svg/…), plus `editor.rs` and `core.rs`. (The
- KDL/JSON-driven `json_layout.rs` is dissolved; `scene/layout.rs` is the box model.)
+ dropdown/…), `display/` (label/graph/svg/…), plus `editor.rs` (`TextEditorState`, the
+ model behind `TextBox`), `line_edit.rs` (`LineEdit`: the text, caret, selection and keymap
+ of a one-line field an app draws itself — cce-browser's URL bar and dialog fields) and
+ `core.rs`. (The KDL/JSON-driven `json_layout.rs` is dissolved; `scene/layout.rs` is the
+ box model.)
- `protocol.rs` — inline-generated Wayland protocol bindings.
-- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`).
+- `ipc.rs` — the `/tmp/<prefix>-<WAYLAND_DISPLAY>.sock` helpers (`socket_path`, `send_command`,
+ the bounded `read_request_line`, `focus_window`), and `ipc::instance`: single-instance
+ claim-or-forward for apps that run once per session.
- `icon.rs` — XDG icon-theme lookup: a `.desktop` `Icon=` key (or an SNI tray icon
name) → a file on disk, plus `upload_themed` to rasterize/decode and upload it.
**Not** `lib.rs`'s `upload_icon`, which loads a *bundled* cce-icons glyph by its
diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
new file mode 100644
index 0000000..0ae3488
--- /dev/null
+++ b/src/widget/line_edit.rs
@@ -0,0 +1,317 @@
+//! A one-line text field's model: the text, a caret, and a selection — and
+//! the keymap that edits them.
+//!
+//! For an app that draws its own field rather than using [`TextBox`]: the
+//! app keeps a `LineEdit`, feeds it keys with [`LineEdit::handle_key`], and
+//! draws [`LineEdit::display`] with the caret and selection wherever its
+//! own layout puts them. Enter and Escape come back as [`EditOutcome`]s for
+//! the caller to act on, as does any chord the field does not own, so the
+//! app's own shortcuts still reach it. cce-browser's URL bar and its dialog
+//! fields (HTTP auth, JS prompts) are built on it.
+//!
+//! Indices are **byte** offsets into `text`, always on a char boundary
+//! ([`prev_boundary`] / [`next_boundary`] step them), which is what slicing
+//! and shaping want. [`TextEditorState`](super::TextEditorState), the model
+//! behind `TextBox`, counts chars and leaves the keymap to its widget;
+//! the two are not interchangeable.
+//!
+//! [`TextBox`]: super::TextBox
+
+use crate::widget::{ElementState, Key, KeyEvent, NamedKey};
+
+/// What a keystroke meant, beyond editing the text.
+#[derive(Debug, PartialEq)]
+pub enum EditOutcome {
+ /// Nothing structural — redraw and carry on.
+ Edited,
+ /// Enter: the caller commits.
+ Submit,
+ /// Escape: the caller cancels.
+ Cancel,
+ /// Not ours (a chord the chrome owns).
+ Ignored,
+}
+
+#[derive(Default)]
+pub struct LineEdit {
+ pub text: String,
+ pub cursor: usize,
+ /// Normalized (start < end). Any edit replaces or drops it.
+ pub selection: Option<(usize, usize)>,
+ /// Render as bullets. Set for password fields.
+ pub masked: bool,
+}
+
+/// The char boundary before byte `i` in `s` (0 at the start). For stepping a
+/// caret, or trimming text to fit without splitting a character.
+pub fn prev_boundary(s: &str, i: usize) -> usize {
+ let mut j = i;
+ while j > 0 {
+ j -= 1;
+ if s.is_char_boundary(j) {
+ return j;
+ }
+ }
+ 0
+}
+
+/// The char boundary after byte `i` in `s` (`s.len()` at the end).
+pub fn next_boundary(s: &str, i: usize) -> usize {
+ let mut j = i;
+ while j < s.len() {
+ j += 1;
+ if s.is_char_boundary(j) {
+ return j;
+ }
+ }
+ s.len()
+}
+
+impl LineEdit {
+ pub fn with_text(text: impl Into<String>) -> Self {
+ let text = text.into();
+ Self { cursor: text.len(), text, ..Self::default() }
+ }
+
+ pub fn masked() -> Self {
+ Self { masked: true, ..Self::default() }
+ }
+
+ /// What to draw. Never returns the password itself.
+ pub fn display(&self) -> String {
+ if self.masked {
+ "\u{2022}".repeat(self.text.chars().count())
+ } else {
+ self.text.clone()
+ }
+ }
+
+ pub fn select_all(&mut self) {
+ self.cursor = self.text.len();
+ self.selection = (self.cursor > 0).then_some((0, self.cursor));
+ }
+
+ fn take_selection(&mut self) -> bool {
+ match self.selection.take() {
+ Some((a, b)) if a < b && b <= self.text.len() => {
+ self.text.replace_range(a..b, "");
+ self.cursor = a;
+ true
+ }
+ _ => false,
+ }
+ }
+
+ pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
+ if event.state != ElementState::Pressed {
+ return EditOutcome::Ignored;
+ }
+ match &event.logical_key {
+ Key::Named(NamedKey::Enter) => return EditOutcome::Submit,
+ Key::Named(NamedKey::Escape) => return EditOutcome::Cancel,
+ Key::Named(NamedKey::Backspace) => {
+ if !self.take_selection() && self.cursor > 0 {
+ let prev = prev_boundary(&self.text, self.cursor);
+ self.text.replace_range(prev..self.cursor, "");
+ self.cursor = prev;
+ }
+ }
+ Key::Named(NamedKey::Delete) => {
+ if !self.take_selection() && self.cursor < self.text.len() {
+ let next = next_boundary(&self.text, self.cursor);
+ self.text.replace_range(self.cursor..next, "");
+ }
+ }
+ // Arrows collapse a selection to the edge they move toward.
+ Key::Named(NamedKey::ArrowLeft) => {
+ self.cursor = match self.selection.take() {
+ Some((a, _)) => a,
+ None => prev_boundary(&self.text, self.cursor),
+ };
+ }
+ Key::Named(NamedKey::ArrowRight) => {
+ self.cursor = match self.selection.take() {
+ Some((_, b)) => b,
+ None => next_boundary(&self.text, self.cursor),
+ };
+ }
+ Key::Named(NamedKey::Home) => {
+ self.selection = None;
+ self.cursor = 0;
+ }
+ Key::Named(NamedKey::End) => {
+ self.selection = None;
+ self.cursor = self.text.len();
+ }
+ Key::Character(c) if event.ctrl => match c.as_str() {
+ "a" => self.select_all(),
+ "u" => {
+ self.text.clear();
+ self.cursor = 0;
+ self.selection = None;
+ }
+ // Copy and cut are deliberately absent on a masked field:
+ // a password should not leave through the clipboard by a
+ // chord the user may not have meant. Paste is allowed, since
+ // that is how password managers hand one over.
+ "v" => {
+ if let Some(t) = crate::widget::clipboard::read_from_clipboard() {
+ let flat: String = t.chars().filter(|c| !c.is_control()).collect();
+ if !flat.is_empty() {
+ self.take_selection();
+ self.text.insert_str(self.cursor, &flat);
+ self.cursor += flat.len();
+ }
+ }
+ }
+ "c" | "x" if !self.masked => {
+ if let Some((a, b)) = self.selection.filter(|&(a, b)| a < b) {
+ crate::widget::clipboard::copy_to_clipboard(&self.text[a..b]);
+ if c == "x" {
+ self.take_selection();
+ }
+ }
+ }
+ _ => return EditOutcome::Ignored,
+ },
+ _ => {
+ let insert = match (&event.text, &event.logical_key) {
+ (Some(t), _) if !event.ctrl && !t.chars().any(char::is_control) => {
+ Some(t.clone())
+ }
+ (None, Key::Named(NamedKey::Space)) => Some(" ".to_string()),
+ (None, Key::Character(c)) if !event.ctrl => Some(c.clone()),
+ _ => return EditOutcome::Ignored,
+ };
+ if let Some(t) = insert {
+ self.take_selection();
+ self.text.insert_str(self.cursor, &t);
+ self.cursor += t.len();
+ }
+ }
+ }
+ EditOutcome::Edited
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ev(key: Key, ctrl: bool) -> KeyEvent {
+ let text = match &key {
+ Key::Character(c) if !ctrl => Some(c.clone()),
+ _ => None,
+ };
+ KeyEvent {
+ state: ElementState::Pressed,
+ logical_key: key,
+ text,
+ repeat: false,
+ ctrl,
+ shift: false,
+ alt: false,
+ }
+ }
+ fn ch(c: &str) -> KeyEvent {
+ ev(Key::Character(c.into()), false)
+ }
+ fn ctrl(c: &str) -> KeyEvent {
+ ev(Key::Character(c.into()), true)
+ }
+ fn named(n: NamedKey) -> KeyEvent {
+ ev(Key::Named(n), false)
+ }
+
+ fn typed(e: &mut LineEdit, s: &str) {
+ for c in s.chars() {
+ e.handle_key(&ch(&c.to_string()));
+ }
+ }
+
+ #[test]
+ fn typing_inserts_at_the_caret() {
+ let mut e = LineEdit::default();
+ typed(&mut e, "abc");
+ assert_eq!(e.text, "abc");
+ assert_eq!(e.cursor, 3);
+ }
+
+ /// The URL bar's defining behaviour: entering it selects everything, so
+ /// the next keystroke replaces the address rather than appending to it.
+ #[test]
+ fn typing_over_a_selection_replaces_it() {
+ let mut e = LineEdit::with_text("https://example.com");
+ e.select_all();
+ typed(&mut e, "x");
+ assert_eq!(e.text, "x");
+ assert_eq!(e.selection, None);
+ }
+
+ #[test]
+ fn ctrl_a_selects_all_and_ctrl_u_clears() {
+ let mut e = LineEdit::with_text("abc");
+ e.handle_key(&ctrl("a"));
+ assert_eq!(e.selection, Some((0, 3)));
+ e.handle_key(&ctrl("u"));
+ assert_eq!(e.text, "");
+ assert_eq!(e.selection, None);
+ }
+
+ #[test]
+ fn backspace_deletes_a_selection_whole_or_one_char() {
+ let mut e = LineEdit::with_text("abc");
+ e.select_all();
+ e.handle_key(&named(NamedKey::Backspace));
+ assert_eq!(e.text, "");
+
+ let mut e = LineEdit::with_text("abc");
+ e.handle_key(&named(NamedKey::Backspace));
+ assert_eq!(e.text, "ab");
+ }
+
+ /// Arrows collapse to the edge they move toward rather than stepping from
+ /// the caret — otherwise Left after a select-all lands in the wrong place.
+ #[test]
+ fn arrows_collapse_a_selection_to_its_edge() {
+ let mut e = LineEdit::with_text("abc");
+ e.select_all();
+ e.handle_key(&named(NamedKey::ArrowLeft));
+ assert_eq!((e.cursor, e.selection), (0, None));
+
+ e.select_all();
+ e.handle_key(&named(NamedKey::ArrowRight));
+ assert_eq!((e.cursor, e.selection), (3, None));
+ }
+
+ #[test]
+ fn enter_and_escape_are_reported_not_swallowed() {
+ let mut e = LineEdit::with_text("x");
+ assert_eq!(e.handle_key(&named(NamedKey::Enter)), EditOutcome::Submit);
+ assert_eq!(e.handle_key(&named(NamedKey::Escape)), EditOutcome::Cancel);
+ }
+
+ /// Multi-byte text must not be split mid-character.
+ #[test]
+ fn caret_moves_by_character_not_byte() {
+ let mut e = LineEdit::with_text("é1");
+ e.handle_key(&named(NamedKey::Home));
+ e.handle_key(&named(NamedKey::ArrowRight));
+ assert_eq!(e.cursor, 2, "é is two bytes");
+ e.handle_key(&named(NamedKey::Backspace));
+ assert_eq!(e.text, "1");
+ }
+
+ /// A password must not leave through a chord the user may not have meant.
+ #[test]
+ fn a_masked_field_hides_its_text_and_refuses_copy() {
+ let mut e = LineEdit::masked();
+ typed(&mut e, "hunter2");
+ assert_eq!(e.display(), "•".repeat(7));
+ assert_ne!(e.display(), e.text);
+ e.select_all();
+ assert_eq!(e.handle_key(&ctrl("c")), EditOutcome::Ignored);
+ assert_eq!(e.handle_key(&ctrl("x")), EditOutcome::Ignored);
+ assert_eq!(e.text, "hunter2", "cut must not have removed it");
+ }
+}
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index ea9710f..9086f2f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -623,12 +623,14 @@ impl EmbedImage {
}
#[cfg(feature = "doc_editor")]
pub mod doc_editor;
+pub mod line_edit;
pub mod model;
pub mod scroll_region;
pub mod scroll_motion;
// Re-exports
pub use self::editor::TextEditorState;
+pub use self::line_edit::{EditOutcome, LineEdit};
pub use self::layout_helper::{ColumnLayout, RowLayout};
pub use self::scroll_region::{ScrollRegion, ScrollbarActivity};
pub use self::scroll_motion::{Bounds, ScrollAxis, ScrollMotion, ScrollPhase, ScrollSettings, LINE_PX};