From 4efd92b38f7e6f56aaed2b345a05ddb5c27d7268 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 12:37:34 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: the search field is a shared text field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search Photos gets the Cut / Copy / Paste / Select All menu, and Esc gives back the search from before the edit (and filters by it again). The field gains the builder options it needs (no frame, font, colour, alignment, margin) and `text_field::id` for ⌘F to focus it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 35 +++++++++++++++ crates/ui-egui/src/panels/topbar.rs | 18 ++++---- crates/ui-egui/src/text_field.rs | 67 ++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 324f35e62..8b326a06b 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -678,6 +678,41 @@ mod tests { assert!(egui::Popup::is_any_open(&h.view.ctx), "its context menu"); } + /// The search field is a shared text field: right-click ▸ Paste puts the clipboard's text in + /// and filters by it. + #[test] + fn the_search_field_pastes_from_its_menu() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + h.view.clipboard = "wedding".into(); + for (id, button) in [("field:search", "right"), ("field:search:paste", "left")] { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.ui.search, "wedding"); + assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it"); + } + + /// Esc in the search field gives back the search from before the edit, and the filter with it. + #[test] + fn escape_in_the_search_field_gives_back_the_search() { + let mut h = demo([1200.0, 760.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + for (method, params) in [("ui.clickWidget", json!({"id": "field:search"})), ("ui.text", json!({"text": "travel"}))] { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.filter.text, "travel", "filtered while typing"); + let r = h.request("ui.key", json!({"key": "Escape"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", "")); + } + #[test] fn demo_grid_snapshot_has_ui_pixels() { let t0 = Instant::now(); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index d06a75594..6462c87d6 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -79,7 +79,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); // search field (centred on the window) let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); - let id = egui::Id::new("search-field"); + let id = crate::text_field::id("field:search"); if std::mem::take(&mut app.ui.focus_search) { ui.memory_mut(|m| m.request_focus(id)); } @@ -91,7 +91,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Stroke::new(1.0, if focused { t.accent } else { t.field_border }), StrokeKind::Inside, ); - register(ui.ctx(), "field:search", sr); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); let empty = app.ui.search.is_empty(); @@ -102,14 +101,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim); child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim); } - let resp = child.add( - egui::TextEdit::singleline(&mut app.ui.search) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(sr.width() - 20.0) - .font(t.font(13.5)) - .text_color(t.text), - ); + let resp = crate::text_field::TextField::singleline("field:search", &mut app.ui.search) + .frame(false) + .width(sr.width() - 20.0) + .font(t.font(13.5)) + .text_color(t.text) + .show(&mut child) + .response; if resp.changed() { let q = app.ui.search.clone(); let _ = app.run("library.filter", json!({"text": q})); diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index 060c1ba0b..8174d5072 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -47,12 +47,34 @@ pub struct TextField<'a> { hint: Option, width: Option, select_on_focus: bool, + frame: bool, + font: Option, + color: Option, + align: Option, + margin: Option, +} + +/// The egui id of the field registered as `widget`, to give it the focus (⌘F to search). +pub fn id(widget: &str) -> egui::Id { + egui::Id::new("lc-text-field").with(widget) } impl<'a> TextField<'a> { /// A one-line field (Return ends the edit) registered as `widget`. pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { - TextField { widget, text, multiline: false, hint: None, width: None, select_on_focus: false } + TextField { + widget, + text, + multiline: false, + hint: None, + width: None, + select_on_focus: false, + frame: true, + font: None, + color: None, + align: None, + margin: None, + } } /// A multi-line field: Return starts a new line; leaving the field ends the edit. @@ -71,6 +93,32 @@ impl<'a> TextField<'a> { self } + /// Draw the field's own box (on by default); off where the caller draws one around it. + pub fn frame(mut self, on: bool) -> Self { + self.frame = on; + self + } + + pub fn font(mut self, font: egui::FontId) -> Self { + self.font = Some(font); + self + } + + pub fn text_color(mut self, color: egui::Color32) -> Self { + self.color = Some(color); + self + } + + pub fn horizontal_align(mut self, align: egui::Align) -> Self { + self.align = Some(align); + self + } + + pub fn margin(mut self, margin: egui::Margin) -> Self { + self.margin = Some(margin); + self + } + /// Select all the text when the field takes focus, so typing replaces it (names, numbers). pub fn select_on_focus(mut self, on: bool) -> Self { self.select_on_focus = on; @@ -78,7 +126,7 @@ impl<'a> TextField<'a> { } pub fn show(self, ui: &mut Ui) -> TextFieldResponse { - let id = egui::Id::new("lc-text-field").with(self.widget); + let id = id(self.widget); let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); // a choice from the menu last frame: the field takes the focus back and does it, with // egui's own handling of the clipboard events (a password is never copied) @@ -100,6 +148,21 @@ impl<'a> TextField<'a> { if let Some(w) = self.width { edit = edit.desired_width(w); } + if !self.frame { + edit = edit.frame(egui::Frame::NONE); + } + if let Some(font) = self.font { + edit = edit.font(font); + } + if let Some(color) = self.color { + edit = edit.text_color(color); + } + if let Some(align) = self.align { + edit = edit.horizontal_align(align); + } + if let Some(margin) = self.margin { + edit = edit.margin(margin); + } let (escape, enter) = ui.input(|i| (i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Enter))); // a right-click places the cursor as a left one does: the selection the menu acts on // would be gone