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

Floats and modals

At the end of this chapter, hovering a badge shows a tooltip, and a Delete button opens a dialog that Escape closes.

A float is out of the flow

Everything so far sat inside its parent and took up room. A float does neither: it is positioned against an anchor and drawn on top. Tooltips, menus and dialogs are floats.

The tooltip prop

//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats

use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Ask,
    Confirm,
    Cancel,
    /// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
    /// under it.
    Dialog,
}

#[derive(Default)]
struct Floats {
    asking: bool,
    deleted: u32,
}

impl App for Floats {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                // `tooltip` is a prop: the node tracks its own hover, and
                // the core floats the hint below it while hovered. The
                // same text is what a screen reader says after the name.
                ui.text_in(
                    NodeSpec::row()
                        .pad_xy(10.0, 6.0)
                        .radius(8.0)
                        .bg(t.raised)
                        .border(1.0, t.border)
                        .tooltip("A float: out of the flow, on top, unclipped"),
                    "hover me",
                    TextStyle::new(13.0).color(t.fg),
                );

                ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
                    widgets::button(ui, "Delete…", Msg::Ask);
                    ui.text(
                        &format!("deleted {} times", self.deleted),
                        TextStyle::new(12.0).color(t.muted),
                    );
                });

                // Declared last, so it stacks on top. `modal` scopes Tab
                // to it, keeps the pointer from what is behind, and turns
                // Escape or a press outside into a `dismiss` event.
                if self.asking {
                    ui.with_keyed(
                        "dialog",
                        NodeSpec::column()
                            .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                            .modal(Msg::Dialog)
                            .label("Delete?")
                            .width(260.0)
                            .pad(16.0)
                            .gap(12.0)
                            .bg(t.raised)
                            .radius(10.0)
                            .border(1.0, t.border_strong),
                        |ui| {
                            ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
                            ui.text(
                                "This cannot be undone.",
                                TextStyle::new(12.0).color(t.muted),
                            );
                            ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
                                widgets::button(ui, "Cancel", Msg::Cancel);
                                widgets::button(ui, "Delete", Msg::Confirm);
                            });
                        },
                    );
                }
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Ask) => self.asking = true,
            Some(Msg::Confirm) => {
                self.deleted += 1;
                self.asking = false;
            }
            // The core closes nothing. Both a Cancel click and a dismiss
            // end the same way: the view stops declaring the dialog.
            Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Floats")
        .size(420.0, 280.0)
        .run(Floats::default())
}

.tooltip(text) is enough. The node tracks its own hover, and kui floats the text below it while hovered. Near the bottom of the window it flips above, so it never hangs off the edge. The same text is what a screen reader says after the node’s name.

A modal the app declares

A dialog is a float you declare when the model says so and stop declaring when it is answered:

//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats

use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Ask,
    Confirm,
    Cancel,
    /// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
    /// under it.
    Dialog,
}

#[derive(Default)]
struct Floats {
    asking: bool,
    deleted: u32,
}

impl App for Floats {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                // `tooltip` is a prop: the node tracks its own hover, and
                // the core floats the hint below it while hovered. The
                // same text is what a screen reader says after the name.
                ui.text_in(
                    NodeSpec::row()
                        .pad_xy(10.0, 6.0)
                        .radius(8.0)
                        .bg(t.raised)
                        .border(1.0, t.border)
                        .tooltip("A float: out of the flow, on top, unclipped"),
                    "hover me",
                    TextStyle::new(13.0).color(t.fg),
                );

                ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
                    widgets::button(ui, "Delete…", Msg::Ask);
                    ui.text(
                        &format!("deleted {} times", self.deleted),
                        TextStyle::new(12.0).color(t.muted),
                    );
                });

                // Declared last, so it stacks on top. `modal` scopes Tab
                // to it, keeps the pointer from what is behind, and turns
                // Escape or a press outside into a `dismiss` event.
                if self.asking {
                    ui.with_keyed(
                        "dialog",
                        NodeSpec::column()
                            .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                            .modal(Msg::Dialog)
                            .label("Delete?")
                            .width(260.0)
                            .pad(16.0)
                            .gap(12.0)
                            .bg(t.raised)
                            .radius(10.0)
                            .border(1.0, t.border_strong),
                        |ui| {
                            ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
                            ui.text(
                                "This cannot be undone.",
                                TextStyle::new(12.0).color(t.muted),
                            );
                            ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
                                widgets::button(ui, "Cancel", Msg::Cancel);
                                widgets::button(ui, "Delete", Msg::Confirm);
                            });
                        },
                    );
                }
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Ask) => self.asking = true,
            Some(Msg::Confirm) => {
                self.deleted += 1;
                self.asking = false;
            }
            // The core closes nothing. Both a Cancel click and a dismiss
            // end the same way: the view stops declaring the dialog.
            Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Floats")
        .size(420.0, 280.0)
        .run(Floats::default())
}

