1use std::cell::{OnceCell, RefCell};
24use std::rc::Rc;
25
26use frus_core::{BorderRadius, BorderSide, Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
27use frus_layout::{Align, Dimension, Style};
28
29use crate::flex::Flex;
30use crate::interaction::Status;
31use crate::theme::Theme;
32use crate::widget::Widget;
33use crate::widgettheme::resolve_shape;
34
35pub const SEARCH_BAR_MIN_WIDTH: f32 = 360.0;
41pub const SEARCH_BAR_MAX_WIDTH: f32 = 800.0;
43pub const SEARCH_BAR_HEIGHT: f32 = 56.0;
45const ELEVATION: f32 = 6.0;
47const PADDING_X: f32 = 8.0;
53const DISABLED_OPACITY: f32 = 0.38;
60
61pub struct SearchBar<Msg = crate::callback::Callback> {
63 value: String,
64 hint: Option<String>,
65 leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
66 trailing: RefCell<Vec<Box<dyn Widget<Msg>>>>,
67 on_input: Option<Rc<dyn Fn(String) -> Msg>>,
68 on_submit: Option<Msg>,
69 on_tap: Option<Msg>,
70 enabled: bool,
71 read_only: bool,
72 background: Option<Color>,
73 shadow_color: Option<Color>,
74 elevation: Option<f32>,
75 shape: Option<ShapeBorder>,
76 radius: Option<BorderRadius>,
77 side: Option<BorderSide>,
78 padding: Option<Insets>,
79 min_width: Option<f32>,
80 max_width: Option<f32>,
81 height: Option<f32>,
82 text_style: Option<TextStyle>,
83 hint_style: Option<TextStyle>,
84 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
85}
86
87impl SearchBar<crate::callback::Callback> {
88 pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
93 self.value = controller.text();
94 let write = controller.clone();
95 self.on_input(move |text| write.set_text(text))
96 }
97}
98
99impl<Msg: Clone + 'static> SearchBar<Msg> {
100 pub fn new(value: impl Into<String>) -> Self {
102 Self {
103 value: value.into(),
104 hint: None,
105 leading: RefCell::new(None),
106 trailing: RefCell::new(Vec::new()),
107 on_input: None,
108 on_submit: None,
109 on_tap: None,
110 enabled: true,
111 read_only: false,
112 background: None,
113 shadow_color: None,
114 elevation: None,
115 shape: None,
116 radius: None,
117 side: None,
118 padding: None,
119 min_width: None,
120 max_width: None,
121 height: None,
122 text_style: None,
123 hint_style: None,
124 built: OnceCell::new(),
125 }
126 }
127
128 pub fn hint(mut self, hint: impl Into<String>) -> Self {
130 self.hint = Some(hint.into());
131 self.rebuild();
132 self
133 }
134
135 pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
137 self.leading_boxed(Box::new(leading))
138 }
139
140 pub fn leading_boxed(mut self, leading: Box<dyn Widget<Msg>>) -> Self {
142 *self.leading.borrow_mut() = Some(leading);
143 self.rebuild();
144 self
145 }
146
147 pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
151 self.trailing_boxed(Box::new(trailing))
152 }
153
154 pub fn trailing_boxed(mut self, trailing: Box<dyn Widget<Msg>>) -> Self {
156 self.trailing.borrow_mut().push(trailing);
157 self.rebuild();
158 self
159 }
160
161 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
163 mut self,
164 on_input: impl Fn(String) -> R + 'static,
165 ) -> Self {
166 self.on_input = Some(Rc::new(crate::callback::handler1(on_input)));
167 self.rebuild();
168 self
169 }
170
171 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
173 self.on_submit = Some(message.into());
174 self.rebuild();
175 self
176 }
177
178 pub fn on_tap(mut self, message: impl Into<Msg>) -> Self {
182 self.on_tap = Some(message.into());
183 self.rebuild();
184 self
185 }
186
187 pub fn enabled(mut self, enabled: bool) -> Self {
189 self.enabled = enabled;
190 self.rebuild();
191 self
192 }
193
194 pub fn read_only(mut self, read_only: bool) -> Self {
198 self.read_only = read_only;
199 self.rebuild();
200 self
201 }
202
203 pub fn background_color(mut self, color: Color) -> Self {
206 self.background = Some(color);
207 self
208 }
209
210 pub fn shadow_color(mut self, color: Color) -> Self {
213 self.shadow_color = Some(color);
214 self
215 }
216
217 pub fn elevation(mut self, elevation: f32) -> Self {
220 self.elevation = Some(elevation);
221 self
222 }
223
224 pub fn shape(mut self, shape: ShapeBorder) -> Self {
226 self.shape = Some(shape);
227 self
228 }
229
230 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
232 self.radius = Some(radius.into());
233 self
234 }
235
236 pub fn side(mut self, width: f32, color: Color) -> Self {
239 self.side = Some(BorderSide { width, color });
240 self
241 }
242
243 pub fn padding(mut self, padding: Insets) -> Self {
245 self.padding = Some(padding);
246 self.rebuild();
247 self
248 }
249
250 pub fn min_width(mut self, width: f32) -> Self {
252 self.min_width = Some(width);
253 self
254 }
255
256 pub fn max_width(mut self, width: f32) -> Self {
258 self.max_width = Some(width);
259 self
260 }
261
262 pub fn height(mut self, height: f32) -> Self {
264 self.height = Some(height);
265 self
266 }
267
268 pub fn text_style(mut self, style: TextStyle) -> Self {
271 self.text_style = Some(style);
272 self.rebuild();
273 self
274 }
275
276 pub fn hint_style(mut self, style: TextStyle) -> Self {
281 self.hint_style = Some(style);
282 self.rebuild();
283 self
284 }
285
286 fn rebuild(&mut self) {
288 self.built.take();
289 }
290
291 fn room(&self, theme: &Theme) -> Insets {
293 self.padding
294 .or(theme.widgets.search_bar.padding)
295 .unwrap_or(Insets::new(0.0, PADDING_X, 0.0, PADDING_X))
296 }
297
298 fn outline(&self, theme: &Theme) -> ShapeBorder {
300 let t = &theme.widgets.search_bar;
301 let shape = resolve_shape(
302 self.shape,
303 t.shape,
304 self.radius.or(t.radius),
305 ShapeBorder::stadium(),
306 );
307 match self.side.or(t.side) {
308 Some(side) => shape.with_side(side),
309 None => shape,
310 }
311 }
312
313 fn value_style(&self, theme: &Theme) -> TextStyle {
315 self.text_style
316 .or(theme.widgets.search_bar.text_style)
317 .unwrap_or_else(|| crate::theme::type_scale(Some(theme)).body_large)
318 }
319
320 fn hint_style_of(&self, theme: &Theme) -> TextStyle {
328 let t = &theme.widgets.search_bar;
329 self.hint_style
330 .or(t.hint_style)
331 .or(self.text_style)
332 .or(t.text_style)
333 .unwrap_or_else(|| crate::theme::type_scale(Some(theme)).body_large)
334 }
335
336 fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
338 let text_style = self.value_style(theme);
339 let hint_style = self.hint_style_of(theme);
340
341 let mut field = crate::TextField::<Msg>::new(self.value.clone())
342 .borderless()
343 .dense(true)
344 .enabled(self.enabled)
345 .style(crate::TextFieldStyle {
346 text_color: text_style.color.or(Some(theme.scheme.on_surface)),
347 label_color: hint_style.color.or(Some(theme.scheme.on_surface_variant)),
350 padding_x: Some(self.room(theme).left),
354 ..Default::default()
355 });
356 if let Some(size) = text_style.size {
357 field = field.size(size);
358 }
359 if let Some(hint) = &self.hint {
360 field = field.placeholder(hint.clone());
361 }
362 if self.read_only {
363 field = field.read_only();
364 }
365 if let Some(on_input) = &self.on_input {
366 let on_input = on_input.clone();
367 field = field.on_input(move |value| on_input(value));
368 }
369 if let Some(message) = &self.on_submit {
370 field = field.on_submit(message.clone());
371 }
372 let mut row = Flex::<Msg>::row().align(Align::Center);
373 if let Some(leading) = self.leading.borrow_mut().take() {
374 row = row.child_boxed(leading);
375 }
376 row = row.child(crate::Expanded::new(field));
377 for trailing in std::mem::take(&mut *self.trailing.borrow_mut()) {
378 row = row.child_boxed(trailing);
379 }
380 vec![Box::new(row)]
381 }
382}
383
384impl<Msg: Clone + 'static> Widget<Msg> for SearchBar<Msg> {
385 fn style(&self) -> Style {
386 Style {
387 height: Dimension::Length(self.height.unwrap_or(SEARCH_BAR_HEIGHT)),
388 min_width: Dimension::Length(self.min_width.unwrap_or(SEARCH_BAR_MIN_WIDTH)),
389 max_width: Dimension::Length(self.max_width.unwrap_or(SEARCH_BAR_MAX_WIDTH)),
390 align: Align::Center,
391 padding: Insets::new(0.0, PADDING_X, 0.0, PADDING_X),
392 ..Default::default()
393 }
394 }
395
396 fn style_themed(&self, theme: &Theme) -> Style {
397 let t = &theme.widgets.search_bar;
398 let room = self.room(theme);
399 Style {
400 height: Dimension::Length(self.height.or(t.height).unwrap_or(SEARCH_BAR_HEIGHT)),
401 min_width: Dimension::Length(
402 self.min_width
403 .or(t.min_width)
404 .unwrap_or(SEARCH_BAR_MIN_WIDTH),
405 ),
406 max_width: Dimension::Length(
407 self.max_width
408 .or(t.max_width)
409 .unwrap_or(SEARCH_BAR_MAX_WIDTH),
410 ),
411 padding: room,
412 ..Widget::<Msg>::style(self)
413 }
414 }
415
416 fn build_themed(&self, theme: &Theme) {
417 let _ = self.built.set(self.assemble(theme));
418 }
419
420 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
421 self.built.get_or_init(|| self.assemble(&Theme::default()))
422 }
423
424 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
425 let o = status.opacity * if self.enabled { 1.0 } else { DISABLED_OPACITY };
427 let t = &theme.widgets.search_bar;
428
429 let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
430 let shadow = self
433 .shadow_color
434 .or(t.shadow_color)
435 .unwrap_or(theme.scheme.shadow);
436 if depth > 0.0 && shadow.a > 0.0 {
437 frus_core::paint_elevation(
439 scene,
440 bounds,
441 BorderRadius::uniform(bounds.height * 0.5),
442 depth,
443 shadow.fade(o),
444 );
445 }
446
447 let base = self
448 .background
449 .or(t.background_color)
450 .unwrap_or(theme.scheme.surface_container_high);
451 let fill = if self.enabled && base.a > 0.0 {
463 theme.state_layer(base, theme.scheme.on_surface, &status)
464 } else {
465 base
466 };
467 scene.draw_shape(bounds, self.outline(theme), fill.fade(o));
468 }
469
470 fn on_click(&self) -> Option<Msg> {
475 self.enabled.then(|| self.on_tap.clone()).flatten()
476 }
477
478 fn focusable(&self) -> bool {
479 self.enabled
480 }
481
482 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
486 let mut props = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
487 .value(self.value.clone())
488 .disabled(!self.enabled);
489 if let Some(hint) = &self.hint {
490 props = props.label(hint.clone());
491 }
492 if self.enabled {
493 props = props.clickable();
494 }
495 Some(props)
496 }
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use frus_core::Primitive;
503
504 #[derive(Clone, Debug, PartialEq)]
505 enum Msg {
506 Open,
507 Query(String),
508 }
509
510 fn bar() -> SearchBar<Msg> {
511 SearchBar::new("").hint("Search mail")
512 }
513
514 fn scene_of(widget: &dyn Widget<Msg>, status: Status, theme: &Theme) -> Scene {
515 let mut scene = Scene::new();
516 widget.paint(
517 Rect::new(0.0, 0.0, 400.0, SEARCH_BAR_HEIGHT),
518 status,
519 theme,
520 &mut scene,
521 );
522 scene
523 }
524
525 fn lit(status: Status) -> Status {
526 Status {
527 opacity: 1.0,
528 ..status
529 }
530 }
531
532 #[test]
536 fn it_asks_for_the_box_the_reference_asks_for() {
537 let theme = Theme::default();
538 let style = Widget::<Msg>::style_themed(&bar(), &theme);
539 assert_eq!(style.min_width, Dimension::Length(SEARCH_BAR_MIN_WIDTH));
540 assert_eq!(style.max_width, Dimension::Length(SEARCH_BAR_MAX_WIDTH));
541 assert_eq!(style.height, Dimension::Length(SEARCH_BAR_HEIGHT));
542
543 let narrow = Widget::<Msg>::style_themed(&bar().min_width(240.0).height(40.0), &theme);
544 assert_eq!(narrow.min_width, Dimension::Length(240.0));
545 assert_eq!(narrow.height, Dimension::Length(40.0));
546 }
547
548 #[test]
552 fn the_hint_takes_the_values_type_before_its_own_default() {
553 let theme = Theme::default();
554 let plain = bar();
555 assert_eq!(
556 plain.hint_style_of(&theme),
557 crate::theme::type_scale(Some(&theme)).body_large,
558 "with nothing said, both are the default step"
559 );
560
561 let told = bar().text_style(TextStyle::new(22.0));
562 assert_eq!(
563 told.hint_style_of(&theme).size,
564 Some(22.0),
565 "the hint follows the value it stands in for"
566 );
567
568 let both = bar()
569 .text_style(TextStyle::new(22.0))
570 .hint_style(TextStyle::new(12.0));
571 assert_eq!(
572 both.hint_style_of(&theme).size,
573 Some(12.0),
574 "and a hint told its own type keeps it"
575 );
576 assert_eq!(both.value_style(&theme).size, Some(22.0));
577 }
578
579 #[test]
583 fn a_disabled_bar_is_inert_and_faded() {
584 let theme = Theme::default();
585 let off = bar().on_tap(Msg::Open).enabled(false);
586 assert_eq!(Widget::<Msg>::on_click(&off), None);
587 assert!(!Widget::<Msg>::focusable(&off));
588 let semantics = Widget::<Msg>::semantics(&off).expect("still announced");
589 assert!(semantics.disabled);
590 assert!(!semantics.clickable);
591
592 let on = bar().on_tap(Msg::Open);
593 assert_eq!(Widget::<Msg>::on_click(&on), Some(Msg::Open));
594 assert!(Widget::<Msg>::focusable(&on));
595
596 let alpha = |bar: &SearchBar<Msg>| {
598 scene_of(bar, lit(Status::default()), &theme)
599 .primitives()
600 .iter()
601 .rev()
602 .find_map(|p| match p {
603 Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(color.a),
604 _ => None,
605 })
606 .expect("the bar paints its pill")
607 };
608 assert!(
609 (alpha(&off) - DISABLED_OPACITY).abs() < 0.01,
610 "{}",
611 alpha(&off)
612 );
613 assert!((alpha(&on) - 1.0).abs() < 0.01);
614 }
615
616 #[test]
619 fn a_disabled_bar_does_not_light_under_a_pointer() {
620 let theme = Theme::default();
621 let hovered = Status {
622 opacity: 1.0,
623 hover_progress: 1.0,
624 ..Default::default()
625 };
626 let fill = |bar: &SearchBar<Msg>, status: Status| {
627 scene_of(bar, status, &theme)
628 .primitives()
629 .iter()
630 .rev()
631 .find_map(|p| match p {
632 Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
633 _ => None,
634 })
635 .expect("the bar paints its pill")
636 };
637 let resting = fill(&bar(), lit(Status::default()));
638 assert_ne!(
639 fill(&bar(), hovered),
640 resting,
641 "an available bar answers a pointer"
642 );
643 let off = bar().enabled(false);
644 assert_eq!(
645 fill(&off, hovered).fade(1.0),
646 fill(&off, lit(Status::default())).fade(1.0),
647 "an unavailable one has nothing to say to it"
648 );
649 }
650
651 #[test]
655 fn the_field_inside_paints_no_container() {
656 let theme = Theme::default();
657 let boxed = crate::TextField::<Msg>::new("x").filled();
658 let bare = crate::TextField::<Msg>::new("x").borderless();
659 let rects = |field: &crate::TextField<Msg>| {
660 let mut scene = Scene::new();
661 field.paint(
662 Rect::new(0.0, 0.0, 200.0, 48.0),
663 lit(Status::default()),
664 &theme,
665 &mut scene,
666 );
667 scene
668 .primitives()
669 .iter()
670 .filter(|p| matches!(p, Primitive::Rect { .. }))
671 .count()
672 };
673 assert!(rects(&boxed) > 0, "a filled field draws its container");
674 assert_eq!(rects(&bare), 0, "a borderless one draws none of it");
675 }
676
677 #[test]
681 fn the_whole_pill_answers_a_press() {
682 let bar = bar().read_only(true).on_tap(Msg::Open);
683 assert_eq!(Widget::<Msg>::on_click(&bar), Some(Msg::Open));
684 assert_eq!(
685 Widget::<Msg>::on_click(&SearchBar::<Msg>::new("")),
686 None,
687 "and a bar with nothing to open answers nothing"
688 );
689 }
690
691 #[test]
693 fn it_announces_its_value() {
694 let bar = SearchBar::<Msg>::new("mail").hint("Search mail");
695 let semantics = Widget::<Msg>::semantics(&bar).expect("announced");
696 assert_eq!(semantics.role, frus_core::Role::TextInput);
697 assert_eq!(semantics.value.as_deref(), Some("mail"));
698 assert_eq!(semantics.label.as_deref(), Some("Search mail"));
699 }
700
701 #[test]
703 fn saying_it_afterwards_says_it() {
704 let theme = Theme::default();
705 let after = SearchBar::<Msg>::new("")
706 .on_input(Msg::Query)
707 .hint("Search");
708 after.build_themed(&theme);
709 let mut found = 0;
710 fn walk<Msg: Clone + 'static>(node: &dyn Widget<Msg>, found: &mut usize) {
711 if node.debug_name() == "TextField" {
712 *found += 1;
713 }
714 for child in node.children() {
715 walk(&**child, found);
716 }
717 }
718 for child in Widget::<Msg>::children(&after) {
719 walk(&**child, &mut found);
720 }
721 assert_eq!(found, 1, "one field, built once");
722 }
723
724 #[test]
727 fn an_outline_rides_on_the_shape() {
728 let theme = Theme::default();
729 assert_eq!(bar().outline(&theme), ShapeBorder::stadium());
730 let edged = bar().side(2.0, Color::rgb8(200, 0, 0));
731 assert_eq!(edged.outline(&theme).side().width, 2.0);
732 let squared = bar().radius(4.0).side(1.0, Color::rgb8(0, 0, 0));
733 assert!(matches!(
734 squared.outline(&theme),
735 ShapeBorder::RoundedRectangle { .. }
736 ));
737 }
738
739 #[test]
744 fn a_named_shadow_colour_is_the_colour() {
745 let theme = Theme::default();
746 let shadows = |bar: &SearchBar<Msg>, theme: &Theme| {
747 crate::shadowprobe::colour(scene_of(bar, lit(Status::default()), theme).primitives())
748 };
749 assert_eq!(
750 shadows(&bar(), &theme),
751 Some(theme.scheme.shadow),
752 "the default, unchanged"
753 );
754 assert!(
755 shadows(&bar().shadow_color(Color::TRANSPARENT), &theme).is_none(),
756 "a transparent shadow casts none"
757 );
758 let half = Color::rgba(0.2, 0.0, 0.4, 0.5);
759 assert_eq!(shadows(&bar().shadow_color(half), &theme), Some(half));
760 let mut themed = Theme::default();
761 themed.widgets.search_bar.shadow_color = Some(half);
762 assert_eq!(
763 shadows(&bar(), &themed),
764 Some(half),
765 "the theme's, likewise"
766 );
767 }
768}