Skip to main content

repose_material/material3/
search_bar.rs

1#![allow(non_snake_case)]
2
3use std::cell::{Cell, RefCell};
4use std::rc::Rc;
5
6use repose_core::NestedScrollConnection;
7use repose_core::animation::AnimationSpec;
8use repose_core::text::ImeAction;
9use repose_core::*;
10use repose_ui::{
11    BasicTextField, Box, Column, Row, Spacer, Text, TextFieldState, TextStyle,
12    ViewExt, ZStack,
13    anim::animate_f32,
14    overlay::OverlayHandle,
15};
16
17use super::*;
18use super::app_bar::WindowInsets;
19use super::util::apply_tonal_elevation;
20
21use super::util::lerp_color;
22/// Color slots for [`SearchBar`]. Matches Compose Material3 `SearchBarColors`.
23#[derive(Clone, Copy, Debug)]
24pub struct SearchBarColors {
25    pub container_color: Color,
26    pub active_container_color: Color,
27    pub divider_color: Color,
28    pub content_color: Color,
29    pub placeholder_color: Color,
30    pub scrim_color: Color,
31}
32
33impl SearchBarColors {
34    pub fn container(&self, active: bool) -> Color {
35        if active {
36            self.active_container_color
37        } else {
38            self.container_color
39        }
40    }
41}
42
43impl Default for SearchBarColors {
44    fn default() -> Self {
45        Self {
46            container_color: SearchBarDefaults::container_color(),
47            active_container_color: SearchBarDefaults::active_container_color(),
48            divider_color: SearchBarDefaults::divider_color(),
49            content_color: SearchBarDefaults::content_color(),
50            placeholder_color: SearchBarDefaults::placeholder_color(),
51            scrim_color: SearchBarDefaults::scrim_color(),
52        }
53    }
54}
55
56/// Color slots for [`AppBarWithSearch`]. Scrolled/not-scrolled pairs.
57#[derive(Clone, Copy, Debug)]
58pub struct AppBarWithSearchColors {
59    pub search_bar_colors: SearchBarColors,
60    pub scrolled_search_bar_container_color: Color,
61    pub app_bar_container_color: Color,
62    pub scrolled_app_bar_container_color: Color,
63    pub navigation_icon_content_color: Color,
64    pub action_icon_content_color: Color,
65}
66
67impl AppBarWithSearchColors {
68    pub fn search_bar_container(&self, scroll_fraction: f32) -> Color {
69        lerp_color(
70            self.search_bar_colors.container_color,
71            self.scrolled_search_bar_container_color,
72            scroll_fraction.clamp(0.0, 1.0),
73        )
74    }
75    pub fn app_bar_container(&self, scroll_fraction: f32) -> Color {
76        lerp_color(
77            self.app_bar_container_color,
78            self.scrolled_app_bar_container_color,
79            scroll_fraction.clamp(0.0, 1.0),
80        )
81    }
82}
83
84impl Default for AppBarWithSearchColors {
85    fn default() -> Self {
86        Self {
87            search_bar_colors: SearchBarColors::default(),
88            scrolled_search_bar_container_color: SearchBarDefaults::scrolled_container_color(),
89            app_bar_container_color: SearchBarDefaults::app_bar_container_color(),
90            scrolled_app_bar_container_color: SearchBarDefaults::scrolled_app_bar_container_color(),
91            navigation_icon_content_color: SearchBarDefaults::navigation_icon_content_color(),
92            action_icon_content_color: SearchBarDefaults::action_icon_content_color(),
93        }
94    }
95}
96
97/// Configuration for [`SearchBar`].
98#[derive(Clone, Debug)]
99pub struct SearchBarConfig {
100    pub modifier: Modifier,
101    pub colors: SearchBarColors,
102    pub height: f32,
103    pub shape_radius: f32,
104    pub active_shape_radius: f32,
105    pub expanded_width: f32,
106    pub collapsed_width: f32,
107    pub tonal_elevation: f32,
108    pub shadow_elevation: f32,
109    pub window_insets: WindowInsets,
110    pub content_padding: PaddingValues,
111    pub min_width: f32,
112    pub max_width: f32,
113}
114
115impl Default for SearchBarConfig {
116    fn default() -> Self {
117        Self {
118            modifier: Modifier::new(),
119            colors: SearchBarColors::default(),
120            height: SearchBarDefaults::HEIGHT,
121            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
122            active_shape_radius: SearchBarDefaults::ACTIVE_SHAPE_RADIUS,
123            expanded_width: SearchBarDefaults::EXPANDED_WIDTH,
124            collapsed_width: SearchBarDefaults::COLLAPSED_WIDTH,
125            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
126            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
127            window_insets: WindowInsets::default(),
128            content_padding: SearchBarDefaults::CONTENT_PADDING,
129            min_width: SearchBarDefaults::MIN_WIDTH,
130            max_width: SearchBarDefaults::MAX_WIDTH,
131        }
132    }
133}
134
135/// Configuration for [`ExpandedFullScreenSearchBar`].
136#[derive(Clone, Debug)]
137pub struct ExpandedFullScreenSearchBarConfig {
138    pub modifier: Modifier,
139    pub colors: SearchBarColors,
140    pub collapsed_shape_radius: f32,
141    pub tonal_elevation: f32,
142    pub shadow_elevation: f32,
143    pub window_insets: WindowInsets,
144    pub scrim_color: Color,
145}
146
147impl Default for ExpandedFullScreenSearchBarConfig {
148    fn default() -> Self {
149        Self {
150            modifier: Modifier::new(),
151            colors: SearchBarColors::default(),
152            collapsed_shape_radius: SearchBarDefaults::SHAPE_RADIUS,
153            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
154            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
155            window_insets: WindowInsets::default(),
156            scrim_color: SearchBarDefaults::scrim_color(),
157        }
158    }
159}
160
161/// Configuration for [`ExpandedDockedSearchBar`].
162#[derive(Clone, Debug)]
163pub struct ExpandedDockedSearchBarConfig {
164    pub modifier: Modifier,
165    pub colors: SearchBarColors,
166    pub shape_radius: f32,
167    pub dropdown_shape_radius: f32,
168    pub dropdown_gap_size: f32,
169    pub dropdown_scrim_color: Color,
170    pub tonal_elevation: f32,
171    pub shadow_elevation: f32,
172}
173
174impl Default for ExpandedDockedSearchBarConfig {
175    fn default() -> Self {
176        Self {
177            modifier: Modifier::new(),
178            colors: SearchBarColors::default(),
179            shape_radius: SearchBarDefaults::DOCKED_SHAPE_RADIUS,
180            dropdown_shape_radius: SearchBarDefaults::DROPDOWN_SHAPE_RADIUS,
181            dropdown_gap_size: SearchBarDefaults::DROPDOWN_GAP_SIZE,
182            dropdown_scrim_color: SearchBarDefaults::dropdown_scrim_color(),
183            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
184            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
185        }
186    }
187}
188
189/// Configuration for [`AppBarWithSearch`].
190#[derive(Clone, Debug)]
191pub struct AppBarWithSearchConfig {
192    pub modifier: Modifier,
193    pub colors: AppBarWithSearchColors,
194    pub height: f32,
195    pub shape_radius: f32,
196    pub tonal_elevation: f32,
197    pub shadow_elevation: f32,
198    pub content_padding: PaddingValues,
199    pub window_insets: WindowInsets,
200    pub scroll_fraction: f32,
201    pub scroll_offset: f32,
202}
203
204impl Default for AppBarWithSearchConfig {
205    fn default() -> Self {
206        Self {
207            modifier: Modifier::new(),
208            colors: AppBarWithSearchColors::default(),
209            height: SearchBarDefaults::HEIGHT,
210            shape_radius: SearchBarDefaults::SHAPE_RADIUS,
211            tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
212            shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
213            content_padding: SearchBarDefaults::CONTENT_PADDING,
214            window_insets: WindowInsets::default(),
215            scroll_fraction: 0.0,
216            scroll_offset: 0.0,
217        }
218    }
219}
220
221/// Scroll behavior for [`AppBarWithSearch`] -> collapses/expands on scroll.
222pub struct SearchBarScrollBehavior {
223    pub collapsed_offset: Signal<f32>,
224    pub height: f32,
225    pub collapsed_height: f32,
226    _pending: Rc<Cell<f32>>,
227}
228
229impl SearchBarScrollBehavior {
230    pub fn new(height: f32, collapsed_height: f32) -> Self {
231        Self {
232            collapsed_offset: signal(0.0),
233            height,
234            collapsed_height,
235            _pending: Rc::new(Cell::new(0.0)),
236        }
237    }
238
239    pub fn offset(&self) -> f32 {
240        self.collapsed_offset.get()
241    }
242
243    pub fn nested_scroll_connection(&self) -> NestedScrollConnection {
244        let offset = self.collapsed_offset.clone();
245        let max_offset = self.height - self.collapsed_height;
246        NestedScrollConnection::new().on_pre_scroll(move |delta: Vec2, _source| {
247            let cur = offset.get();
248            let new = (cur - delta.y).clamp(-max_offset, 0.0);
249            let consumed = cur - new;
250            offset.set(new);
251            request_frame();
252            Vec2 {
253                x: 0.0,
254                y: consumed,
255            }
256        })
257    }
258}
259
260/// Possible values of [`SearchBarState`].
261#[derive(Clone, Copy, Debug, PartialEq)]
262pub enum SearchBarValue {
263    Collapsed,
264    Expanded,
265}
266
267/// State for `SearchBar` -> manages expanded/collapsed progress, query text,
268/// active state, and collapsed layout coordinates for popup anchoring.
269pub struct SearchBarState {
270    pub query: Signal<String>,
271    pub expanded: Signal<bool>,
272    pub active: Signal<bool>,
273    /// Whether this search bar expands to full-screen (vs docked).
274    /// Used by AppBarWithSearch to hide the collapsed bar when expanded.
275    pub expands_to_full_screen: Signal<bool>,
276    /// Container animation (shape, size, position)
277    anim: Rc<RefCell<AnimatedValue<f32>>>,
278    /// Content fade animation -> fades FIRST on collapse before container shrinks
279    content_anim: Rc<RefCell<AnimatedValue<f32>>>,
280    /// Tracked via `on_globally_positioned` on the collapsed bar.
281    /// Used by expanded docked variants for popup placement.
282    pub collapsed_layout_rect: Signal<(f32, f32, f32, f32)>,
283}
284
285impl Default for SearchBarState {
286    fn default() -> Self {
287        Self::new()
288    }
289}
290
291impl SearchBarState {
292    pub fn new() -> Self {
293        Self {
294            query: signal(String::new()),
295            expanded: signal(false),
296            active: signal(false),
297            expands_to_full_screen: signal(false),
298            anim: Rc::new(RefCell::new(AnimatedValue::new(
299                0.0,
300                AnimationSpec::spring_gentle(),
301            ))),
302            content_anim: Rc::new(RefCell::new(AnimatedValue::new(
303                0.0,
304                AnimationSpec::spring_gentle(),
305            ))),
306            collapsed_layout_rect: signal((0.0, 0.0, 0.0, 0.0)),
307        }
308    }
309
310    pub fn query(&self) -> String {
311        self.query.get()
312    }
313
314    pub fn set_query(&self, q: impl Into<String>) {
315        self.query.set(q.into());
316    }
317
318    pub fn is_expanded(&self) -> bool {
319        self.expanded.get()
320    }
321
322    pub fn expand(&self) {
323        self.expanded.set(true);
324        self.anim.borrow_mut().set_target(1.0);
325        self.content_anim.borrow_mut().set_target(1.0);
326        request_frame();
327    }
328
329    pub fn collapse(&self) {
330        self.expanded.set(false);
331        self.active.set(false);
332        // Content fades first; container follows in progress()
333        self.content_anim.borrow_mut().set_target(0.0);
334        self.anim.borrow_mut().set_target(0.0);
335        request_frame();
336    }
337
338    pub fn is_active(&self) -> bool {
339        self.active.get()
340    }
341
342    pub fn activate(&self) {
343        self.active.set(true);
344        self.expanded.set(true);
345        self.anim.borrow_mut().set_target(1.0);
346        self.content_anim.borrow_mut().set_target(1.0);
347        request_frame();
348    }
349
350    pub fn deactivate(&self) {
351        if self.expanded.get() {
352            self.expanded.set(false);
353            self.content_anim.borrow_mut().set_target(0.0);
354            self.anim.borrow_mut().set_target(0.0);
355        }
356        self.active.set(false);
357        FocusManager::new(vec![], None).clear_focus(false);
358        request_frame();
359    }
360
361    /// Container animation progress: 0.0 = collapsed, 1.0 = expanded.
362    /// Ticks the underlying AnimatedValue and requests frames while animating.
363    pub fn progress(&self) -> f32 {
364        let mut a = self.anim.borrow_mut();
365        let still = a.update();
366        if still {
367            request_frame();
368        }
369        a.get().clamp(0.0, 1.0)
370    }
371
372    /// Content fade progress -> fades ahead of container on collapse.
373    pub fn content_progress(&self) -> f32 {
374        let mut a = self.content_anim.borrow_mut();
375        let still = a.update();
376        if still {
377            request_frame();
378        }
379        a.get().clamp(0.0, 1.0)
380    }
381
382    /// Whether the animation is currently running.
383    pub fn is_animating(&self) -> bool {
384        self.anim.borrow().is_animating() || self.content_anim.borrow().is_animating()
385    }
386
387    /// Whether the search bar is currently expanded (with tolerance for spring overshoot).
388    pub fn current_value(&self) -> SearchBarValue {
389        if *self.anim.borrow().get() <= 0.02 {
390            SearchBarValue::Collapsed
391        } else {
392            SearchBarValue::Expanded
393        }
394    }
395
396    /// Snap the container progress to a specific fraction (0.0 = collapsed, 1.0 = expanded).
397    pub fn snap_to(&self, fraction: f32) {
398        self.anim.borrow_mut().snap_to(fraction.clamp(0.0, 1.0));
399        request_frame();
400    }
401}
402
403#[derive(Clone)]
404pub struct SearchBarInputFieldConfig {
405    pub state: Option<Rc<SearchBarState>>,
406    pub on_search: Option<Rc<dyn Fn(String)>>,
407    pub enabled: bool,
408    pub text_color: Color,
409    pub placeholder_color: Color,
410    pub leading_icon: Option<View>,
411    pub trailing_icon: Option<View>,
412    pub interaction_source: Option<MutableInteractionSource>,
413}
414
415impl Default for SearchBarInputFieldConfig {
416    fn default() -> Self {
417        let th = theme();
418        Self {
419            state: None,
420            on_search: None,
421            enabled: true,
422            text_color: th.on_surface,
423            placeholder_color: th.on_surface_variant,
424            leading_icon: None,
425            trailing_icon: None,
426            interaction_source: None,
427        }
428    }
429}
430
431/// Build a search bar input field with proper M3 SearchBar styling.
432/// Equivalent to Compose Material3's `SearchBarDefaults.InputField`.
433/// When `state` is provided, focus gain triggers expand and Escape triggers collapse.
434/// Always renders a `UiTextField` (focusable even in collapsed state, matching CK).
435pub fn SearchBarInputField(
436    placeholder: String,
437    query: String,
438    on_query_change: Rc<dyn Fn(String)>,
439    expanded: bool,
440    config: SearchBarInputFieldConfig,
441) -> View {
442    let source: Rc<MutableInteractionSource> = config
443        .interaction_source
444        .clone()
445        .map(Rc::new)
446        .unwrap_or_else(|| Rc::new(MutableInteractionSource::new()));
447    let focused = source.source().collect_is_focused();
448    let state = config.state;
449    let enabled = config.enabled;
450
451    let mut input_m = Modifier::new()
452        .flex_grow(1.0)
453        .padding(4.0)
454        .required_width_in(SearchBarDefaults::MIN_WIDTH, SearchBarDefaults::MAX_WIDTH)
455        .required_height_in(SearchBarDefaults::HEIGHT, SearchBarDefaults::HEIGHT)
456        .interaction_source(&*source)
457        .semantics(Semantics {
458            role: Role::TextField,
459            label: Some("Search".into()),
460            focused: expanded || focused,
461            enabled,
462            selectable_group: false,
463        })
464        .on_key_event({
465            let s = state.clone();
466            move |ev| {
467                if ev.key == Key::Escape {
468                    if let Some(ref s) = s {
469                        if s.is_active() {
470                            s.deactivate();
471                        }
472                    }
473                    true
474                } else if ev.key == Key::ArrowDown || ev.key == Key::ArrowUp {
475                    if let Some(ref s) = s {
476                        if !s.is_expanded() {
477                            s.activate();
478                        }
479                    }
480                    true
481                } else {
482                    false
483                }
484            }
485        });
486    if let Some(ref s) = state {
487        let s2 = s.clone();
488        input_m = input_m.on_focus_changed(move |focused| {
489            if focused {
490                s2.activate();
491            }
492        });
493    }
494
495    let on_qc = on_query_change.clone();
496    let on_s = config.on_search.clone();
497
498    // Always render the text field (focusable even when collapsed, matching CK).
499    let read_only = !expanded;
500
501    let display_color = if query.is_empty() {
502        config.placeholder_color
503    } else {
504        config.text_color
505    };
506
507    let tf_state = remember_with_key("SearchBarInputField_tf_state", || {
508        RefCell::new(TextFieldState::new())
509    });
510    if tf_state.borrow().text != query {
511        tf_state.borrow_mut().text = query.clone();
512    }
513
514    // Build the row: [leading_icon] + text_field + [trailing_icon]
515    let mut row_children: Vec<View> = Vec::new();
516    if let Some(icon) = config.leading_icon {
517        row_children.push(icon);
518    }
519    let on_qc2 = on_qc.clone();
520    row_children.push(
521        BasicTextField(
522            tf_state.clone(),
523            input_m,
524            placeholder,
525            repose_ui::TextFieldConfig {
526                on_change: Some(Rc::new(move |text| on_qc2(text))),
527                on_submit: on_s.clone(),
528                enabled,
529                read_only,
530                line_limits: TextFieldLineLimits::SingleLine,
531                keyboard_options: KeyboardOptions {
532                    ime_action: ImeAction::Search,
533                    ..KeyboardOptions::DEFAULT
534                },
535                ..Default::default()
536            },
537        )
538        .color(display_color)
539        .size(repose_core::locals::theme().typography.body_large),
540    );
541    if let Some(icon) = config.trailing_icon {
542        row_children.push(icon);
543    }
544
545    if row_children.len() == 1 {
546        row_children.into_iter().next().unwrap()
547    } else {
548        Row(Modifier::new()
549            .fill_max_width()
550            .align_items(AlignItems::CENTER))
551        .child(row_children)
552    }
553}
554
555
556/// Record the collapsed bar's layout rect on the state. Returns a modifier
557/// that should be applied to the collapsed bar.
558fn track_collapsed_layout(state: &Rc<SearchBarState>) -> Modifier {
559    let s = state.clone();
560    Modifier::new().on_globally_positioned(move |rect| {
561        s.collapsed_layout_rect
562            .set((rect.x, rect.y, rect.w, rect.h));
563    })
564}
565
566
567/// M3 Collapsed Search Bar -> renders ONLY the collapsed bar surface wrapping
568/// the provided `input_field`. Does NOT manage expanded content.
569///
570/// Equivalent to CK's `SearchBar(state, inputField)` overload -> a passive
571/// Surface that does NOT handle clicks or ripple. The click/focus->expand
572/// behavior is managed by the `InputField` (via `SearchBarInputField`).
573///
574/// Pressing <kbd>Escape</kbd> deactivates the search bar (cross-platform back).
575///
576/// Use [`ExpandedFullScreenSearchBar`] / [`ExpandedDockedSearchBar`] for the
577/// expanded state, or [`SearchBarWithContent`] for an all-in-one variant.
578pub fn SearchBar(
579    state: Rc<SearchBarState>,
580    input_field: View,
581    modifier: Modifier,
582    leading_icon: Option<View>,
583    trailing_icon: Option<View>,
584    config: SearchBarConfig,
585) -> View {
586    let th = theme();
587    let colors = config.colors;
588
589    let mut bar_m = modifier
590        .fill_max_width()
591        .height(config.height)
592        .state_elevation(StateElevation {
593            default: config.tonal_elevation,
594            hovered: th.elevation.level2,
595            pressed: th.elevation.level3,
596            dragged: th.elevation.level3,
597            disabled: 0.0,
598        })
599        .shadow(config.shadow_elevation, 0.0)
600        .padding_values(config.content_padding)
601        .on_key_event({
602            let s = state.clone();
603            move |ev| {
604                if ev.key == Key::Escape && s.is_active() {
605                    s.deactivate();
606                    true
607                } else {
608                    false
609                }
610            }
611        })
612        .on_focus_changed({
613            let s = state.clone();
614            move |focused| {
615                if focused {
616                    s.activate();
617                }
618            }
619        })
620        .semantics(Semantics {
621            role: Role::TextField,
622            label: Some("Search".into()),
623            focused: state.is_active(),
624            enabled: true,
625            selectable_group: false,
626        })
627        .background(colors.container_color)
628        .clip_rounded(config.shape_radius)
629        .then(track_collapsed_layout(&state));
630
631    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
632
633    Box(bar_m).child(
634        Row(Modifier::new()
635            .fill_max_size()
636            .align_items(AlignItems::CENTER))
637        .child((
638            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
639            Box(Modifier::new().width(8.0).fill_max_height()),
640            input_field,
641            trailing_icon.unwrap_or(Box(Modifier::new())),
642        )),
643    )
644}
645
646
647/// M3 Search Bar that manages expanded content with animated width and
648/// suggestions dropdown. Equivalent to CK's
649/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
650///
651/// The bar itself is a passive surface (no click handling) -> expansion is
652/// driven by the `InputField`'s focus tracking inside `input_field`.
653pub fn SearchBarWithContent(
654    input_field: View,
655    expanded: bool,
656    on_expanded_change: Rc<dyn Fn(bool)>,
657    modifier: Modifier,
658    leading_icon: Option<View>,
659    trailing_icon: Option<View>,
660    config: SearchBarConfig,
661    content: View,
662) -> View {
663    let th = theme();
664    let width = animate_f32(
665        "sbwc_w",
666        if expanded {
667            config.expanded_width
668        } else {
669            config.collapsed_width
670        },
671        theme().motion.expand,
672    );
673
674    let bar_bg = if expanded {
675        config.colors.active_container_color
676    } else {
677        config.colors.container_color
678    };
679    let shape = if expanded {
680        config.active_shape_radius
681    } else {
682        config.shape_radius
683    };
684
685    let mut bar_m = modifier
686        .clone()
687        .width(width)
688        .min_width(config.min_width)
689        .max_width(config.max_width)
690        .height(config.height)
691        .shadow(config.shadow_elevation, 0.0)
692        .padding_values(config.content_padding)
693        .on_key_event({
694            let cb = on_expanded_change.clone();
695            move |ev| {
696                if ev.key == Key::Escape {
697                    cb(false);
698                    true
699                } else {
700                    false
701                }
702            }
703        })
704        .background(bar_bg)
705        .clip_rounded(shape);
706
707    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
708
709    // Content fades with separate alpha so content can fade before collapse
710    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
711
712    let bar = Box(bar_m).child(
713        Row(Modifier::new()
714            .fill_max_size()
715            .align_items(AlignItems::CENTER))
716        .child((
717            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
718            Box(Modifier::new().width(8.0).fill_max_height()),
719            input_field,
720            trailing_icon.unwrap_or(Box(Modifier::new())),
721        )),
722    );
723
724    let show_content = expanded || content_alpha > 0.01;
725    if show_content || expanded {
726        Column(modifier).child((
727            bar,
728            Box(Modifier::new()
729                .width(width)
730                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
731                .alpha(content_alpha)
732                .background(config.colors.container_color)
733                .clip_rounded(th.shapes.extra_small))
734            .child(content),
735        ))
736    } else {
737        bar
738    }
739}
740
741/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
742/// dropdown (height + alpha).  Equivalent to CK's
743/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
744/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
745pub fn DockedSearchBar(
746    input_field: View,
747    expanded: bool,
748    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
749    modifier: Modifier,
750    leading_icon: Option<View>,
751    config: SearchBarConfig,
752    content: View,
753) -> View {
754    let th = theme();
755    let active = expanded;
756    let colors = config.colors;
757
758    let content_target = if expanded {
759        get_window_container_height() * 2.0 / 3.0
760    } else {
761        0.0
762    };
763    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
764    let content_alpha = animate_f32(
765        "docked_sa",
766        if expanded { 1.0 } else { 0.0 },
767        theme().motion.color,
768    );
769    let bar_bg = if active {
770        colors.active_container_color
771    } else {
772        colors.container_color
773    };
774
775    let clear_btn = if active {
776        Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
777            let cb = on_expanded_change.clone();
778            move || {
779                if let Some(ref cb) = cb {
780                    cb(false);
781                }
782            }
783        }))
784        .child(Text("✕").size(16.0).color(colors.placeholder_color))
785    } else {
786        Box(Modifier::new())
787    };
788
789    let mut bar_m = modifier
790        .z_index(1.0)
791        .min_width(SearchBarDefaults::MIN_WIDTH)
792        .height(config.height)
793        .state_elevation(StateElevation {
794            default: if active {
795                th.elevation.level3
796            } else {
797                config.tonal_elevation
798            },
799            hovered: th.elevation.level2,
800            pressed: th.elevation.level3,
801            dragged: th.elevation.level3,
802            disabled: 0.0,
803        })
804        .shadow(config.shadow_elevation, 0.0)
805        .padding_values(config.content_padding)
806        .on_key_event({
807            let cb = on_expanded_change.clone();
808            move |ev| {
809                if ev.key == Key::Escape {
810                    if let Some(ref cb) = cb {
811                        cb(false);
812                    }
813                    true
814                } else {
815                    false
816                }
817            }
818        })
819        .background(bar_bg)
820        .clip_rounded(config.shape_radius);
821
822    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
823
824    let bar = Box(bar_m).child(
825        Row(Modifier::new()
826            .fill_max_size()
827            .align_items(AlignItems::CENTER))
828        .child((
829            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
830            Box(Modifier::new().width(12.0).fill_max_height()),
831            input_field,
832            clear_btn,
833        )),
834    );
835
836    let show_content = expanded || content_height > 1.0;
837    if show_content {
838        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
839            bar,
840            Box(Modifier::new()
841                .min_width(SearchBarDefaults::MIN_WIDTH)
842                .height(content_height)
843                .alpha(content_alpha)
844                .clip_rounded(th.shapes.small)
845                .background(colors.container_color)
846                .state_elevation(StateElevation {
847                    default: th.elevation.level3,
848                    hovered: th.elevation.level3,
849                    pressed: th.elevation.level3,
850                    dragged: th.elevation.level3,
851                    disabled: 0.0,
852                }))
853            .child(
854                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
855                    Box(Modifier::new()
856                        .min_width(SearchBarDefaults::MIN_WIDTH)
857                        .height(1.0)
858                        .background(colors.divider_color)),
859                    content,
860                )),
861            ),
862        ))
863    } else {
864        bar
865    }
866}
867
868/// Platform-agnostic window container height. On Skiko this would read
869/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
870/// The `LayoutEngine` keeps this current from the physical viewport + density.
871pub fn set_window_container_height(h: f32) {
872    repose_core::locals::set_window_container_height(h);
873}
874
875fn get_window_container_height() -> f32 {
876    repose_core::locals::get_window_container_height()
877}
878
879/// Set the window container width (in dp) used for dropdown constraints.
880pub fn set_window_container_width(w: f32) {
881    repose_core::locals::set_window_container_width(w);
882}
883
884fn get_window_container_width() -> f32 {
885    repose_core::locals::get_window_container_width()
886}
887
888/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
889/// entire window. Uses the state's own `progress()` for animation.
890/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
891pub fn ExpandedFullScreenSearchBar(
892    state: Rc<SearchBarState>,
893    overlay: OverlayHandle,
894    input_field: View,
895    modifier: Modifier,
896    config: ExpandedFullScreenSearchBarConfig,
897    content: View,
898) -> View {
899    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
900    state.expands_to_full_screen.set(true);
901
902    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
903    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
904    *current_content.borrow_mut() = content;
905
906    let progress = state.progress();
907    let _content_alpha = state.content_progress();
908
909    let expanded = state.is_expanded();
910    let visible = expanded || progress > 0.01;
911
912    if visible {
913        if overlay_id.get() == 0 {
914            let input_fr = FocusRequester::new();
915            let focus_requested = Rc::new(Cell::new(false));
916            let builder: Rc<dyn Fn() -> View> = Rc::new({
917                let state = state.clone();
918                let modifier = modifier.clone();
919                let input_field = input_field.clone();
920                let current_content = current_content.clone();
921                let config = config.clone();
922                let input_fr = input_fr.clone();
923                let focus_requested = focus_requested.clone();
924                move || {
925                    let progress = state.progress();
926                    let content_alpha = state.content_progress();
927                    let alpha = progress.clamp(0.0, 1.0);
928                    let c_alpha = content_alpha.clamp(0.0, 1.0);
929                    let th = theme();
930                    let content = current_content.borrow().clone();
931
932                    // Wrap input with focus requester and request focus.
933                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
934                        .child(input_field.clone());
935                    if !focus_requested.get() {
936                        focus_requested.set(true);
937                        input_fr.request_focus();
938                    }
939
940                    let header = Box(modifier
941                        .clone()
942                        .fill_max_width()
943                        .height(SearchBarDefaults::HEIGHT)
944                        .padding_values(PaddingValues {
945                            left: 16.0,
946                            right: 16.0,
947                            top: 0.0,
948                            bottom: 0.0,
949                        })
950                        .background(config.colors.container_color)
951                        .alpha(alpha))
952                    .child(inp);
953
954                    let body = Box(Modifier::new()
955                        .fill_max_width()
956                        .flex_grow(1.0)
957                        .alpha(c_alpha)
958                        .background(th.surface))
959                    .child(content);
960
961                    let insets = config.window_insets;
962                    let full = Column(Modifier::new().fill_max_size().padding_values(
963                        PaddingValues {
964                            left: insets.left,
965                            right: insets.right,
966                            top: insets.top,
967                            bottom: insets.bottom,
968                        },
969                    ))
970                    .child((header, body));
971
972                    let scrim = Box(Modifier::new()
973                        .fill_max_size()
974                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
975                        .on_click({
976                            let s = state.clone();
977                            move || s.collapse()
978                        }));
979
980                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
981                }
982            });
983
984            let id = overlay.show_entry(builder, 900.0, false);
985            overlay_id.set(id);
986        }
987    } else {
988        let prev = overlay_id.get();
989        if prev != 0 {
990            let _ = overlay.dismiss(prev);
991            overlay_id.set(0);
992        }
993    }
994
995    Box(Modifier::new())
996}
997
998/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
999/// the collapsed search bar using `collapsed_layout_rect`.
1000/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
1001pub fn ExpandedDockedSearchBar(
1002    state: Rc<SearchBarState>,
1003    overlay: OverlayHandle,
1004    input_field: View,
1005    modifier: Modifier,
1006    config: ExpandedDockedSearchBarConfig,
1007    content: View,
1008) -> View {
1009    // Docked search bar does NOT expand to full-screen
1010    state.expands_to_full_screen.set(false);
1011
1012    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
1013    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
1014    *current_content.borrow_mut() = content;
1015
1016    let progress = state.progress();
1017    let _content_alpha = state.content_progress();
1018    let expanded = state.is_expanded();
1019    let visible = expanded || progress > 0.01;
1020
1021    if visible {
1022        if overlay_id.get() == 0 {
1023            let input_fr = FocusRequester::new();
1024            let focus_requested = Rc::new(Cell::new(false));
1025            let builder: Rc<dyn Fn() -> View> = Rc::new({
1026                let state = state.clone();
1027                let modifier = modifier.clone();
1028                let input_field = input_field.clone();
1029                let current_content = current_content.clone();
1030                let config = config.clone();
1031                let input_fr = input_fr.clone();
1032                let focus_requested = focus_requested.clone();
1033                move || {
1034                    let progress = state.progress();
1035                    let content_alpha = state.content_progress();
1036                    let alpha = progress.clamp(0.0, 1.0);
1037                    let c_alpha = content_alpha.clamp(0.0, 1.0);
1038                    let th = theme();
1039                    let content = current_content.borrow().clone();
1040                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
1041
1042                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
1043                        .child(input_field.clone());
1044                    if !focus_requested.get() {
1045                        focus_requested.set(true);
1046                        input_fr.request_focus();
1047                    }
1048
1049                    let header = Box(modifier
1050                        .clone()
1051                        .fill_max_width()
1052                        .height(SearchBarDefaults::HEIGHT)
1053                        .alpha(alpha)
1054                        .background(config.colors.container_color)
1055                        .clip_rounded(config.shape_radius)
1056                        .state_elevation(StateElevation {
1057                            default: th.elevation.level3,
1058                            hovered: th.elevation.level2,
1059                            pressed: th.elevation.level3,
1060                            dragged: th.elevation.level3,
1061                            disabled: 0.0,
1062                        }))
1063                    .child(inp);
1064
1065                    let dropdown = Box(Modifier::new()
1066                        .fill_max_width()
1067                        .max_height(get_window_container_height() * 2.0 / 3.0)
1068                        .alpha(c_alpha)
1069                        .clip_rounded(config.dropdown_shape_radius)
1070                        .background(config.colors.container_color)
1071                        .state_elevation(StateElevation {
1072                            default: th.elevation.level3,
1073                            hovered: th.elevation.level3,
1074                            pressed: th.elevation.level3,
1075                            dragged: th.elevation.level3,
1076                            disabled: 0.0,
1077                        }))
1078                    .child(
1079                        Column(Modifier::new().fill_max_width()).child((
1080                            Box(Modifier::new()
1081                                .fill_max_width()
1082                                .height(1.0)
1083                                .background(config.colors.divider_color)),
1084                            content,
1085                        )),
1086                    );
1087
1088                    let docked_width = _cw.max(SearchBarDefaults::MIN_WIDTH);
1089                    let popup_left = _cx;
1090                    let popup_top = _cy + _ch + config.dropdown_gap_size;
1091
1092                    let col = Column(Modifier::new().fill_max_width()).child((header, dropdown));
1093
1094                    let positioned = Box(Modifier::new()
1095                        .absolute()
1096                        .offset(Some(popup_left), Some(popup_top), None, None)
1097                        .width(docked_width))
1098                    .child(col);
1099
1100                    let scrim = Box(Modifier::new()
1101                        .fill_max_size()
1102                        .background(config.dropdown_scrim_color)
1103                        .on_click({
1104                            let s = state.clone();
1105                            move || s.collapse()
1106                        }));
1107
1108                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, positioned))
1109                }
1110            });
1111
1112            let id = overlay.show_entry(builder, 900.0, false);
1113            overlay_id.set(id);
1114        }
1115    } else {
1116        let prev = overlay_id.get();
1117        if prev != 0 {
1118            let _ = overlay.dismiss(prev);
1119            overlay_id.set(0);
1120        }
1121    }
1122
1123    Box(Modifier::new())
1124}
1125
1126/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
1127/// with optional navigation icon, action buttons, scroll behavior, and window insets.
1128/// Wraps the internal `SearchBar` collapsed component.
1129pub fn AppBarWithSearch(
1130    state: Rc<SearchBarState>,
1131    input_field: View,
1132    navigation_icon: Option<View>,
1133    actions: Option<Vec<View>>,
1134    config: AppBarWithSearchConfig,
1135) -> View {
1136    let bg = config.colors.search_bar_container(config.scroll_fraction);
1137    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
1138
1139    let insets = config.window_insets;
1140
1141    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
1142    let is_container_transparent = app_bar_bg.3 == 0;
1143    let tonal_elevation = if is_container_transparent {
1144        0.0
1145    } else {
1146        config.tonal_elevation
1147    };
1148    let shadow_elevation = if is_container_transparent {
1149        0.0
1150    } else {
1151        config.shadow_elevation
1152    };
1153
1154    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
1155    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
1156    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
1157
1158    let bar_m = Modifier::new()
1159        .fill_max_width()
1160        .height(config.height + insets.top)
1161        .translate(0.0, config.scroll_offset)
1162        .background(app_bar_bg)
1163        .semantics(Semantics::new(Role::Container).with_selectable_group());
1164
1165    let row = Row(Modifier::new()
1166        .fill_max_size()
1167        .align_items(AlignItems::CENTER)
1168        .padding_values(PaddingValues {
1169            left: config.content_padding.left + insets.left,
1170            right: config.content_padding.right + insets.right,
1171            top: insets.top,
1172            bottom: 0.0,
1173        }))
1174    .child({
1175        let mut children: Vec<View> = Vec::new();
1176        if let Some(nav) = navigation_icon {
1177            children.push(nav);
1178            children.push(Box(Modifier::new().width(4.0)));
1179        }
1180        // Wrap input_field in collapsed SearchBar (CK parity)
1181        let sb_colors = &config.colors.search_bar_colors;
1182        let collapsed_bar = SearchBar(
1183            state.clone(),
1184            input_field,
1185            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
1186            None,
1187            None,
1188            SearchBarConfig {
1189                height: config.height - 8.0,
1190                shape_radius: config.shape_radius,
1191                colors: SearchBarColors {
1192                    container_color: bg,
1193                    active_container_color: bg,
1194                    divider_color: sb_colors.divider_color,
1195                    content_color: sb_colors.content_color,
1196                    placeholder_color: sb_colors.placeholder_color,
1197                    scrim_color: sb_colors.scrim_color,
1198                },
1199                tonal_elevation,
1200                shadow_elevation,
1201                ..Default::default()
1202            },
1203        );
1204        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
1205        if let Some(acts) = actions {
1206            children.push(Spacer());
1207            for a in acts {
1208                children.push(a);
1209            }
1210        }
1211        children
1212    });
1213
1214    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
1215}