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