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            disabled: 0.0,
597        })
598        .shadow(config.shadow_elevation, 0.0)
599        .padding_values(config.content_padding)
600        .on_key_event({
601            let s = state.clone();
602            move |ev| {
603                if ev.key == Key::Escape && s.is_active() {
604                    s.deactivate();
605                    true
606                } else {
607                    false
608                }
609            }
610        })
611        .on_focus_changed({
612            let s = state.clone();
613            move |focused| {
614                if focused {
615                    s.activate();
616                }
617            }
618        })
619        .semantics(Semantics {
620            role: Role::TextField,
621            label: Some("Search".into()),
622            focused: state.is_active(),
623            enabled: true,
624            selectable_group: false,
625        })
626        .background(colors.container_color)
627        .clip_rounded(config.shape_radius)
628        .then(track_collapsed_layout(&state));
629
630    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, colors.container_color);
631
632    Box(bar_m).child(
633        Row(Modifier::new()
634            .fill_max_size()
635            .align_items(AlignItems::CENTER))
636        .child((
637            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
638            Box(Modifier::new().width(8.0).fill_max_height()),
639            input_field,
640            trailing_icon.unwrap_or(Box(Modifier::new())),
641        )),
642    )
643}
644
645
646/// M3 Search Bar that manages expanded content with animated width and
647/// suggestions dropdown. Equivalent to CK's
648/// `SearchBar(inputField, expanded, onExpandedChange, ..., content)` overload.
649///
650/// The bar itself is a passive surface (no click handling) -> expansion is
651/// driven by the `InputField`'s focus tracking inside `input_field`.
652pub fn SearchBarWithContent(
653    input_field: View,
654    expanded: bool,
655    on_expanded_change: Rc<dyn Fn(bool)>,
656    modifier: Modifier,
657    leading_icon: Option<View>,
658    trailing_icon: Option<View>,
659    config: SearchBarConfig,
660    content: View,
661) -> View {
662    let th = theme();
663    let width = animate_f32(
664        "sbwc_w",
665        if expanded {
666            config.expanded_width
667        } else {
668            config.collapsed_width
669        },
670        theme().motion.expand,
671    );
672
673    let bar_bg = if expanded {
674        config.colors.active_container_color
675    } else {
676        config.colors.container_color
677    };
678    let shape = if expanded {
679        config.active_shape_radius
680    } else {
681        config.shape_radius
682    };
683
684    let mut bar_m = modifier
685        .clone()
686        .width(width)
687        .min_width(config.min_width)
688        .max_width(config.max_width)
689        .height(config.height)
690        .shadow(config.shadow_elevation, 0.0)
691        .padding_values(config.content_padding)
692        .on_key_event({
693            let cb = on_expanded_change.clone();
694            move |ev| {
695                if ev.key == Key::Escape {
696                    cb(false);
697                    true
698                } else {
699                    false
700                }
701            }
702        })
703        .background(bar_bg)
704        .clip_rounded(shape);
705
706    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
707
708    // Content fades with separate alpha so content can fade before collapse
709    let content_alpha = animate_f32("sbwc_a", if expanded { 1.0 } else { 0.0 }, th.motion.color);
710
711    let bar = Box(bar_m).child(
712        Row(Modifier::new()
713            .fill_max_size()
714            .align_items(AlignItems::CENTER))
715        .child((
716            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
717            Box(Modifier::new().width(8.0).fill_max_height()),
718            input_field,
719            trailing_icon.unwrap_or(Box(Modifier::new())),
720        )),
721    );
722
723    let show_content = expanded || content_alpha > 0.01;
724    if show_content || expanded {
725        Column(modifier).child((
726            bar,
727            Box(Modifier::new()
728                .width(width)
729                .max_height(SearchBarDefaults::DOCKED_HEIGHT)
730                .alpha(content_alpha)
731                .background(config.colors.container_color)
732                .clip_rounded(th.shapes.extra_small))
733            .child(content),
734        ))
735    } else {
736        bar
737    }
738}
739
740/// M3 Docked Search Bar -> bounded-width variant with animated suggestions
741/// dropdown (height + alpha).  Equivalent to CK's
742/// `DockedSearchBar(inputField, expanded, onExpandedChange, ..., content)`.
743/// The bar itself is a passive Surface -> expansion is driven by `InputField`.
744pub fn DockedSearchBar(
745    input_field: View,
746    expanded: bool,
747    on_expanded_change: Option<Rc<dyn Fn(bool)>>,
748    modifier: Modifier,
749    leading_icon: Option<View>,
750    config: SearchBarConfig,
751    content: View,
752) -> View {
753    let th = theme();
754    let active = expanded;
755    let colors = config.colors;
756
757    let content_target = if expanded {
758        get_window_container_height() * 2.0 / 3.0
759    } else {
760        0.0
761    };
762    let content_height = animate_f32("docked_sh", content_target, theme().motion.expand);
763    let content_alpha = animate_f32(
764        "docked_sa",
765        if expanded { 1.0 } else { 0.0 },
766        theme().motion.color,
767    );
768    let bar_bg = if active {
769        colors.active_container_color
770    } else {
771        colors.container_color
772    };
773
774    let clear_btn = if active {
775        Box(Modifier::new().size(24.0, 24.0).clickable().on_click({
776            let cb = on_expanded_change.clone();
777            move || {
778                if let Some(ref cb) = cb {
779                    cb(false);
780                }
781            }
782        }))
783        .child(Text("✕").size(16.0).color(colors.placeholder_color))
784    } else {
785        Box(Modifier::new())
786    };
787
788    let mut bar_m = modifier
789        .z_index(1.0)
790        .min_width(SearchBarDefaults::MIN_WIDTH)
791        .height(config.height)
792        .state_elevation(StateElevation {
793            default: if active {
794                th.elevation.level3
795            } else {
796                config.tonal_elevation
797            },
798            hovered: th.elevation.level2,
799            pressed: th.elevation.level3,
800            disabled: 0.0,
801        })
802        .shadow(config.shadow_elevation, 0.0)
803        .padding_values(config.content_padding)
804        .on_key_event({
805            let cb = on_expanded_change.clone();
806            move |ev| {
807                if ev.key == Key::Escape {
808                    if let Some(ref cb) = cb {
809                        cb(false);
810                    }
811                    true
812                } else {
813                    false
814                }
815            }
816        })
817        .background(bar_bg)
818        .clip_rounded(config.shape_radius);
819
820    bar_m = apply_tonal_elevation(bar_m, config.tonal_elevation, bar_bg);
821
822    let bar = Box(bar_m).child(
823        Row(Modifier::new()
824            .fill_max_size()
825            .align_items(AlignItems::CENTER))
826        .child((
827            leading_icon.unwrap_or(Box(Modifier::new().size(24.0, 24.0))),
828            Box(Modifier::new().width(12.0).fill_max_height()),
829            input_field,
830            clear_btn,
831        )),
832    );
833
834    let show_content = expanded || content_height > 1.0;
835    if show_content {
836        Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
837            bar,
838            Box(Modifier::new()
839                .min_width(SearchBarDefaults::MIN_WIDTH)
840                .height(content_height)
841                .alpha(content_alpha)
842                .clip_rounded(th.shapes.small)
843                .background(colors.container_color)
844                .state_elevation(StateElevation {
845                    default: th.elevation.level3,
846                    hovered: th.elevation.level3,
847                    pressed: th.elevation.level3,
848                    disabled: 0.0,
849                }))
850            .child(
851                Column(Modifier::new().min_width(SearchBarDefaults::MIN_WIDTH)).child((
852                    Box(Modifier::new()
853                        .min_width(SearchBarDefaults::MIN_WIDTH)
854                        .height(1.0)
855                        .background(colors.divider_color)),
856                    content,
857                )),
858            ),
859        ))
860    } else {
861        bar
862    }
863}
864
865/// Platform-agnostic window container height. On Skiko this would read
866/// `LocalWindowInfo`, on Android `LocalConfiguration`. Defaults to 800 dp.
867/// The `LayoutEngine` keeps this current from the physical viewport + density.
868pub fn set_window_container_height(h: f32) {
869    repose_core::locals::set_window_container_height(h);
870}
871
872fn get_window_container_height() -> f32 {
873    repose_core::locals::get_window_container_height()
874}
875
876/// Set the window container width (in dp) used for dropdown constraints.
877pub fn set_window_container_width(w: f32) {
878    repose_core::locals::set_window_container_width(w);
879}
880
881fn get_window_container_width() -> f32 {
882    repose_core::locals::get_window_container_width()
883}
884
885/// M3 Expanded Full‑Screen Search Bar -> rendered in an overlay covering the
886/// entire window. Uses the state's own `progress()` for animation.
887/// Equivalent to CK's `ExpandedFullScreenSearchBar(state, inputField, ...)`.
888pub fn ExpandedFullScreenSearchBar(
889    state: Rc<SearchBarState>,
890    overlay: OverlayHandle,
891    input_field: View,
892    modifier: Modifier,
893    config: ExpandedFullScreenSearchBarConfig,
894    content: View,
895) -> View {
896    // Mark as full-screen so AppBarWithSearch can hide the collapsed bar
897    state.expands_to_full_screen.set(true);
898
899    let overlay_id = remember_with_key("efs_oid", || signal(0u64));
900    let current_content = remember_state_with_key("efs_cc", || Box(Modifier::new()));
901    *current_content.borrow_mut() = content;
902
903    let progress = state.progress();
904    let _content_alpha = state.content_progress();
905
906    let expanded = state.is_expanded();
907    let visible = expanded || progress > 0.01;
908
909    if visible {
910        if overlay_id.get() == 0 {
911            let input_fr = FocusRequester::new();
912            let builder: Rc<dyn Fn() -> View> = Rc::new({
913                let state = state.clone();
914                let modifier = modifier.clone();
915                let input_field = input_field.clone();
916                let current_content = current_content.clone();
917                let config = config.clone();
918                let input_fr = input_fr.clone();
919                move || {
920                    let progress = state.progress();
921                    let content_alpha = state.content_progress();
922                    let alpha = progress.clamp(0.0, 1.0);
923                    let c_alpha = content_alpha.clamp(0.0, 1.0);
924                    let th = theme();
925                    let content = current_content.borrow().clone();
926
927                    // Wrap input with focus requester and request focus (CK parity: auto-focus on expand)
928                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
929                        .child(input_field.clone());
930                    input_fr.request_focus();
931
932                    let header = Box(modifier
933                        .clone()
934                        .fill_max_width()
935                        .height(SearchBarDefaults::HEIGHT)
936                        .padding_values(PaddingValues {
937                            left: 16.0,
938                            right: 16.0,
939                            top: 0.0,
940                            bottom: 0.0,
941                        })
942                        .background(config.colors.container_color)
943                        .alpha(alpha))
944                    .child(inp);
945
946                    let body = Box(Modifier::new()
947                        .fill_max_width()
948                        .flex_grow(1.0)
949                        .alpha(c_alpha)
950                        .background(th.surface))
951                    .child(content);
952
953                    let insets = config.window_insets;
954                    let full = Column(Modifier::new().fill_max_size().padding_values(
955                        PaddingValues {
956                            left: insets.left,
957                            right: insets.right,
958                            top: insets.top,
959                            bottom: insets.bottom,
960                        },
961                    ))
962                    .child((header, body));
963
964                    let scrim = Box(Modifier::new()
965                        .fill_max_size()
966                        .background(config.scrim_color.with_alpha((85.0 * alpha) as u8))
967                        .on_click({
968                            let s = state.clone();
969                            move || s.collapse()
970                        }));
971
972                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, full))
973                }
974            });
975
976            let id = overlay.show_entry(builder, 900.0, false);
977            overlay_id.set(id);
978        }
979    } else {
980        let prev = overlay_id.get();
981        if prev != 0 {
982            let _ = overlay.dismiss(prev);
983            overlay_id.set(0);
984        }
985    }
986
987    Box(Modifier::new())
988}
989
990/// M3 Expanded Docked Search Bar -> rendered as an overlay popup anchored below
991/// the collapsed search bar using `collapsed_layout_rect`.
992/// Equivalent to CK's `ExpandedDockedSearchBar(state, inputField, ...)`.
993pub fn ExpandedDockedSearchBar(
994    state: Rc<SearchBarState>,
995    overlay: OverlayHandle,
996    input_field: View,
997    modifier: Modifier,
998    config: ExpandedDockedSearchBarConfig,
999    content: View,
1000) -> View {
1001    // Docked search bar does NOT expand to full-screen
1002    state.expands_to_full_screen.set(false);
1003
1004    let overlay_id = remember_with_key("eds_oid", || signal(0u64));
1005    let current_content = remember_state_with_key("eds_cc", || Box(Modifier::new()));
1006    *current_content.borrow_mut() = content;
1007
1008    let progress = state.progress();
1009    let _content_alpha = state.content_progress();
1010    let expanded = state.is_expanded();
1011    let visible = expanded || progress > 0.01;
1012
1013    if visible {
1014        if overlay_id.get() == 0 {
1015            let input_fr = FocusRequester::new();
1016            let builder: Rc<dyn Fn() -> View> = Rc::new({
1017                let state = state.clone();
1018                let modifier = modifier.clone();
1019                let input_field = input_field.clone();
1020                let current_content = current_content.clone();
1021                let config = config.clone();
1022                let input_fr = input_fr.clone();
1023                move || {
1024                    let progress = state.progress();
1025                    let content_alpha = state.content_progress();
1026                    let alpha = progress.clamp(0.0, 1.0);
1027                    let c_alpha = content_alpha.clamp(0.0, 1.0);
1028                    let th = theme();
1029                    let content = current_content.borrow().clone();
1030                    let (_cx, _cy, _cw, _ch) = state.collapsed_layout_rect.get();
1031
1032                    let inp = Box(Modifier::new().focus_requester(input_fr.clone()))
1033                        .child(input_field.clone());
1034                    input_fr.request_focus();
1035
1036                    let header = Box(modifier
1037                        .clone()
1038                        .fill_max_width()
1039                        .height(SearchBarDefaults::HEIGHT)
1040                        .alpha(alpha)
1041                        .background(config.colors.container_color)
1042                        .clip_rounded(config.shape_radius)
1043                        .state_elevation(StateElevation {
1044                            default: th.elevation.level3,
1045                            hovered: th.elevation.level2,
1046                            pressed: th.elevation.level3,
1047                            disabled: 0.0,
1048                        }))
1049                    .child(inp);
1050
1051                    let dropdown = Box(Modifier::new()
1052                        .fill_max_width()
1053                        .max_height(get_window_container_height() * 2.0 / 3.0)
1054                        .alpha(c_alpha)
1055                        .clip_rounded(config.dropdown_shape_radius)
1056                        .background(config.colors.container_color)
1057                        .state_elevation(StateElevation {
1058                            default: th.elevation.level3,
1059                            hovered: th.elevation.level3,
1060                            pressed: th.elevation.level3,
1061                            disabled: 0.0,
1062                        }))
1063                    .child(
1064                        Column(Modifier::new().fill_max_width()).child((
1065                            Box(Modifier::new()
1066                                .fill_max_width()
1067                                .height(1.0)
1068                                .background(config.colors.divider_color)),
1069                            content,
1070                        )),
1071                    );
1072
1073                    let col = Column(Modifier::new().fill_max_width().padding_values(
1074                        PaddingValues {
1075                            left: _cx.max(16.0),
1076                            right: 16.0,
1077                            top: _cy + _ch + config.dropdown_gap_size,
1078                            bottom: 0.0,
1079                        },
1080                    ))
1081                    .child((header, dropdown));
1082
1083                    let scrim = Box(Modifier::new()
1084                        .fill_max_size()
1085                        .background(config.dropdown_scrim_color)
1086                        .on_click({
1087                            let s = state.clone();
1088                            move || s.collapse()
1089                        }));
1090
1091                    ZStack(Modifier::new().fill_max_size().absolute()).child((scrim, col))
1092                }
1093            });
1094
1095            let id = overlay.show_entry(builder, 900.0, false);
1096            overlay_id.set(id);
1097        }
1098    } else {
1099        let prev = overlay_id.get();
1100        if prev != 0 {
1101            let _ = overlay.dismiss(prev);
1102            overlay_id.set(0);
1103        }
1104    }
1105
1106    Box(Modifier::new())
1107}
1108
1109/// M3 App Bar With Search -> integrates a search bar into a top app bar layout
1110/// with optional navigation icon, action buttons, scroll behavior, and window insets.
1111/// Wraps the internal `SearchBar` collapsed component.
1112pub fn AppBarWithSearch(
1113    state: Rc<SearchBarState>,
1114    input_field: View,
1115    navigation_icon: Option<View>,
1116    actions: Option<Vec<View>>,
1117    config: AppBarWithSearchConfig,
1118) -> View {
1119    let bg = config.colors.search_bar_container(config.scroll_fraction);
1120    let app_bar_bg = config.colors.app_bar_container(config.scroll_fraction);
1121
1122    let insets = config.window_insets;
1123
1124    // CK parity: when app bar container is transparent, disable tonal/shadow elevations
1125    let is_container_transparent = app_bar_bg.3 == 0;
1126    let tonal_elevation = if is_container_transparent {
1127        0.0
1128    } else {
1129        config.tonal_elevation
1130    };
1131    let shadow_elevation = if is_container_transparent {
1132        0.0
1133    } else {
1134        config.shadow_elevation
1135    };
1136
1137    // Hide the collapsed bar when full-screen expanded (CK parity via expandsToFullScreen)
1138    let hide_collapsed = state.expands_to_full_screen.get() && state.is_expanded();
1139    let collapsed_alpha = if hide_collapsed { 0.0 } else { 1.0 };
1140
1141    let bar_m = Modifier::new()
1142        .fill_max_width()
1143        .height(config.height + insets.top)
1144        .translate(0.0, config.scroll_offset)
1145        .background(app_bar_bg)
1146        .semantics(Semantics::new(Role::Container).with_selectable_group());
1147
1148    let row = Row(Modifier::new()
1149        .fill_max_size()
1150        .align_items(AlignItems::CENTER)
1151        .padding_values(PaddingValues {
1152            left: config.content_padding.left + insets.left,
1153            right: config.content_padding.right + insets.right,
1154            top: insets.top,
1155            bottom: 0.0,
1156        }))
1157    .child({
1158        let mut children: Vec<View> = Vec::new();
1159        if let Some(nav) = navigation_icon {
1160            children.push(nav);
1161            children.push(Box(Modifier::new().width(4.0)));
1162        }
1163        // Wrap input_field in collapsed SearchBar (CK parity)
1164        let sb_colors = &config.colors.search_bar_colors;
1165        let collapsed_bar = SearchBar(
1166            state.clone(),
1167            input_field,
1168            Modifier::new().flex_grow(1.0).alpha(collapsed_alpha),
1169            None,
1170            None,
1171            SearchBarConfig {
1172                height: config.height - 8.0,
1173                shape_radius: config.shape_radius,
1174                colors: SearchBarColors {
1175                    container_color: bg,
1176                    active_container_color: bg,
1177                    divider_color: sb_colors.divider_color,
1178                    content_color: sb_colors.content_color,
1179                    placeholder_color: sb_colors.placeholder_color,
1180                    scrim_color: sb_colors.scrim_color,
1181                },
1182                tonal_elevation,
1183                shadow_elevation,
1184                ..Default::default()
1185            },
1186        );
1187        children.push(Box(Modifier::new().flex_grow(1.0)).child(collapsed_bar));
1188        if let Some(acts) = actions {
1189            children.push(Spacer());
1190            for a in acts {
1191                children.push(a);
1192            }
1193        }
1194        children
1195    });
1196
1197    Box(bar_m.shadow(shadow_elevation, 0.0)).child(row)
1198}