Skip to main content

teksilo_widgets/
command_palette.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! CommandPalette — type-to-run access to every command an app has registered.
5//!
6//! The palette is **application-agnostic**: it holds no list of its own and knows
7//! nothing about any particular app. Its content is the tree's
8//! [`ShortcutRegistry`](teksilo_core::shortcut::ShortcutRegistry), which already
9//! carries everything a palette row needs — a localized
10//! [`name`](teksilo_core::shortcut::Shortcut), an optional `category` to group by, an
11//! optional `description`, the effective keystroke (user rebinds merged in), and a
12//! live `enabled` verdict. Activating a row sends the command's intent, which is the
13//! same path a menu row or the chord itself takes.
14//!
15//! That has a consequence worth stating plainly, because it is the whole design:
16//! **a command does not need a keystroke to appear here.** `iter_effective()` yields
17//! every registered entry, bound or not, so an app makes a command searchable by
18//! registering it with a name and no chord:
19//!
20//! ```ignore
21//! // Reachable from the palette, and rebindable by the user later, without
22//! // occupying a keystroke today.
23//! ctx.register_shortcut_global(
24//!     Shortcut::new("document.export")
25//!         .name("Export…")
26//!         .category("File")
27//!         .build(),
28//! );
29//! ```
30//!
31//! # Running a command
32//!
33//! Activating a row (Enter, or a click) closes the palette, which hands focus
34//! back to whatever the user was working in, and then sends the command's
35//! intent from there, as its keystroke would have been sent
36//! ([`EventContext::send_intent_from_focus`]). So a command answers wherever
37//! its action is registered: on the editor the palette was opened over, on
38//! one of that editor's ancestors, or window-wide.
39//!
40//! That is also why a presented palette lists the commands that act on the
41//! focused editor: the standard edit commands every tree registers
42//! ([`StandardEdit`]: cut, copy, paste, undo, redo, select all) and the
43//! editors' own ([`FormatShortcut`](crate::editor_shortcuts::FormatShortcut),
44//! [`CodeShortcut`](crate::editor_shortcuts::CodeShortcut)). A palette embedded
45//! in a surface of the caller's keeps the focus in its own search field, where
46//! those commands would act on the query, so it leaves them out.
47//!
48//! # Presenting it
49//!
50//! [`CommandPalette::present`] shows it as a modal near the top of the window,
51//! dismissed by Escape or a click outside:
52//!
53//! ```ignore
54//! ctx.register_action_global(Action::new("app.command_palette").on_invoke(|_, ctx| {
55//!     CommandPalette::new().present(ctx);
56//! }));
57//! ```
58//!
59//! Presenting it as a **window-level** modal is deliberate, not incidental: a palette
60//! is routinely opened from a menu, and a menu is itself a transient overlay.
61//! Anchoring to the invoking widget would render the palette inside the menu that
62//! opened it, positioned against a surface that is about to disappear. It is
63//! anchored by its top edge ([`ModalPlacement::Top`]) rather than centred: the
64//! palette is as tall as its list, and a centred one would move its search field
65//! with every keystroke that changed the number of matches.
66//!
67//! # What a row shows
68//!
69//! One line: the category, muted (`File:`), then the command's name, with the
70//! letters the query matched in bold, then its description when it has one,
71//! muted and the first to give way to a long name, and the effective shortcut,
72//! user rebinds merged in, drawn as a key at the trailing edge. Rows grow with
73//! the global text scale.
74//!
75//! # Matching
76//!
77//! Typing filters by subsequence, not substring, so `ndw` finds "New Window" and
78//! `expdoc` finds "Export document". Matches score higher when the typed letters land
79//! consecutively and on word starts, so the most literal reading of a query sorts
80//! first. An empty query lists everything in the registry's own deterministic
81//! `(category, id)` order. The category takes part in matching, so `file new` finds
82//! the New command filed under File.
83//! A query can usually land in a command in more than one way (`exp` in "File:
84//! Export": the `e` of "File", or the `E` of "Export"); the best-scoring way is
85//! kept, and its letters are the ones the row shows in bold.
86//!
87//! The category a reader sees and matches against is the shortcut's
88//! `category_label` when it has one, and its `category` key otherwise. Give a
89//! localized label (`.category_label(tr!(…))`) and keep the key stable: the key
90//! orders the groups, so it must not change with the language, and the label is
91//! what a French reader types (`fichier nouveau`). Settings ▸ Keymap
92//! (`ShortcutSettings`) uses the same pair, and names the group of shortcuts that
93//! declared no category from the widget catalogue
94//! (`shortcut-settings-uncategorized`).
95//!
96//! # Keyboard
97//!
98//! Focus stays in the search field throughout: that is what makes a palette feel
99//! like one. Arrow keys are not editing keys for the field, so they bubble to the
100//! palette's own handler, which moves the highlight (wrapping at either end) and
101//! scrolls it into view; PageUp and PageDown move it a viewport at a time. Home and
102//! End stay with the field, which moves its caret with them. Enter runs the
103//! highlighted command; Escape dismisses.
104//!
105//! Typing re-filters the list and nothing else: the list is a sibling of the search
106//! field, not its parent, so the field keeps its caret, its selection and an input
107//! method's composition from one keystroke to the next.
108
109use std::cell::{Cell, RefCell};
110use std::rc::Rc;
111
112use teksilo_canvas::{Point, Rect, Size, SizeProposal};
113use teksilo_core::accessibility::AccessNodeBuilder;
114use teksilo_core::accesskit::{Live, Role};
115use teksilo_core::binding::BindingLevel;
116use teksilo_core::build_context::BuildContext;
117use teksilo_core::event::{EventResponse, Key, WidgetEvent};
118use teksilo_core::intent::Intent;
119use teksilo_core::modal::{ModalCloseBehavior, ModalPlacement, ModalPresentation, ModalRequest};
120use teksilo_core::shortcut::{KeyStroke, StandardEdit};
121use teksilo_core::signal::Signal;
122use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
123use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
124use teksilo_core::widget_id::WidgetId;
125use teksilo_i18n::{LocalizedString, tr_widget};
126
127use crate::dialog::ModalContainer;
128use crate::list_view::ListViewHandle;
129use crate::primitives::{Divider, Padding};
130use crate::search_field::SearchField;
131
132mod matcher;
133mod results;
134mod row;
135
136use results::{PaletteEntry, PaletteResults, ResultsConfig};
137
138/// The palette's width when nothing narrower is proposed. Room for a category,
139/// a command name and its chord without any of them having to ellipsize in
140/// the common case.
141const PALETTE_WIDTH: f32 = 600.0;
142/// How far below the top of the window a presented palette's top edge sits.
143const PALETTE_TOP_OFFSET: f32 = 56.0;
144/// Inset of the search field from the palette's edges.
145const FIELD_PADDING: f32 = 8.0;
146
147/// One command as the palette sees it.
148///
149/// A read-only projection of a registered shortcut, handed to
150/// [`CommandPalette::include`] so an app can decide what belongs in its palette
151/// without the widget growing knowledge of any app's command names. Deliberately
152/// *not* the `Shortcut` itself: that type carries the activation closure and the
153/// rebinding machinery, neither of which a filter predicate has any business
154/// reaching.
155#[derive(Debug, Clone)]
156pub struct PaletteCommand {
157    /// The stable registry id, e.g. `"work.export"`.
158    pub id: &'static str,
159    /// The localized display name, already resolved for the active locale.
160    pub name: String,
161    /// The grouping key, if the command declared one: stable across languages,
162    /// so an [`CommandPalette::include`] filter can match on it.
163    pub category: Option<&'static str>,
164    /// The category as the reader sees it, already resolved for the active
165    /// locale: the command's `category_label`, or the key when it gave none.
166    pub category_text: Option<String>,
167    /// The longer explanation, if the command declared one.
168    pub description: Option<String>,
169    /// The effective primary chord (user rebinds merged in), or `None` when the
170    /// command has no keystroke at all, which is normal for a palette-only command.
171    pub keystroke: Option<KeyStroke>,
172    /// Whether the command's own `enabled_when` predicate currently says yes.
173    pub enabled: bool,
174    /// The intent name activation sends. Falls back to [`Self::id`] when the command
175    /// declared no explicit intent, exactly as the keystroke dispatcher does.
176    pub intent: &'static str,
177}
178
179impl PaletteCommand {
180    /// The text a query is matched against: category and name together, so
181    /// `file new` finds a New command filed under File. The displayed category,
182    /// not the key, because the reader types what they read: `fichier nouveau`
183    /// in a French interface.
184    fn haystack(&self) -> String {
185        match &self.category_text {
186            Some(cat) => format!("{cat} {}", self.name),
187            None => self.name.clone(),
188        }
189    }
190}
191
192type IncludeFn = Rc<dyn Fn(&PaletteCommand) -> bool>;
193type DismissFn = Rc<dyn Fn(&mut EventContext)>;
194
195/// The parts of a palette its rows, its result list and its handlers share,
196/// separated from the widget so they can be cloned into `'static` closures.
197#[derive(Clone)]
198struct PaletteState {
199    query: Signal<String>,
200    /// The highlighted row: what Enter runs, what the row tint follows, what a
201    /// screen reader is told is selected. One signal, so the three cannot
202    /// drift apart.
203    highlight: Signal<usize>,
204    /// The rows on screen. The key handler acts on exactly what the reader is
205    /// looking at rather than re-deriving the list and risking a different
206    /// answer.
207    rows: Rc<RefCell<Vec<Rc<PaletteEntry>>>>,
208    /// How many rows match, for the palette's own accessible description.
209    count: Signal<usize>,
210    /// The query the rows were last built for. `None` before the first build.
211    last_query: Rc<RefCell<Option<String>>>,
212    /// The result list, to keep the highlight scrolled into view.
213    list: Rc<RefCell<Option<ListViewHandle>>>,
214    /// How many rows fit in the list's viewport: one PageUp / PageDown.
215    page_rows: Rc<Cell<usize>>,
216    /// Whether [`CommandPalette::present`] put this palette up. A presented
217    /// palette hands focus back before a command runs; an embedded one keeps
218    /// it.
219    presented: Rc<Cell<bool>>,
220    on_dismiss: Rc<RefCell<Option<DismissFn>>>,
221    /// Whether the dismissal hook has already run.
222    ///
223    /// It is reachable from two directions, the palette's own Escape /
224    /// activate path and the modal's `on_dismiss` for the routes the overlay
225    /// system handles before the palette ever sees the key, and a caller that
226    /// installed it to release a resource must not be called twice.
227    dismissed: Rc<Cell<bool>>,
228    /// The result list's node, published for the search field's `controls`
229    /// relation. `None` while nothing matches.
230    listbox_id: Signal<Option<WidgetId>>,
231    /// The highlighted row's node, published for the search field's
232    /// `active_descendant`. `None` when the list is empty, or while the
233    /// highlighted row is outside the realized virtualization window.
234    active_row: Signal<Option<WidgetId>>,
235}
236
237impl PaletteState {
238    fn new() -> Self {
239        Self {
240            query: Signal::new(String::new()),
241            highlight: Signal::new(0),
242            rows: Rc::new(RefCell::new(Vec::new())),
243            count: Signal::new(0),
244            last_query: Rc::new(RefCell::new(None)),
245            list: Rc::new(RefCell::new(None)),
246            page_rows: Rc::new(Cell::new(1)),
247            presented: Rc::new(Cell::new(false)),
248            on_dismiss: Rc::new(RefCell::new(None)),
249            dismissed: Rc::new(Cell::new(false)),
250            listbox_id: Signal::new(None),
251            active_row: Signal::new(None),
252        }
253    }
254
255    fn len(&self) -> usize {
256        self.rows.borrow().len()
257    }
258
259    /// Move the highlight to `index`. Writes nothing when it is already
260    /// there, so a rebuild that keeps the reader's place notifies no row.
261    fn set_highlight(&self, index: usize) {
262        if self.highlight.get() != index {
263            self.highlight.set(index);
264        }
265    }
266
267    /// Move the highlight to `index` and scroll it into view.
268    fn move_highlight(&self, index: usize) {
269        self.set_highlight(index);
270        if let Some(list) = self.list.borrow().as_ref() {
271            list.ensure_index_visible(index);
272        }
273    }
274
275    /// One row up or down, wrapping at either end, as a menu does.
276    fn step(&self, down: bool) {
277        let len = self.len();
278        if len == 0 {
279            return;
280        }
281        let current = self.highlight.get().min(len - 1);
282        let next = if down {
283            (current + 1) % len
284        } else {
285            (current + len - 1) % len
286        };
287        self.move_highlight(next);
288    }
289
290    /// One viewport up or down, stopping at either end.
291    fn page(&self, down: bool) {
292        let len = self.len();
293        if len == 0 {
294            return;
295        }
296        let page = self.page_rows.get().max(1);
297        let current = self.highlight.get().min(len - 1);
298        let next = if down {
299            (current + page).min(len - 1)
300        } else {
301            current.saturating_sub(page)
302        };
303        self.move_highlight(next);
304    }
305
306    /// Run the highlighted command and close the palette.
307    ///
308    /// The palette closes first and the intent is sent from the focus that
309    /// closing hands back ([`EventContext::send_intent_from_focus`]), which
310    /// is how the command's own keystroke would have been dispatched: the
311    /// editor the palette was opened over hears it, along with its ancestors
312    /// and the window-global actions. Sent from the palette, it would start
313    /// from a surface that is already gone.
314    fn activate_highlighted(&self, ctx: &mut EventContext) {
315        let picked = self.rows.borrow().get(self.highlight.get()).cloned();
316        let Some(entry) = picked else { return };
317        if !entry.command.enabled {
318            // Reachable only with `show_disabled`, where a greyed row is
319            // displayed precisely to say "not now": running it anyway would
320            // make the grey a lie.
321            return;
322        }
323        self.dismiss(ctx);
324        ctx.send_intent_from_focus(Intent::new(entry.command.intent));
325    }
326
327    fn dismiss(&self, ctx: &mut EventContext) {
328        if self.dismissed.replace(true) {
329            return;
330        }
331        let dismiss = self.on_dismiss.borrow().clone();
332        if let Some(dismiss) = dismiss {
333            dismiss(ctx);
334        }
335    }
336}
337
338/// Type-to-run access to every registered command. See the [module docs](self).
339pub struct CommandPalette {
340    state: PaletteState,
341    placeholder: Option<LocalizedString>,
342    empty_text: Option<LocalizedString>,
343    include: Option<IncludeFn>,
344    show_disabled: bool,
345    field_id: Option<WidgetId>,
346    divider_id: Option<WidgetId>,
347    results_id: Option<WidgetId>,
348}
349
350impl Default for CommandPalette {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356impl CommandPalette {
357    /// A palette over every command in the tree's registry.
358    pub fn new() -> Self {
359        Self {
360            state: PaletteState::new(),
361            placeholder: None,
362            empty_text: None,
363            include: None,
364            show_disabled: false,
365            field_id: None,
366            divider_id: None,
367            results_id: None,
368        }
369    }
370
371    /// Replace the search field's placeholder text.
372    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
373        self.placeholder = Some(text.into());
374        self
375    }
376
377    /// Replace the text shown when nothing matches the query.
378    pub fn empty_text(mut self, text: impl Into<LocalizedString>) -> Self {
379        self.empty_text = Some(text.into());
380        self
381    }
382
383    /// Keep only the commands this predicate accepts.
384    ///
385    /// The usual reasons are to hide the command that opens the palette itself, and
386    /// to drop registry entries that are key bindings rather than commands a person
387    /// would look for by name.
388    pub fn include(mut self, f: impl Fn(&PaletteCommand) -> bool + 'static) -> Self {
389        self.include = Some(Rc::new(f));
390        self
391    }
392
393    /// Run this after a command is activated, and when Escape is pressed.
394    ///
395    /// [`present`](Self::present) installs its own, so this is for callers embedding
396    /// the palette in a surface they manage themselves.
397    pub fn on_dismiss(self, f: impl Fn(&mut EventContext) + 'static) -> Self {
398        *self.state.on_dismiss.borrow_mut() = Some(Rc::new(f));
399        self
400    }
401
402    /// Also list commands whose `enabled_when` predicate currently says no, greyed
403    /// out and inert. Off by default: a palette answers "what can I do now", and a
404    /// row that cannot run is a row that has to be explained.
405    pub fn show_disabled(mut self, show: bool) -> Self {
406        self.show_disabled = show;
407        self
408    }
409
410    /// The query signal, so a caller can seed or observe what was typed.
411    pub fn query_signal(&self) -> Signal<String> {
412        self.state.query.clone()
413    }
414
415    /// Show the palette near the top of the window, dismissed by Escape or a
416    /// click outside. See the [module docs](self) on why this is window-level.
417    pub fn present(self, ctx: &mut EventContext) {
418        self.state.presented.set(true);
419        // The palette's own Escape handler never runs for a presented palette:
420        // the overlay system claims Escape first and tears the modal down
421        // without it, and the same is true of a press outside. So a caller's
422        // `on_dismiss`, installed to release whatever opening the palette
423        // reserved, only ever fired when a command was actually run. Route
424        // the framework's dismissal into the same latched hook.
425        //
426        // Only the caller's own callback is carried here, never the default
427        // `ctx.dismiss_modal()` installed below: on this path the modal is
428        // already going away, and that call would set a deferred flag spent
429        // on whatever modal is topmost when it is read.
430        let user_dismiss = self.state.on_dismiss.borrow().clone();
431        let already_dismissed = self.state.dismissed.clone();
432        let palette = match user_dismiss.clone() {
433            None => self.on_dismiss(|ctx| ctx.dismiss_modal()),
434            // Close the modal as well as running the caller's hook: a
435            // palette that ran a command and stayed up would leave the
436            // command's focus target under it.
437            Some(user) => self.on_dismiss(move |ctx| {
438                user(ctx);
439                ctx.dismiss_modal();
440            }),
441        };
442        let mut inner = Some(palette);
443        ctx.present_modal(
444            ModalRequest::deferred(move |tree| {
445                let palette = inner
446                    .take()
447                    .expect("CommandPalette present closure called twice");
448                // The palette draws its own insets: the dialog padding would
449                // float the search field in the middle of the panel.
450                tree.add(ModalContainer::new(palette).padding(0.0))
451            })
452            .presentation(ModalPresentation::InTree)
453            .close_behavior(ModalCloseBehavior::EscapeOrClickOutside)
454            // Top-anchored, so the search field stays where it is while the
455            // list under it narrows.
456            .placement(ModalPlacement::Top {
457                offset: PALETTE_TOP_OFFSET,
458            })
459            .on_dismiss(Rc::new(move |_reason, ctx: &mut EventContext| {
460                if already_dismissed.replace(true) {
461                    return;
462                }
463                if let Some(cb) = &user_dismiss {
464                    cb(ctx);
465                }
466            })),
467        );
468    }
469}
470
471/// Whether `id` is a command that runs on the focused editor: a standard edit
472/// command, or one of the editors' own.
473fn acts_on_the_focused_editor(id: &str) -> bool {
474    StandardEdit::from_id(id).is_some()
475        || crate::editor_shortcuts::FormatShortcut::from_id(id).is_some()
476        || crate::editor_shortcuts::CodeShortcut::from_id(id).is_some()
477}
478
479impl std::fmt::Debug for CommandPalette {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        f.debug_struct("CommandPalette")
482            .field("query", &self.state.query.get())
483            .field("rows", &self.state.len())
484            .finish_non_exhaustive()
485    }
486}
487
488impl Widget for CommandPalette {
489    /// Built once. Neither typing nor moving the highlight rebuilds the
490    /// palette: the query rebuilds only the result list, a sibling of the
491    /// search field, and the highlight repaints the rows.
492    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
493        // The match count is this node's accessible description.
494        self.state.count.bind_to(
495            ctx.self_id(),
496            ctx.binding_registry(),
497            BindingLevel::AccessibilityOnly,
498        );
499
500        let placeholder = self
501            .placeholder
502            .clone()
503            .unwrap_or_else(|| tr_widget!(command_palette_placeholder()));
504        let submit_state = self.state.clone();
505        let field = SearchField::new(self.state.query.clone())
506            .placeholder(placeholder)
507            .label(tr_widget!(command_palette_title()))
508            .on_submit_fn(move |ctx| submit_state.activate_highlighted(ctx))
509            // The ARIA combobox pattern. Focus never leaves this field (that
510            // is what makes a palette feel like one), so the highlight has to
511            // be announced through the field's own AT node. `SearchField`
512            // forwards both down to the focusable `TextInputField`, the only
513            // node whose `active_descendant` assistive technology follows.
514            .drives_listbox(self.state.listbox_id.clone(), self.state.active_row.clone());
515
516        // `Live::Off` on each part of the content. The palette's own node is
517        // a polite live region, and `accesskit_consumer` hands a node's
518        // politeness down to every descendant that sets none (node.rs:
519        // 906-910), while all three adapters announce each named node that
520        // enters the filtered tree, or is renamed there, with an inherited
521        // politeness (atspi_common adapter.rs:71-77 and node.rs:610-622,
522        // windows adapter.rs:255-263, macos event.rs:236-241). Without it,
523        // opening the palette announced the field and every row, and each
524        // keystroke announced the rows it brought in, each cutting off the
525        // one before.
526        self.field_id = Some(
527            ctx.add(
528                Padding::uniform(FIELD_PADDING)
529                    .child(field)
530                    .access_live(Live::Off),
531            ),
532        );
533        self.divider_id = Some(ctx.add(Divider::horizontal()));
534        let config = Rc::new(ResultsConfig {
535            include: self.include.clone(),
536            show_disabled: self.show_disabled,
537            empty_text: self.empty_text.clone(),
538        });
539        self.results_id =
540            Some(ctx.add(PaletteResults::new(self.state.clone(), config).access_live(Live::Off)));
541
542        let key_state = self.state.clone();
543        ctx.apply_self_handlers(HandlerSet::new().on_key(move |ev, ctx| {
544            // Home and End are not here: they belong to the search field,
545            // which moves its caret with them.
546            let WidgetEvent::KeyDown { key, .. } = ev else {
547                return EventResponse::Ignored;
548            };
549            match key {
550                Key::ArrowDown => key_state.step(true),
551                Key::ArrowUp => key_state.step(false),
552                Key::PageDown => key_state.page(true),
553                Key::PageUp => key_state.page(false),
554                Key::Escape => key_state.dismiss(ctx),
555                _ => return EventResponse::Ignored,
556            }
557            EventResponse::Handled
558        }));
559
560        self.children()
561    }
562
563    fn accessibility(&self, node: &mut AccessNodeBuilder) {
564        node.set_role(Role::Dialog);
565        // An unnamed dialog is announced as "dialog" and nothing else, which
566        // tells a screen-reader user that something opened but not what.
567        node.set_name(
568            tr_widget!(command_palette_title())
569                .resolve_now()
570                .to_string(),
571        );
572        node.set_modal();
573        // How many commands the query currently matches: the one fact a
574        // sighted user reads off the list at a glance and a screen-reader user
575        // otherwise has to arrow through the whole list to learn.
576        let count = self.state.count.get();
577        node.set_description(
578            tr_widget!(command_palette_result_count(count = count as i64)).resolve_now(),
579        );
580        node.set_live(Live::Polite);
581    }
582
583    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
584        let width = proposal.width.unwrap_or(PALETTE_WIDTH);
585        let at_width = SizeProposal::with_width(width);
586        let fixed: f32 = [self.field_id, self.divider_id]
587            .into_iter()
588            .flatten()
589            .filter_map(|id| ctx.child_size(id, at_width))
590            .map(|s| s.height)
591            .sum();
592        let results = self
593            .results_id
594            .and_then(|id| ctx.child_layout_response(id, at_width));
595        let (ideal, min) = match results {
596            Some(r) => (fixed + r.size.height, fixed + r.min.height),
597            None => (fixed, fixed),
598        };
599        // As tall as the content and no taller, so the panel hugs a short
600        // list; squeezed by a short window, the list gives up rows first.
601        let height = proposal.height.map_or(ideal, |h| h.min(ideal));
602        LayoutResponse::shrinkable(
603            Size::new(width, height),
604            Size::new(width, min.min(height)),
605            1.0,
606        )
607    }
608
609    fn place_children(
610        &self,
611        bounds: Rect,
612        _proposal: SizeProposal,
613        children: &mut [WidgetPlacement],
614        ctx: &LayoutContext,
615    ) {
616        let at_width = SizeProposal::with_width(bounds.width);
617        let mut y = bounds.y;
618        for child in children.iter_mut() {
619            let height = if Some(child.id) == self.results_id {
620                (bounds.bottom() - y).max(0.0)
621            } else {
622                ctx.child_size(child.id, at_width)
623                    .map(|s| s.height)
624                    .unwrap_or(0.0)
625            };
626            child.origin = Point::new(bounds.x, y);
627            child.size = Size::new(bounds.width, height);
628            y += height;
629        }
630    }
631
632    fn children(&self) -> Vec<WidgetId> {
633        [self.field_id, self.divider_id, self.results_id]
634            .into_iter()
635            .flatten()
636            .collect()
637    }
638}
639
640#[cfg(test)]
641mod tests;