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 adismissevent 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 alabel, and kui warns withmodal-without-nameif 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 acontextmenuevent withxandy; declare a modal float atFloatConfig::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
- The reference examples:
widgets/tooltip.rs,features/modal.rs,apps/counter.rsfor the context menu.