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, 0.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, None, 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                            let field_name = placeholder.clone();
110                            BasicTextFieldDecorated(
111                                state,
112                                Modifier::empty()
113                                    .fill_max_width()
114                                    .padding_symmetric(0.0, 9.0)
115                                    .role(cranpose_ui::SemanticsWidgetRole::SearchField)
116                                    .content_description(field_name),
117                                BasicTextFieldOptions {
118                                    text_style: field_style.clone(),
119                                    ..BasicTextFieldOptions::default()
120                                },
121                                move |inner| {
122                                    let empty = state.text().is_empty();
123                                    let placeholder = placeholder.clone();
124                                    let placeholder_style = placeholder_style.clone();
125                                    Box(
126                                        Modifier::empty().fill_max_width(),
127                                        BoxSpec::default().content_alignment(Alignment::new(
128                                            cranpose_ui_layout::HorizontalAlignment::Start,
129                                            cranpose_ui_layout::VerticalAlignment::CenterVertically,
130                                        )),
131                                        move || {
132                                            if empty {
133                                                Text(
134                                                    placeholder.clone(),
135                                                    Modifier::empty(),
136                                                    placeholder_style.clone(),
137                                                );
138                                            }
139                                            inner.inner_text_field();
140                                        },
141                                    )
142                                },
143                            );
144                        },
145                    );
146                },
147            );
148        },
149    );
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn search_spec_defaults_to_a_search_label_and_glass() {
158        let spec = LiquidSearchFieldSpec::default();
159        assert_eq!(spec.placeholder, "Search");
160        assert!(spec.on_glass);
161        assert_eq!(spec.glass, Glass::regular());
162        assert_eq!(spec.foreground, None);
163    }
164}
165
166/// The themed search field with the conventional Compose name.
167#[composable]
168#[allow(non_snake_case)]
169pub fn SearchBar(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
170    LiquidSearchField(
171        modifier,
172        state,
173        LiquidSearchFieldSpec {
174            placeholder: placeholder.into(),
175            ..Default::default()
176        },
177    );
178}
179
180/// Field-oriented name for [`SearchBar`].
181#[composable]
182#[allow(non_snake_case)]
183pub fn SearchField(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
184    SearchBar(modifier, state, placeholder);
185}