Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

features/popup.rs

//! A combobox whose list is taller than the window: the case
//! `FloatConfig::fit` cannot place, and so the reason `WindowKind::Popup`
//! exists (`docs/adr/0004-multi-window.md`, decision 9). The window is 360
//! x 150; the list is twelve rows and 300 tall, so opening it visibly
//! extends past the frame instead of being clamped inside it.
//!
//! Everything here is the declaration. The field carries `on_layout`, so
//! the app is told the rect it occupies — that rect *is* the anchor, which
//! is why a popup needs no new geometry query. While `open` is true the
//! view declares a second window, `"menu"`, of kind popup; when it is false
//! the declaration stops and the window closes. Nothing calls a "close
//! popup" function, because there isn't one.
//!
//! Four things to watch with it running:
//!
//! - The **field keeps its focus ring** while the list is up, and the arrow
//!   keys walk the list. A popup does not take OS focus, so the owner still
//!   believes it has the keyboard; the runner routes the keys on.
//! - **Escape and a press outside** produce `{kind:"dismiss"}` — the same
//!   event a `modal` node gets — and close nothing by themselves. This app
//!   answers by clearing `open`, which is the whole of "closing" a popup.
//! - The **list draws past the bottom edge** of the main window, which an
//!   in-window float cannot do. Shrink the window and it still does.
//! - **Press the field, drag into the list, release on a row** — the native
//!   select gesture, in one gesture with no second click
//!   (`docs/adr/0009-press-drag-release-into-a-popup.md`). None of it is in
//!   this file: the OS gives the whole drag to the window the press landed
//!   in, and the runner translates the moves into the popup's coordinates
//!   and synthesises the press-and-release the popup never saw. All this
//!   app does is open on `on_drag`'s `start` phase instead of waiting for a
//!   click, and **set** rather than toggle — which is safe because a press
//!   that dismisses a popup is consumed rather than passed through, so a
//!   press on the open field closes the menu and cannot reopen it.
//!
//! Run: cargo run --example popup

use kui_devtools::Example;
use kui_native::{
    App, Color, CursorShape, NodeSpec, Rect, TextStyle, Ui, UiEvent, Value, WindowConfig,
};

/// The list is twelve rows of 24 plus the panel's padding — deliberately
/// twice the window's height, so "taller than the window" is not a detail
/// of how it was resized.
const ITEMS: [&str; 12] = [
    "Aluminium",
    "Beryllium",
    "Cadmium",
    "Chromium",
    "Iridium",
    "Lithium",
    "Magnesium",
    "Osmium",
    "Palladium",
    "Rhodium",
    "Titanium",
    "Vanadium",
];
const ROW_H: f32 = 24.0;
const MENU_W: f32 = 200.0;
const MENU_H: f32 = ITEMS.len() as f32 * ROW_H + 12.0;

#[derive(Default)]
struct Combo {
    /// Which item is chosen, and which the arrows are sitting on. Both are
    /// app state: the core stores no selection for a list an app draws.
    chosen: usize,
    cursor: usize,
    /// Whether the list is declared this frame. The one flag that opens and
    /// closes an OS window.
    open: bool,
    /// The field's rect, as `on_layout` last reported it — the anchor the
    /// popup is placed against, in this window's own coordinates.
    field: Rect,
}

