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;