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