impl App for Combo {
    fn view(&mut self, ui: &mut Ui<'_>) {
        // Every window of the app runs this same `view`; `window_name`
        // says which one is being drawn (ADR 0004 decision 12).
        if &*ui.window_name() == "menu" {
            self.list(ui);
            return;
        }

        // The list exists exactly while the field says so. The config —
        // kind, size and anchor — is read on the frame it opens and never
        // again, so a popup that must follow a moving anchor stops being
        // declared and starts again.
        if self.open {
            ui.window("menu", WindowConfig::popup(self.field, MENU_W, MENU_H));
        }

        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .pad(16.0)
                .gap(10.0)
                // The window's whole surface, not just the content box: a
                // `Fit` column would paint its background around the text
                // and leave the rest of the window whatever the renderer
                // cleared it to.
                .fill()
                .bg(t.bg),
            |ui| {
                ui.text("Alloy", TextStyle::new(12.0).color(t.muted));
                // The field. `on_layout` is the only thing here that has
                // anything to do with the popup: it reports this rect, and
                // the rect is the anchor.
                ui.with_keyed(
                    "field",
                    NodeSpec::row()
                        .pad_xy(10.0, 6.0)
                        .gap(8.0)
                        .width(MENU_W)
                        .bg(t.sunken)
                        .hover_bg(t.sunken.mix(t.accent, 0.10))
                        .radius(5.0)
                        .focusable()
                        .label("Alloy")
                        .on_layout("field")
                        // Two ways in, and both of them *open*. `on_drag`
                        // is the press: its `start` phase arrives on
                        // mouse-down, before any slop, which is what makes
                        // press-drag-release one gesture. `on_click` is the
                        // release that never moved, and the keyboard, and
                        // assistive technology — idempotent after a `start`
                        // that already opened the list.
                        .on_drag(Value::Null)
                        .on_click(Value::map([("kind", Value::str("open"))]))
                        // The hand is declared, never derived: a field
                        // that can be pressed says so with `cursor`, and
                        // nothing about its `on_drag` makes it a grab.
                        .cursor(CursorShape::Pointer),
                    |ui| {
                        ui.text(ITEMS[self.chosen], TextStyle::new(14.0).color(t.fg));
                        ui.leaf(NodeSpec::row().grow_width());
                        ui.text("v", TextStyle::new(11.0).color(t.muted));
                    },
                );
                ui.text(
                    if self.open {
                        "Release on a row, or press outside to dismiss"
                    } else {
                        "Press and drag into the list, or Tab here and press Space"
                    },
                    TextStyle::new(11.0).color(t.faint),
                );
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        let kind = ev.kind();
        match kind {
            // The field's rect, every time layout changes it. Stored, not
            // acted on: it is what the *next* declaration will carry.
            Some("layout") => {
                let n = |k: &str| ev.payload.get(k).and_then(Value::as_float).unwrap_or(0.0) as f32;
                self.field = Rect::new(n("x"), n("y"), n("w"), n("h"));
            }
            // Everything opens; only a dismissal or a choice closes. The
            // press (`drag`'s `start`) and the click that follows a
            // stationary release both land here, and assigning rather than
            // toggling is what lets them: neither has to know which press
            // it is answering.
            Some("drag") if ev.payload.get_str("phase") == Some("start") => {
                self.open = true;
                self.cursor = self.chosen;
            }
            Some("open") => {
                self.open = true;
                self.cursor = self.chosen;
            }
            // The same event a `modal` float would have given, on the
            // window instead of on a node — which is the whole of ADR 0004
            // decision 9's "changes its declaration and not its handler".
            // The core closed nothing; this line is what closes it.
            Some("dismiss") => self.open = false,
            Some("choose") => {
                if let Some(i) = ev.payload.get_int("i") {
                    self.chosen = i as usize;
                }
                self.open = false;
            }
            // The arrows arrive on the popup's core, because the runner
            // routes the owner's keyboard there while a non-activating
            // popup is up. The field keeps its ring throughout.
            // Presses only: the list's sink never asked for releases.
            Some("key") => match ev.payload.get_str("code") {
                Some("down") => self.cursor = (self.cursor + 1) % ITEMS.len(),
                Some("up") => self.cursor = (self.cursor + ITEMS.len() - 1) % ITEMS.len(),
                Some("enter") => {
                    self.chosen = self.cursor;
                    self.open = false;
                }
                _ => {}
            },
            _ => {}
        }
    }
}

impl Combo {
    /// The popup window's own frame: a plain list, drawn by the same
    /// `view`. Nothing about it says "popup" — the declaration did that.
    fn list(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        let root = ui.with(
            NodeSpec::column()
                .pad(6.0)
                // The popup's window *is* the panel, so its root fills it:
                // the rows grow across, and there is nothing else to share
                // the height with.
                .fill()
                // A popup window is a float that got its own surface, so
                // it takes the role a menu or a tooltip takes.
                .bg(t.raised)
                .border(1.0, t.border_strong)
                // The list owns its keyboard: `on_key` makes it a sink, so
                // the arrows walk the list rather than moving a focus ring
                // the user cannot see — the visible ring is over in the
                // window that opened this one.
                .on_key(Value::map([("kind", Value::str("key"))])),
            |ui| {
                for (i, item) in ITEMS.iter().enumerate() {
                    let on = i == self.cursor;
                    ui.with_keyed(
                        item,
                        NodeSpec::row()
                            .pad_xy(10.0, 4.0)
                            .height(ROW_H)
                            .grow_width()
                            // The cursor row is a wash, not a fill, for
                            // the reason the stock menu's is: the label's
                            // colour is chosen before the core resolves a
                            // `hover_bg`, so a fill would be unreadable on
                            // a light base for a frame.
                            .bg(if on {
                                t.accent_soft
                            } else {
                                Color::TRANSPARENT
                            })
                            .hover_bg(t.raised.mix(t.accent, 0.10))
                            .radius(4.0)
                            .selected(i == self.chosen)
                            // The rows are a hand too, each for itself.
                            .cursor(CursorShape::Pointer)
                            .on_click(Value::map([
                                ("kind", Value::str("choose")),
                                ("i", Value::Int(i as i64)),
                            ])),
                        |ui| {
                            ui.text(
                                item,
                                TextStyle::new(13.0).color(if on { t.fg } else { t.muted }),
                            );
                        },
                    );
                }
            },
        );
        // The sink has to hold key focus to be handed anything, and this
        // window's own ring is invisible to the user — the ring they see
        // belongs to the field. Edge-triggered, so asking every frame is
        // asking once.
        ui.take_key_focus(root);
    }
}

impl Example for Combo {
    const KEYS: &'static [(&'static str, &'static str)] = &[
        ("Enter / ↓", "open the list"),
        ("↑ ↓", "walk it"),
        ("Esc", "close it"),
    ];

    /// Small on purpose: the list is twice this tall, so it cannot be an
    /// in-window float however `fit` is asked to place it.
    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(360.0, 150.0)
    }

    /// No dock unless asked: the point is a list taller than the frame it
    /// opens from, and a dock beside it would make the frame tall enough
    /// to hold it. `--dock side` still works for the readout.
    fn dock(&self) -> kui_devtools::Dock {
        kui_devtools::Dock::Off
    }
}

kui_devtools::main!(Combo::default());