use egui::{Color32, Key, Margin, Painter, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use crate::automation::{self, Details, Role};
use crate::tokens::{self, Tokens};
pub struct SearchResponse {
pub response: egui::Response,
pub changed: bool,
pub submitted: bool,
pub cleared: bool,
}
pub struct SearchField<'a> {
id: &'a str,
text: &'a mut String,
hint: &'a str,
width: Option<f32>,
max_len: usize,
height: Option<f32>,
rounded: bool,
text_margin: Option<Margin>,
icon: Option<&'a dyn Fn(&Painter, Rect, Color32)>,
}
impl<'a> SearchField<'a> {
pub fn new(id: &'a str, text: &'a mut String) -> Self {
SearchField { id, text, hint: "Search", width: None, max_len: 512, height: None, rounded: false, text_margin: None, icon: None }
}
pub fn hint(mut self, hint: &'a str) -> Self {
self.hint = hint;
self
}
pub fn width(mut self, width: f32) -> Self {
self.width = Some(width);
self
}
pub fn max_len(mut self, n: usize) -> Self {
self.max_len = n.clamp(1, 65_536);
self
}
pub fn height(mut self, height: f32) -> Self {
self.height = height.is_finite().then(|| height.clamp(12.0, 96.0));
self
}
pub fn rounded(mut self, rounded: bool) -> Self {
self.rounded = rounded;
self
}
pub fn text_margin(mut self, margin: Margin) -> Self {
self.text_margin = Some(margin);
self
}
pub fn icon(mut self, paint: &'a dyn Fn(&Painter, Rect, Color32)) -> Self {
self.icon = Some(paint);
self
}
pub fn focus(ui: &Ui, id: &str) {
let text_id = ui.make_persistent_id(id).with("text");
ui.memory_mut(|m| m.request_focus(text_id));
}
pub fn show(self, ui: &mut Ui) -> SearchResponse {
let t = tokens::current(ui.ctx());
let id = ui.make_persistent_id(self.id);
let width = self.width.filter(|w| w.is_finite() && *w > 0.0).unwrap_or_else(|| ui.available_width()).clamp(60.0, 4096.0);
let h = self.height.unwrap_or(t.control_height);
let (rect, _) = ui.allocate_exact_size(vec2(width, h), Sense::hover());
let icon_w = h;
let clear_w = if self.text.is_empty() { 0.0 } else { h };
let edit_rect = Rect::from_min_max(pos2(rect.left() + icon_w, rect.top()), pos2(rect.right() - clear_w, rect.bottom()));
let bg_idx = ui.painter().add(egui::Shape::Noop);
let before = self.text.len();
let font = Tokens::font(t.type_scale.body);
let row_h = ui.fonts_mut(|f| f.row_height(&font));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(edit_rect).id_salt("ashlar_search"));
let response = child.add(
egui::TextEdit::singleline(self.text)
.id(id.with("text"))
.hint_text(self.hint)
.char_limit(self.max_len)
.desired_width(edit_rect.width())
.font(font)
.frame(egui::Frame::NONE.inner_margin(self.text_margin.unwrap_or(Margin::symmetric(0, ((h - row_h) / 2.0).max(0.0).floor() as i8)))),
);
let mut changed = response.changed() || self.text.len() != before;
let mut cleared = false;
let submitted = response.lost_focus() && ui.input(|i| i.key_pressed(Key::Enter));
if response.has_focus() || response.lost_focus() {
let escape = ui.input(|i| i.key_pressed(Key::Escape));
if escape && !self.text.is_empty() {
self.text.clear();
changed = true;
cleared = true;
response.request_focus();
}
}
if clear_w > 0.0 {
let clear_rect = Rect::from_center_size(pos2(rect.right() - clear_w / 2.0, rect.center().y), Vec2::splat(h * 0.7));
let clear = ui.interact(clear_rect, id.with("clear"), Sense::click());
let col = if clear.hovered() { t.text.primary } else { t.text.secondary };
if clear.hovered() {
ui.painter().rect_filled(clear_rect, t.radius.sm, t.surface.hover);
}
let c = clear_rect.center();
let k = h * 0.14;
ui.painter().line_segment([c - vec2(k, k), c + vec2(k, k)], Stroke::new(1.5, col));
ui.painter().line_segment([c + vec2(-k, k), c + vec2(k, -k)], Stroke::new(1.5, col));
clear.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Clear search"));
let sid = self.id;
if !sid.is_empty() {
automation::register_fmt(ui, clear_rect, format_args!("{sid}.clear"), "Clear search", Role::Button, true, Details::default());
}
if clear.clicked() {
self.text.clear();
changed = true;
cleared = true;
response.request_focus();
}
}
let focused = response.has_focus();
let border = if focused { Stroke::new(1.5, t.focus) } else { Stroke::new(1.0, t.border) };
let radius = if self.rounded { egui::CornerRadius::same((h / 2.0).min(127.0) as u8) } else { egui::CornerRadius::same(t.radius.md) };
let bg = egui::epaint::RectShape::new(rect, radius, t.surface.sunken, border, StrokeKind::Inside);
ui.painter().set(bg_idx, bg);
let cell = Rect::from_min_size(rect.min, Vec2::splat(icon_w));
if let Some(paint) = self.icon {
paint(ui.painter(), cell, t.text.secondary);
} else {
let c = pos2(cell.center().x - 1.0, cell.center().y - 1.0);
let r = h * 0.17;
let glyph = Stroke::new(1.5, t.text.secondary);
ui.painter().circle_stroke(c, r, glyph);
let d = Vec2::splat(std::f32::consts::FRAC_1_SQRT_2);
ui.painter().line_segment([c + d * r, c + d * (r + h * 0.16)], glyph);
}
automation::register_with(ui, &response, self.id, self.hint, Role::Search, Details { value: Some(self.text.as_str()), selected: None });
SearchResponse { response, changed, submitted, cleared }
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
#[derive(Default)]
struct State {
text: String,
submitted: u32,
cleared: u32,
focus: bool,
}
fn harness() -> Harness<'static, State> {
let mut h = Harness::builder().with_size(vec2(300.0, 60.0)).build_ui_state(
|ui, s: &mut State| {
if std::mem::take(&mut s.focus) {
SearchField::focus(ui, "files.search");
}
let out = SearchField::new("files.search", &mut s.text).hint("Filter files").show(ui);
s.submitted += out.submitted as u32;
s.cleared += out.cleared as u32;
},
State::default(),
);
automation::install(&h.ctx);
h.state_mut().focus = true;
h.step();
h.step();
h
}
#[test]
fn typing_escape_clears_then_unfocuses() {
let mut h = harness();
h.event(egui::Event::Text("report".into()));
h.step();
h.step();
assert_eq!(h.state().text, "report");
assert_eq!(automation::find(&h.ctx, "files.search").map(|e| e.value), Some("report".into()));
assert!(automation::find(&h.ctx, "files.search.clear").is_some());
h.key_press(Key::Escape);
h.step();
h.step();
assert_eq!(h.state().text, "");
assert_eq!(h.state().cleared, 1);
h.event(egui::Event::Text("x".into()));
h.step();
assert_eq!(h.state().text, "x", "focus kept after clearing");
h.key_press(Key::Enter);
h.step();
assert_eq!(h.state().submitted, 1);
}
#[test]
fn clear_button_empties_the_query() {
let mut h = harness();
h.event(egui::Event::Text("abc".into()));
h.step();
h.step();
let clear = automation::find(&h.ctx, "files.search.clear").unwrap();
h.hover_at(clear.center());
h.step();
h.drag_at(clear.center());
h.step();
h.drop_at(clear.center());
h.step();
assert_eq!(h.state().text, "");
assert_eq!(h.state().cleared, 1);
}
#[test]
fn house_style_options() {
let painted = std::cell::Cell::new(None);
let mut text = String::new();
let mut h = Harness::builder().with_size(vec2(300.0, 60.0)).build_ui(|ui| {
let icon = |_: &Painter, cell: Rect, _: Color32| painted.set(Some(cell));
SearchField::new("files.search", &mut text).height(22.0).rounded(true).text_margin(Margin::same(3)).icon(&icon).width(180.0).show(ui);
});
h.step();
drop(h);
let cell = painted.get().unwrap();
assert_eq!(cell.size(), vec2(22.0, 22.0));
let mut t = String::new();
assert_eq!(SearchField::new("x", &mut t).height(f32::NAN).height, None);
assert_eq!(SearchField::new("x", &mut t).height(1e9).height, Some(96.0));
}
#[test]
fn length_is_capped() {
let mut h = harness();
h.event(egui::Event::Text("y".repeat(10_000)));
h.step();
assert_eq!(h.state().text.chars().count(), 512);
}
}