tui4s
Guide

Inputs and forms

The native inputs of kyo-ui in the terminal, their bindings and their keys.
Forms.scala
import kyo.*
import kyo.UI.*
import tui4s.{Tui, TuiApp, maxLength}

/** A sign-up form with the native inputs of kyo-ui: a text input, a password, a
  * number, a range, two checkboxes, two radios, a select, a dropdown, a
  * textarea of 3 rows, a read-only and a disabled field, and the Submit and
  * Reset buttons. Tab moves between the fields. Enter in a field submits the
  * form (also in the textarea: Alt-Enter or Ctrl-J there gives a new line). The
  * result shows under the form. Esc quits.
  */
object Forms:

    /** One row of the form: a label of 9 cells and its input (a `fragment` puts
      * more inputs side by side).
      */
    def field(label: String, input: UI): UI =
      div.style(_.row.gap(1.px).align(_.start))(
        span(label).style(_.width(9.px)),
        input,
      )

    /** The form; `quit` ends the run. */
    def ui(quit: Unit < Sync): UI < Sync =
      for
          name    <- Signal.initRef("")
          secret  <- Signal.initRef("")
          age     <- Signal.initRef("30")
          volume  <- Signal.initRef(50.0)
          news    <- Signal.initRef(true)
          updates <- Signal.initRef(false)
          plan    <- Signal.initRef("free")
          colour  <- Signal.initRef("blue")
          size    <- Signal.initRef("m")
          message <- Signal.initRef("")
          result  <- Signal.initRef("Fill the form, then press Enter.")
      yield
          def yesNo(on: Boolean, what: String): String =
            if on then what else s"no $what"
          val submit: Unit < Sync                      =
            for
                n <- name.get
                s <- secret.get
                a <- age.get
                v <- volume.get
                l <- news.get
                u <- updates.get
                p <- plan.get
                c <- colour.get
                z <- size.get
                m <- message.get
                _ <- result.set(
                       s"Sent: $n, ${yesNo(s.nonEmpty, "secret")}, age $a, " +
                         s"volume ${v.toInt}, ${yesNo(l, "news")}, " +
                         s"${yesNo(u, "updates")}, $p, $c, size $z, " +
                         s"${m.linesIterator.size} message lines",
                     )
            yield ()
          // Reset gives every field its first value
          val reset: Unit < Sync                       =
            Kyo
              .foreach(Seq(name, secret, message))(_.set(""))
              .andThen(age.set("30"))
              .andThen(volume.set(50.0))
              .andThen(news.set(true))
              .andThen(updates.set(false))
              .andThen(plan.set("free"))
              .andThen(colour.set("blue"))
              .andThen(size.set("m"))
              .andThen(result.set("The form has its first values again."))
          // two radios of one group: the plan is the state; each radio shows
          // it and asks to change it
          def choice(value: String, label: String): UI =
            div.style(_.row.gap(1.px))(
              radio
                .onChange(_ => plan.set(value))
                .checked(plan.map(_ == value)),
              span(label),
            )
          div.onKeyDown {
            case KeyboardEvent(Keyboard.Escape, _, _) => quit
            case _                                    => ()
          }(
            h1("Sign up"),
            form.onSubmit(submit)(
              field("Name", input.value(name).placeholder("Ada").maxLength(20)),
              field("Secret", passwordInput.value(secret)),
              field("Age", numberInput.value(age).min(0).max(130).step(1)),
              field("Volume", rangeInput.value(volume).min(0).max(100).step(5)),
              field(
                "Mail",
                fragment(
                  checkbox.checked(news),
                  span("news"),
                  checkbox.checked(updates),
                  span("updates"),
                ),
              ),
              field(
                "Plan",
                fragment(choice("free", "Free"), choice("pro", "Pro")),
              ),
              field(
                "Colour",
                select.value(colour)(
                  option.value("blue")("Blue"),
                  option.value("green")("Green"),
                ),
              ),
              field(
                "Size",
                dropdown("s" -> "Small", "m" -> "Medium", "l" -> "Large")
                  .value(size),
              ),
              field(
                "Message",
                textarea.value(message).style(_.width(30.px).height(3.px)),
              ),
              field("Account", input.value("ada-01").readOnly(true)),
              field("Team", input.value("core").disabled(true)),
              div.style(_.row.gap(1.px))(
                button("Submit"),
                // kyo-ui has no button type: a button in a form submits it,
                // so Reset stops its click before the form
                button("Reset").onClick(reset).stopPropagation(true),
              ),
            ),
            p(result).id("result"),
          )
end Forms

/** Runs the form in the real terminal: `examples/target/bin/forms`. */
object FormsApp extends TuiApp:
    def ui: UI < (Async & Scope) = Forms.ui(Tui.quit)

The inputs are plain kyo-ui elements with their .value(ref) and .checked(ref) bindings. The only tui4s name is maxLength. Each input writes its bound ref before its onInput or onChange runs. Tab and Shift-Tab move between the inputs. Enter in a field submits the form.

The keys of each input

InputKeys
input, emailInput, telInput, urlInput, searchInputType, Backspace, Delete, ArrowLeft, ArrowRight, Home, End, Ctrl-A, Ctrl-E, Ctrl-U, Ctrl-K, Ctrl-W, by grapheme clusters. A paste is one edit. A line break in a paste becomes a space. Enter runs onChange and submits the form.
passwordInputAs input. Each grapheme cluster shows as one "•". The text does not go to the screen.
numberInputDigits, one minus sign first and one decimal point. ArrowUp and ArrowDown change the value by step (1 when none), between min and max.
textareaSee the next table.
rangeInputArrowLeft and ArrowDown, ArrowRight and ArrowUp move by step. PageDown and PageUp move by 10 steps. Home and End go to min and max.
checkbox, radioSpace and Enter toggle a checkbox and check a radio. A group of radios is one ref of the app (the plan of the example).
select, dropdownSpace, Enter, ArrowDown and ArrowUp go to the next or the previous option. Enter does not submit the form.
buttonSpace and Enter press it: a click that submits the form that holds it.

The keys of a textarea

A textarea edits lines that wrap at the spaces, by cells. Its height is the height of its style (3 rows when none).

KeyWhat it does
ArrowUp, ArrowDownMoves one line and keeps the column.
PageUp, PageDownMoves by the height of the textarea.
Home, EndGoes to the start or the end of the line.
Ctrl-A, Ctrl-EGoes to the start or the end of the text line between two line breaks.
Alt-Enter, Ctrl-JInserts a line break. Shift-Enter does too, when the terminal reports Shift with Enter.
EnterSubmits the form that holds the textarea. Outside a form, it inserts a line break.
A pasteKeeps its line breaks.

When the focus leaves the textarea after an edit that changed its text, onChange runs with the text.

Limits

  • readOnly(true) keeps the focus and the moves of the caret, and stops each edit. disabled(true) takes the input out of the Tab order and dims it.
  • kyo-ui has no maxLength attribute. maxLength(n) from tui4s limits the grapheme clusters that the keys and a paste give. A browser does not read it.
  • The terminal cursor shows the caret of the focused text input or textarea.
  • The date, time, colour and file inputs show an empty box. The log of the run has one warning for each of them.
  • A typed character outside the Basic Multilingual Plane (an emoji) arrives wrong: jatatui 0.31.0 cuts it to 16 bits. A paste keeps it.
Copyright © 2026