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    /// Optional material override for the floating variant.
23    pub glass: Option<Glass>,
24    /// Foreground override for the icon, input, and placeholder. `None` uses
25    /// the theme's primary color and a subdued placeholder.
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: None,
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 placeholder_color = spec.foreground.unwrap_or_else(|| {
47        colors
48            .label
49            .with_alpha(if colors.is_dark { 0.35 } else { 0.40 })
50    });
51
52    let base = if spec.on_glass {
53        let mut glass = spec
54            .glass
55            .unwrap_or_else(|| super::glass_surface::control_surface_material(foreground));
56        if let Some(color) = spec.foreground {
57            let frost = glass.adaptive_frost;
58            glass = glass.adaptive_frost(color, frost);
59        }
60        Modifier::empty().glass_effect(glass)
61    } else {
62        let fill = colors.fill;
63        Modifier::empty()
64            .rounded_corners(999.0)
65            .draw_behind(move |scope| {
66                scope.draw_round_rect(
67                    cranpose_ui_graphics::Brush::solid(fill),
68                    cranpose_ui_graphics::CornerRadii::uniform(999.0),
69                );
70            })
71    };
72
73    let body = typography.body;
74    let field_style = TextStyle {
75        span_style: SpanStyle {
76            color: Some(foreground),
77            ..body.span_style.clone()
78        },
79        ..body.clone()
80    };
81    let placeholder_style = TextStyle {
82        span_style: SpanStyle {
83            color: Some(placeholder_color),
84            ..body.span_style.clone()
85        },
86        ..body
87    };
88    let placeholder = spec.placeholder;
89    BasicTextFieldDecorated(
90        state,
91        modifier
92            .then(base)
93            .height_in(44.0, f32::INFINITY)
94            .padding_symmetric(11.0, 0.0)
95            .role(cranpose_ui::SemanticsWidgetRole::SearchField)
96            .content_description(placeholder.clone()),
97        BasicTextFieldOptions {
98            text_style: field_style,
99            ..BasicTextFieldOptions::default()
100        },
101        move |inner| {
102            let placeholder = placeholder.clone();
103            let placeholder_style = placeholder_style.clone();
104            Row(
105                Modifier::empty().fill_max_width(),
106                RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
107                move || {
108                    crate::icons::Icon(crate::icons::SEARCH, None, 24.0, foreground);
109                    Box(Modifier::empty().width(5.0), BoxSpec::default(), || {});
110                    let placeholder = placeholder.clone();
111                    let placeholder_style = placeholder_style.clone();
112                    let inner = inner.clone();
113                    Box(
114                        Modifier::empty().weight(1.0).padding_symmetric(0.0, 9.0),
115                        BoxSpec {
116                            content_alignment: Alignment::new(
117                                cranpose_ui_layout::HorizontalAlignment::Start,
118                                cranpose_ui_layout::VerticalAlignment::CenterVertically,
119                            ),
120                            propagate_min_constraints: true,
121                        },
122                        move || {
123                            if state.text().is_empty() {
124                                Text(
125                                    placeholder.clone(),
126                                    Modifier::empty().hide_from_accessibility(),
127                                    placeholder_style.clone(),
128                                );
129                            }
130                            inner.inner_text_field();
131                        },
132                    );
133                },
134            );
135        },
136    );
137}
138
139#[cfg(test)]
140#[path = "tests/search_field_tests.rs"]
141mod tests;
142
143/// The themed search field with the conventional Compose name.
144#[composable]
145pub fn SearchBar(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
146    LiquidSearchField(
147        modifier,
148        state,
149        LiquidSearchFieldSpec {
150            placeholder: placeholder.into(),
151            ..Default::default()
152        },
153    );
154}
155
156/// Field-oriented name for [`SearchBar`].
157#[composable]
158pub fn SearchField(modifier: Modifier, state: TextFieldState, placeholder: impl Into<String>) {
159    SearchBar(modifier, state, placeholder);
160}