Skip to main content

cranpose_liquid/widgets/
search_field.rs

1//! A pill search field on glass.
2
3use crate::material::Glass;
4use crate::material::LiquidModifierExt;
5use crate::theme::{liquid_colors, liquid_typography};
6use cranpose_foundation::text::TextFieldState;
7use cranpose_macros::composable;
8use cranpose_ui::text::{SpanStyle, TextStyle};
9use cranpose_ui::widgets::{
10    BasicTextFieldDecorated, BasicTextFieldOptions, Box, BoxSpec, Row, RowSpec, Text,
11};
12use cranpose_ui::Modifier;
13use cranpose_ui_layout::{Alignment, VerticalAlignment};
14
15/// Configuration for [`LiquidSearchField`].
16#[derive(Clone, Debug, PartialEq)]
17pub struct LiquidSearchFieldSpec {
18    pub placeholder: String,
19    /// Render on glass (floating) instead of the flat fill (inline in lists).
20    pub on_glass: bool,
21}
22
23impl Default for LiquidSearchFieldSpec {
24    fn default() -> Self {
25        Self {
26            placeholder: "Search".to_string(),
27            on_glass: true,
28        }
29    }
30}
31
32/// A capsule search field: magnifier icon and editable text.
33#[composable]
34#[allow(non_snake_case)]
35pub fn LiquidSearchField(modifier: Modifier, state: TextFieldState, spec: LiquidSearchFieldSpec) {
36    let colors = liquid_colors();
37    let typography = liquid_typography();
38
39    let base = if spec.on_glass {
40        Modifier::empty().glass_effect(Glass::regular())
41    } else {
42        let fill = colors.fill;
43        Modifier::empty()
44            .rounded_corners(999.0)
45            .draw_behind(move |scope| {
46                scope.draw_round_rect(
47                    cranpose_ui_graphics::Brush::solid(fill),
48                    cranpose_ui_graphics::CornerRadii::uniform(999.0),
49                );
50            })
51    };
52
53    let placeholder = spec.placeholder.clone();
54    Box(
55        base.then(modifier).padding_symmetric(14.0, 9.0),
56        BoxSpec::default().content_alignment(Alignment::new(
57            cranpose_ui_layout::HorizontalAlignment::Start,
58            cranpose_ui_layout::VerticalAlignment::CenterVertically,
59        )),
60        move || {
61            let placeholder = placeholder.clone();
62            let body = typography.body.clone();
63            Row(
64                Modifier::empty().fill_max_width(),
65                RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
66                move || {
67                    crate::icons::Icon(crate::icons::SEARCH, 18.0, colors.secondary_label);
68                    Box(Modifier::empty().width(8.0), BoxSpec::default(), || {});
69
70                    let field_style = TextStyle {
71                        span_style: SpanStyle {
72                            color: Some(colors.label),
73                            ..body.span_style.clone()
74                        },
75                        ..body.clone()
76                    };
77                    let placeholder_style = TextStyle {
78                        span_style: SpanStyle {
79                            color: Some(colors.secondary_label),
80                            ..body.span_style.clone()
81                        },
82                        ..body.clone()
83                    };
84                    let placeholder = placeholder.clone();
85                    // The field must fill the pill so it has a non-zero width
86                    // (and stays hit-testable) even when empty — otherwise an
87                    // empty search field measures to 0px and rejects every tap,
88                    // so focus + the soft keyboard never fire.
89                    Box(
90                        Modifier::empty().weight(1.0),
91                        BoxSpec::default(),
92                        move || {
93                            let placeholder = placeholder.clone();
94                            let placeholder_style = placeholder_style.clone();
95                            BasicTextFieldDecorated(
96                                state,
97                                Modifier::empty().fill_max_width(),
98                                BasicTextFieldOptions {
99                                    text_style: field_style.clone(),
100                                    ..BasicTextFieldOptions::default()
101                                },
102                                move |inner| {
103                                    let empty = state.text().is_empty();
104                                    let placeholder = placeholder.clone();
105                                    let placeholder_style = placeholder_style.clone();
106                                    Box(
107                                        Modifier::empty().fill_max_width(),
108                                        BoxSpec::default().content_alignment(Alignment::new(
109                                            cranpose_ui_layout::HorizontalAlignment::Start,
110                                            cranpose_ui_layout::VerticalAlignment::CenterVertically,
111                                        )),
112                                        move || {
113                                            if empty {
114                                                Text(
115                                                    placeholder.clone(),
116                                                    Modifier::empty(),
117                                                    placeholder_style.clone(),
118                                                );
119                                            }
120                                            inner.inner_text_field();
121                                        },
122                                    )
123                                },
124                            );
125                        },
126                    );
127                },
128            );
129        },
130    );
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn search_spec_defaults_to_a_search_label_and_glass() {
139        let spec = LiquidSearchFieldSpec::default();
140        assert_eq!(spec.placeholder, "Search");
141        assert!(spec.on_glass);
142    }
143}
144
145/// The themed search field with the conventional Compose name.
146#[composable]
147#[allow(non_snake_case)]
148pub fn SearchBar(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
149    LiquidSearchField(
150        modifier,
151        state,
152        LiquidSearchFieldSpec {
153            placeholder: placeholder.into(),
154            ..Default::default()
155        },
156    );
157}
158
159/// Field-oriented name for [`SearchBar`].
160#[composable]
161#[allow(non_snake_case)]
162pub fn SearchField(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
163    SearchBar(modifier, state, placeholder);
164}