tui4s
Guide

Scrolling and viewports

An element with overflow is a viewport that clips and scrolls; the text flow, the wrap and the ellipsis.
Todo.scala
    /** One line of text: a longer text ends with "…" at the edge of its box
      * (CSS `overflow: hidden`, `text-overflow: ellipsis`,
      * `white-space: nowrap`).
      */
    val oneLine: Style =
      Style.overflow(_.hidden).textOverflow(_.ellipsis).whiteSpace(_.noWrap)

    /** A row: the check box keeps its 3 cells, and a long title ends with "…".
      * The list names the selected row by its id.
      */
    def row(r: Row): Ast.Li =
      li.id(s"item-${r.item.id}")
        .style(
          (if r.selected then
               Style.bg(Style.Color.indigo).color(Style.Color.white)
           else Style.empty).gap(1.px),
        )(
          checkbox.checked(r.item.done).style(_.flexShrink(0)),
          span(r.item.text).style(
            if r.item.done then oneLine.strikethrough.color(Style.Color.gray)
            else oneLine,
          ),
        )

    /** The list keeps the focus while the arrows move the selection:
      * `aria-activedescendant` names the selected row (`selected`, its id), so
      * the viewport around the list scrolls to it, and `preventScrollKeys`
      * keeps the arrows from scrolling it too. kyo-ui's `aria` takes a fixed
      * value, so the list is built again when `selected` changes; a toggle or
      * an add changes only its rows (the `foreachKeyed`).
      */
    def list(
        selected: Signal[String],
        rows: Signal[Chunk[Row]],
        onKey: KeyboardEvent => Any < Async,
    ): UI =
      selected.map(id =>
        ul.id("list")
          .tabIndex(0)
          .preventScrollKeys
          .aria("activedescendant", id)
          .onKeyDown(onKey)
          .style(
            _.border(1.px, Style.Color.slate)
              .minHeight(4.px)
              .focus(_.border(1.px, Style.Color.blue)),
          )(
            rows.foreachKeyed(_.item.id.toString)(row),
          ): UI,
      )

    /** The list and the input bar under it, in a viewport that takes the free
      * rows and scrolls (`overflowY: auto`). It is not a tab stop, because it
      * holds focusable elements (its list and its input).
      */
    def body(list: UI, input: UI): UI =
      div.style(_.overflowY(_.auto).flexGrow(1))(list, input)

An element with overflow, overflowX or overflowY other than visible is a viewport. tui4s lays out its content at its natural size on that axis, clips it to the box, and shows the window at the scroll offset. hidden clips and does not scroll. scroll and auto also scroll.

The scroll keys

Key on the focused element (or on an element inside the viewport)Scroll
ArrowUp, ArrowDownOne row.
PageUp, PageDownOne viewport height.
Home, EndTo the top, to the bottom.
ArrowLeft, ArrowRightOne column (a viewport that scrolls horizontally).

The innermost viewport that can move scrolls. A key does not scroll when the focused element uses it (a text input moves its caret, a select cycles, the arrows of a focus group move the focus). A key does not scroll when an element of the focus chain has kyo-ui's preventScrollKeys. With no focus (autoFocus = false before the first Tab), a key scrolls nothing.

The focus stays in view

When the focus moves, each viewport around the focused element scrolls the least distance that shows it. A key or a paste on the focused element keeps it in view too. A list that keeps the focus and moves a selection names the selected element with aria("activedescendant", id). The viewport follows that element, as the Todo list shows.

A viewport is a tab stop when its content does not fit and it holds no focusable element. tabIndex(-1) takes it out. A focused viewport draws its track heavy. When the content is taller than the box and the box has 3 rows or more, a scroll indicator takes the right column. scrollbarWidth(_.none) hides it.

Text

StyleWhat it does
whiteSpace(_.noWrap)Keeps one line.
whiteSpace(_.pre)Keeps the spaces and the line breaks, with no wrap.
whiteSpace(_.preWrap)Keeps the spaces and the line breaks, and wraps.
textOverflow(_.ellipsis)Puts "…" at the cut. It needs overflow(_.hidden) on the element that holds the text.

A text wraps in a column, and in a row box of a definite width. Each measure counts terminal cells: a wide character (CJK, an emoji) takes two cells, and a cut never splits it.

Copyright © 2026