Three props do the work:

  • .float(FloatConfig::viewport().inside(Center, Center)) — anchored to the window, centred. FloatConfig::parent() anchors to the parent box instead, and .offset(x, y) nudges either.
  • .modal(tag) — while this float is declared, Tab stays inside it, the pointer cannot reach what is behind it, and Escape or a press outside becomes a dismiss event carrying the tag.
  • .label("Delete?") — the dialog’s name. A button is named by the text inside it, but a dialog is not: a screen reader announcing “dialog” with nothing after it tells the user nothing, and the platforms do not build a name from a dialog’s contents. So a modal needs a label, and kui warns with modal-without-name if it has none. The label is not drawn; the title text inside the dialog is a separate node, and it is fine for the two to say the same thing.

kui does not close the dialog. It reports dismiss, and the app decides. Here it stops declaring:

//! Step 7 — floats and a modal. Builds on step 6 by drawing over the
//! page: a tooltip the core floats under a hovered node, and a confirm
//! dialog the app declares as a `modal` float and stops declaring when
//! it is answered or dismissed. Chapter 8 of the book.
//!
//! Run: cargo run -p kui-native --example tutorial_07_floats

use kui_native::widgets;
use kui_native::{Align, App, FloatConfig, Message, NodeSpec, TextStyle, Ui, UiEvent};

#[derive(Message, Clone, Debug, PartialEq)]
enum Msg {
    Ask,
    Confirm,
    Cancel,
    /// The modal's tag: a `dismiss` (Escape, or a press outside) arrives
    /// under it.
    Dialog,
}

#[derive(Default)]
struct Floats {
    asking: bool,
    deleted: u32,
}

impl App for Floats {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(
            NodeSpec::column()
                .fill()
                .pad(20.0)
                .gap(12.0)
                .cross_align(Align::Start)
                .bg(t.bg),
            |ui| {
                // `tooltip` is a prop: the node tracks its own hover, and
                // the core floats the hint below it while hovered. The
                // same text is what a screen reader says after the name.
                ui.text_in(
                    NodeSpec::row()
                        .pad_xy(10.0, 6.0)
                        .radius(8.0)
                        .bg(t.raised)
                        .border(1.0, t.border)
                        .tooltip("A float: out of the flow, on top, unclipped"),
                    "hover me",
                    TextStyle::new(13.0).color(t.fg),
                );

                ui.with(NodeSpec::row().gap(8.0).cross_align(Align::Center), |ui| {
                    widgets::button(ui, "Delete…", Msg::Ask);
                    ui.text(
                        &format!("deleted {} times", self.deleted),
                        TextStyle::new(12.0).color(t.muted),
                    );
                });

                // Declared last, so it stacks on top. `modal` scopes Tab
                // to it, keeps the pointer from what is behind, and turns
                // Escape or a press outside into a `dismiss` event.
                if self.asking {
                    ui.with_keyed(
                        "dialog",
                        NodeSpec::column()
                            .float(FloatConfig::viewport().inside(Align::Center, Align::Center))
                            .modal(Msg::Dialog)
                            .label("Delete?")
                            .width(260.0)
                            .pad(16.0)
                            .gap(12.0)
                            .bg(t.raised)
                            .radius(10.0)
                            .border(1.0, t.border_strong),
                        |ui| {
                            ui.text("Delete the thing?", TextStyle::new(15.0).color(t.fg));
                            ui.text(
                                "This cannot be undone.",
                                TextStyle::new(12.0).color(t.muted),
                            );
                            ui.with(NodeSpec::row().gap(8.0).main_align(Align::End), |ui| {
                                widgets::button(ui, "Cancel", Msg::Cancel);
                                widgets::button(ui, "Delete", Msg::Confirm);
                            });
                        },
                    );
                }
            },
        );
    }

    fn on_event(&mut self, ev: UiEvent) {
        match ev.message::<Msg>() {
            Some(Msg::Ask) => self.asking = true,
            Some(Msg::Confirm) => {
                self.deleted += 1;
                self.asking = false;
            }
            // The core closes nothing. Both a Cancel click and a dismiss
            // end the same way: the view stops declaring the dialog.
            Some(Msg::Cancel) | Some(Msg::Dialog) => self.asking = false,
            None => {}
        }
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    kui_native::app("Floats")
        .size(420.0, 280.0)
        .run(Floats::default())
}

Order matters

Floats stack in the order they are declared. The dialog is declared last in view, so it is on top. Declare it first and the page would paint over it — and kui would warn, modal-behind-content.

Try this

  • Right-click anywhere and open a menu there. .on_context_menu(tag) on the root delivers a contextmenu event with x and y; declare a modal float at FloatConfig::viewport().inside(Start, Start) .offset(x, y).fit(). The reference counter does exactly this.
  • Give the dialog .initial_focus() on the Cancel button, so Enter cancels.

Where this is decided