tui4s
Guide

Overlays and layers

Positioned elements, modals with a focus trap, toasts, and the size of the terminal.
Dashboard.scala
    /** The terminal width from which the tabs and the log are side by side. */
    val wideFrom: Int = 120

    /** The tabs and the log side by side in a terminal of `wideFrom` columns or
      * more, else one above the other. `Tui.size` is the size of the terminal
      * as a signal (kyo-ui has no such signal on the JVM), and a signal of
      * styles restyles the box when the size changes.
      */
    def body(tabs: UI, log: UI, size: Signal[TerminalSize]): UI =
      div(div(tabs).style(_.flexGrow(1)), div(log).style(_.flexGrow(1)))
        .style(
          size.map(t =>
            if t.width >= wideFrom then Style.row.gap(2.px) else Style.column,
          ),
        )

    /** The help: a box in the middle of the screen, over the rest (a
      * `position(_.overlay)` layer that centers it), with a row for each key of
      * `keys`. Its `focusTrap` keeps Tab inside it; its Close button takes the
      * focus when it appears (`focusAuto`) and gives it back when it goes
      * (`focusRestore`). Esc does nothing by itself in a focus trap: the
      * `onKeyDown` of the box closes it.
      */
    def help(s: State, keys: Keymap): UI =
      when(s.help)(
        div.style(_.position(_.overlay).justify(_.center).align(_.center))(
          div
            .id("help")
            .focusTrap(true)
            .onKeyDown {
              case KeyboardEvent(Keyboard.Escape, _, _) => s.help.set(false)
              case _                                    => ()
            }
            .style(
              _.border(1.px, Style.Color.blue)
                .bg(Style.Color.black)
                .padding(0.px, 1.px),
            )(
              h2("Keys"),
              span("←/→  change the tab"),
              UI.fragment(keys.help.map((k, h) => span(f"$k%-4s $h"))*),
              button("Close")
                .id("help-close")
                .focusAuto(true)
                .focusRestore(true)
                .onClick(s.help.set(false))
                .style(_.alignSelf(_.center)),
            ),
        ),
      )

    /** The toast: a box at the bottom right (`position(_.fixed)` with `bottom`
      * and `right`), over the rest, while a message is shown.
      */
    def toast(s: State): UI =
      s.toast.map {
        case None    => UI.empty
        case Some(t) =>
          div.style(
            _.position(_.fixed)
              .bottom(1.px)
              .right(2.px)
              .border(1.px, Style.Color.green)
              .bg(Style.Color.black)
              .padding(0.px, 1.px),
          )(t.text)
      }

In the Dashboard, ? opens the help box over the screen, and p shows a toast at the bottom right. In a terminal of 120 columns or more, the tabs and the log are side by side.

Layers

A positioned element is a layer. position(_.absolute) takes the element out of the flex flow and places it in its containing block: the padding box of the nearest positioned ancestor, else the screen. position(_.fixed) places it in the screen. kyo-ui's overlay is a fixed box over the whole screen. Its dropdown is an absolute box under the right edge of its positioned ancestor. top, right, bottom and left place the box from an edge.

The flow paints first. Then the layers paint in the order of their zIndex (0 when none), and the equal ones in document order. In a layer, a background hides what is under it. When a frame changes cells under a layer, the runner paints those rows again, so nothing shows through the layer.

Modals

A modal is a layer with kyo-ui's focusTrap: Tab stays inside it. Its first focusable element with focusAuto(true) takes the focus when it appears. With focusRestore(true), the focus goes back to the element that had it before. Esc does nothing by itself in a focus trap: the onKeyDown of the app closes the modal, as the help box does.

The size of the terminal

Tui.size is the size of the terminal in cells, as a Signal[TerminalSize]. kyo-ui has no signal of the window size on the JVM. A resize writes it in the frame of the resize, so the layout that comes from it is in the same frame. body in the example restyles the box when the size changes.

flexWrap(_.wrap) breaks the items of a row into lines. displayNone removes an element from the layout and the paint, and its components keep their state.

Copyright © 2026