Skip to main content

cranpose_liquid/widgets/
search_field.rs

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