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]
42pub fn LiquidSearchField(modifier: Modifier, state: TextFieldState, spec: LiquidSearchFieldSpec) {
43    let colors = liquid_colors();
44    let typography = liquid_typography();
45    let foreground = spec.foreground.unwrap_or(colors.label);
46    let secondary_foreground = spec.foreground.unwrap_or(colors.secondary_label);
47
48    let base = if spec.on_glass {
49        let glass = spec
50            .foreground
51            .map(|color| {
52                spec.glass
53                    .clone()
54                    .adaptive_frost(color, spec.glass.adaptive_frost)
55            })
56            .unwrap_or_else(|| spec.glass.clone());
57        Modifier::empty().glass_effect(glass)
58    } else {
59        let fill = colors.fill;
60        Modifier::empty()
61            .rounded_corners(999.0)
62            .draw_behind(move |scope| {
63                scope.draw_round_rect(
64                    cranpose_ui_graphics::Brush::solid(fill),
65                    cranpose_ui_graphics::CornerRadii::uniform(999.0),
66                );
67            })
68    };
69
70    let placeholder = spec.placeholder;
71    Box(
72        base.then(modifier).padding_symmetric(14.0, 0.0),
73        BoxSpec::default().content_alignment(Alignment::new(
74            cranpose_ui_layout::HorizontalAlignment::Start,
75            cranpose_ui_layout::VerticalAlignment::CenterVertically,
76        )),
77        move || {
78            let placeholder = placeholder.clone();
79            let body = typography.body.clone();
80            Row(
81                Modifier::empty().fill_max_width(),
82                RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
83                move || {
84                    crate::icons::Icon(crate::icons::SEARCH, None, 18.0, secondary_foreground);
85                    Box(Modifier::empty().width(8.0), BoxSpec::default(), || {});
86
87                    let field_style = TextStyle {
88                        span_style: SpanStyle {
89                            color: Some(foreground),
90                            ..body.span_style.clone()
91                        },
92                        ..body.clone()
93                    };
94                    let placeholder_style = TextStyle {
95                        span_style: SpanStyle {
96                            color: Some(secondary_foreground),
97                            ..body.span_style.clone()
98                        },
99                        ..body.clone()
100                    };
101                    let placeholder = placeholder.clone();
102                    Box(
103                        Modifier::empty().weight(1.0),
104                        BoxSpec::default(),
105                        move || {
106                            let placeholder = placeholder.clone();
107                            let placeholder_style = placeholder_style.clone();
108                            let field_name = placeholder.clone();
109                            BasicTextFieldDecorated(
110                                state,
111                                Modifier::empty()
112                                    .fill_max_width()
113                                    .padding_symmetric(0.0, 9.0)
114                                    .role(cranpose_ui::SemanticsWidgetRole::SearchField)
115                                    .content_description(field_name),
116                                BasicTextFieldOptions {
117                                    text_style: field_style.clone(),
118                                    ..BasicTextFieldOptions::default()
119                                },
120                                move |inner| {
121                                    let empty = state.text().is_empty();
122                                    let placeholder = placeholder.clone();
123                                    let placeholder_style = placeholder_style.clone();
124                                    Box(
125                                        Modifier::empty().fill_max_width(),
126                                        BoxSpec::default().content_alignment(Alignment::new(
127                                            cranpose_ui_layout::HorizontalAlignment::Start,
128                                            cranpose_ui_layout::VerticalAlignment::CenterVertically,
129                                        )),
130                                        move || {
131                                            if empty {
132                                                Text(
133                                                    placeholder.clone(),
134                                                    Modifier::empty().hide_from_accessibility(),
135                                                    placeholder_style.clone(),
136                                                );
137                                            }
138                                            inner.inner_text_field();
139                                        },
140                                    )
141                                },
142                            );
143                        },
144                    );
145                },
146            );
147        },
148    );
149}
150
151#[cfg(test)]
152#[path = "tests/search_field_tests.rs"]
153mod tests;
154
155/// The themed search field with the conventional Compose name.
156#[composable]
157pub fn SearchBar(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
158    LiquidSearchField(
159        modifier,
160        state,
161        LiquidSearchFieldSpec {
162            placeholder: placeholder.into(),
163            ..Default::default()
164        },
165    );
166}
167
168/// Field-oriented name for [`SearchBar`].
169#[composable]
170pub fn SearchField(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
171    SearchBar(modifier, state, placeholder);
172}