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

Overlays

A dropdown list, a context menu, a tooltip and a modal all have the same problem: they belong to a widget somewhere in the middle of your layout, but they must paint over everything drawn after it. A terminal buffer has no z-index. The only ordering is the order you write cells.

tuile solves this two ways, and which one you need depends on who owns the overlay.

The simple case: draw it last

If your app owns the overlay, render it after everything else. Toasts, modals and the command palette work this way:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { table: DataTableState, toasts: Toaster, modal: ModalState }
impl Page {
fn draw(&mut self, area: Rect, buf: &mut Buffer, now: Instant) {
    let cols = vec![TableColumn::new("Host"), TableColumn::new("Status")];
    let rows = vec![TableRow::from(vec!["web-01", "up"])];
    DataTable::new(cols, rows).render(area, buf, &mut self.table);

    // after the page content, so they paint on top
    Modal::confirm("Delete host?", "This cannot be undone.").render(area, buf, &mut self.modal);
    ToastStack::new().now(now).render(area, buf, &mut self.toasts);
}
}
fn main() {}

Ordering the draw calls is the whole mechanism. The overlay widgets take the full area and place themselves inside it.

The harder case: a widget owns its own popup

A Select renders a field wherever your layout put it, and its dropdown must appear next to that field and over whatever follows. The widget cannot draw the list immediately, because the rest of your layout has not been drawn yet.

Overlay is a queue of deferred draw closures for this:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn demo(area: Rect, buf: &mut Buffer) {
let mut overlay = Overlay::new();

overlay.push(|buf: &mut Buffer| {
    // runs after everything else has been drawn
    fill(buf, Rect::new(4, 6, 20, 5), Rgb::hex(0x1e1e2e));
});

// ... draw the rest of the page ...

overlay.draw(buf);      // drain the queue last
let _ = area;
}
fn main() {}

push takes any FnOnce(&mut Buffer), is_empty() asks whether anything is queued, and draw(self) consumes the queue in push order. The closures borrow for the lifetime of the overlay, so they can capture the widget state and the theme without copying.

Placing a popup

popup_below does the geometry every dropdown needs: prefer below the anchor, flip above when there is no room, and slide horizontally to stay inside the bounds.

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
fn main() {
let screen = Rect::new(0, 0, 80, 24);
let field = Rect::new(10, 20, 20, 3);

let list = popup_below(field, 20, 8, screen);
assert!(list.bottom() <= screen.bottom());   // flipped above, it did not overflow
}

It never returns a rectangle outside bounds, so a dropdown at the bottom of the screen opens upward and one at the right edge shifts left.

Events go the other way

Drawing is back to front. Event handling is front to back. The widget on top must see the click first, or the thing underneath will react to a click it never received visually:

extern crate tuile;
extern crate ratatui_core;
use tuile::prelude::*;
struct Page { modal: ModalState, menu: ContextMenuState, table: DataTableState }
impl Page {
fn event(&mut self, ev: &Event) -> Outcome {
    // topmost first, and stop as soon as one consumes
    let out = self.modal.handle(ev);
    if out.is_consumed() {
        return out;
    }
    let out = self.menu.handle(ev);
    if out.is_consumed() {
        return out;
    }
    self.table.handle(ev)
}
}
fn main() {}

This is the most common source of “why did my table scroll while a dialog was open”. The broadcast pattern from the events chapter is correct only for widgets that do not overlap.

A modal that is closed consumes nothing, so the early return costs one comparison per event when no dialog is open.

Escape and click-outside

Users expect Esc to close the topmost overlay and a click outside it to dismiss it. Widgets that own a popup handle both internally: Select closes its dropdown on Esc and on a click that lands outside the list. For app-owned overlays you write the rule, and the order above is what makes it work: the modal sees Esc first and consumes it, so the table never treats it as a filter reset.

When several overlays are open at once, Esc should close one layer per press. Handling them in topmost-first order with an early return gives that for free.

Cost

An overlay closure is one boxed allocation per popup per frame, drained immediately. A screen with a dropdown open allocates once more than the same screen with it closed. The queue is empty in the common case and is_empty() is what widgets check before doing any work.