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