git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit75d590199c8b9cc159ac2c00cce6c67bb8af06ee
parent74ad203398
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 15:25
refactor: LineEdit lives in cce-ui now

lineedit.rs (the URL bar's and the dialog fields' one-line editor) moved
to cce_ui::widget::line_edit (cce-ui 34bdb08) with its tests; this crate
uses it from there. No behaviour change.

CLAUDE.md's opening count was stale before this (the Raindrop files):
eighteen files, ~11.4k lines, eleven in the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |   2 +-
 src/lineedit.rs | 304 --------------------------------------------------------
 src/main.rs     |  39 ++++----
 3 files changed, 20 insertions(+), 325 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5c717d9..8cab3f7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,7 @@ remote at all, so check `origin/master` after committing). Read the workspace-le
 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
 conventions, and the multi-repo rules all live there.
 
-Sixteen files, ~8.8k lines. The ten that carry the design:
+Eighteen files, ~11.4k lines. The eleven that carry the design:
 
 | file | what it owns |
 | --- | --- |
diff --git a/src/lineedit.rs b/src/lineedit.rs
deleted file mode 100644
index 524ec61..0000000
--- a/src/lineedit.rs
+++ /dev/null
@@ -1,304 +0,0 @@
-//! A one-line text field: the text, a caret, and a selection.
-//!
-//! Written as the shared editor the chrome should use everywhere. The URL bar
-//! still has its own copy of this logic welded into `BrowserApp` (59 call
-//! sites); migrating it is a mechanical change worth doing on its own rather
-//! than folded into a feature, so for now this backs the dialog fields only.
-
-use cce_ui::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,
-}
-
-/// Also used by the chrome's text elision.
-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
-}
-
-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) = cce_ui::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) {
-                        cce_ui::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::*;
-    use cce_ui::widget::{ElementState, Key, NamedKey};
-
-    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/main.rs b/src/main.rs
index abbca35..c513a56 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,7 +11,6 @@
 mod accounts;
 mod downloads;
 mod instance;
-mod lineedit;
 mod pages;
 mod raindrop;
 mod session;
@@ -189,7 +188,7 @@ struct Modal {
     title: String,
     message: String,
     /// Editable fields, in tab order. Empty for a bare alert or confirm.
-    fields: Vec<(&'static str, lineedit::LineEdit)>,
+    fields: Vec<(&'static str, cce_ui::widget::LineEdit)>,
     focused: usize,
     has_cancel: bool,
     kind: ModalKind,
@@ -553,7 +552,7 @@ struct BrowserApp {
     /// URL bar contents; mirrors the page URL unless the bar is focused.
     /// Text, caret and selection all live in the shared editor — the same
     /// one the dialog fields use.
-    url: lineedit::LineEdit,
+    url: cce_ui::widget::LineEdit,
     url_focused: bool,
     /// The circle menu: the DE's corner control toggles the utility bar,
     /// which unfolds from under it. `chrome_t` is the unfold progress
@@ -1618,7 +1617,7 @@ impl BrowserApp {
         if !self.url_focused {
             if let Some(u) = self.host.url() {
                 let s = u.to_string();
-                self.url = lineedit::LineEdit::with_text(
+                self.url = cce_ui::widget::LineEdit::with_text(
                     if s == "about:blank" { String::new() } else { s },
                 );
             }
@@ -1635,7 +1634,7 @@ impl BrowserApp {
         if let Some(d) = self.host.pending_dialog() {
             let mut fields = Vec::new();
             if let Some(default) = d.prompt_default.clone() {
-                let mut e = lineedit::LineEdit::with_text(default);
+                let mut e = cce_ui::widget::LineEdit::with_text(default);
                 e.select_all();
                 fields.push(("", e));
             }
@@ -1663,8 +1662,8 @@ impl BrowserApp {
                 },
                 message: where_,
                 fields: vec![
-                    ("Username", lineedit::LineEdit::default()),
-                    ("Password", lineedit::LineEdit::masked()),
+                    ("Username", cce_ui::widget::LineEdit::default()),
+                    ("Password", cce_ui::widget::LineEdit::masked()),
                 ],
                 focused: 0,
                 has_cancel: true,
@@ -1908,7 +1907,7 @@ impl BrowserApp {
     fn new_tab(&mut self) {
         let url = Url::parse("about:blank").expect("about:blank");
         self.host.open_tab(url);
-        self.url = lineedit::LineEdit::default();
+        self.url = cce_ui::widget::LineEdit::default();
         self.url_focused = true;
         // The focused field has to be on screen, so a new tab unfolds the
         // menu even when it was opened by chord.
@@ -1976,7 +1975,7 @@ impl BrowserApp {
         }
         let mut end = text.len();
         while end > 0 {
-            end = lineedit::prev_boundary(text, end);
+            end = cce_ui::widget::line_edit::prev_boundary(text, end);
             let cut = format!("{}...", &text[..end]);
             if measure_text_width(&cut, sans, size) <= avail {
                 return cut;
@@ -2386,18 +2385,18 @@ impl BrowserApp {
 
 
 
-    /// URL-bar keys. Editing is the shared [`lineedit::LineEdit`]; only what
+    /// URL-bar keys. Editing is the shared [`cce_ui::widget::LineEdit`]; only what
     /// makes this bar a *URL* bar — Enter navigates, Escape returns focus to
     /// the page — is decided here.
     fn edit_url(&mut self, event: &KeyEvent) {
         match self.url.handle_key(event) {
-            lineedit::EditOutcome::Submit => self.navigate(),
-            lineedit::EditOutcome::Cancel => {
+            cce_ui::widget::EditOutcome::Submit => self.navigate(),
+            cce_ui::widget::EditOutcome::Cancel => {
                 self.url_focused = false;
                 self.url.selection = None;
                 self.sync_page_state();
             }
-            lineedit::EditOutcome::Edited | lineedit::EditOutcome::Ignored => {}
+            cce_ui::widget::EditOutcome::Edited | cce_ui::widget::EditOutcome::Ignored => {}
         }
     }
 }
@@ -2479,7 +2478,7 @@ impl Application for BrowserApp {
             scale: 1.0,
             pointer: (0.0, 0.0),
             page_buttons: Vec::new(),
-            url: lineedit::LineEdit::with_text(url_text),
+            url: cce_ui::widget::LineEdit::with_text(url_text),
             url_focused: false,
             chrome_open: false,
             chrome_t: 0.0,
@@ -3145,18 +3144,18 @@ impl Application for BrowserApp {
                 // No field: Enter accepts, Escape cancels, nothing else acts.
                 Some(_) => match (&event.logical_key, event.state) {
                     (Key::Named(NamedKey::Enter), ElementState::Pressed) => {
-                        lineedit::EditOutcome::Submit
+                        cce_ui::widget::EditOutcome::Submit
                     }
                     (Key::Named(NamedKey::Escape), ElementState::Pressed) => {
-                        lineedit::EditOutcome::Cancel
+                        cce_ui::widget::EditOutcome::Cancel
                     }
-                    _ => lineedit::EditOutcome::Ignored,
+                    _ => cce_ui::widget::EditOutcome::Ignored,
                 },
-                None => lineedit::EditOutcome::Ignored,
+                None => cce_ui::widget::EditOutcome::Ignored,
             };
             match outcome {
-                lineedit::EditOutcome::Submit => self.close_modal(true),
-                lineedit::EditOutcome::Cancel => self.close_modal(false),
+                cce_ui::widget::EditOutcome::Submit => self.close_modal(true),
+                cce_ui::widget::EditOutcome::Cancel => self.close_modal(false),
                 _ => {}
             }
             return None;