DEV Community

Cover image for Understanding terminal specifications to help with TUI development
Kenta Takeuchi
Kenta Takeuchi

Posted on

Understanding terminal specifications to help with TUI development

Introduction

When developing TUI (Terminal User Interface) applications, it's easy to implement using existing TUI libraries like vim or htop. However, without understanding how terminals work behind the scenes, why Raw Mode is necessary, and what ANSI escape sequences are, it becomes difficult to understand what high-level APIs are doing and to troubleshoot when problems arise.

This article explains terminal specifications and implementations necessary for TUI development from the following perspectives:

  • Basic Concepts: Clarification of terms like Terminal, Shell, TTY, termios
  • Operating Principles: Input processing by Line Discipline, screen control by escape sequences
  • Implementation Methods: Concrete implementation examples in Go (high-level API/low-level API)

By understanding terminal specifications, you can make informed decisions about TUI library selection and custom implementations, and more easily identify the root cause of problems during debugging.

Prerequisites: Terminology Clarification

To understand TUI development, you first need to understand terminal-related terminology.

Console

Refers to the physical input/output devices of a computer. Originally meant a keyboard and display directly connected to the PC itself. From an OS perspective, it's treated as "the system's primary standard I/O terminal."

Terminal

A terminal is a general term for devices or software that perform input/output to a computer. Historically, it referred to physical terminal devices, but now it mainly refers to virtual terminals implemented in software.

Terminal Emulator

An application that recreates the functionality of physical terminal devices in software. Examples include iTerm2, GNOME Terminal, Windows Terminal, and xterm. Terminal emulators pass keyboard input to programs and render output to the screen. They interpret ANSI escape sequences to perform screen control.

Major terminal emulators include:

  • macOS: Terminal.app, iTerm2
  • Linux: GNOME Terminal, Konsole, xterm
  • Windows: Windows Terminal, ConEmu

CLI (Command Line Interface)

An interface format where commands are input as text and output is also received as text. It's a concept contrasted with GUI (Graphical UI). Shells, REPLs, and TUIs are all types of CLI.

Command Line

Refers to the actual input line where a user enters a single line on the CLI. Lines like ls -l or git commit -m "msg". The shell parses this string and executes it.

Shell

A command interpretation program that receives commands and executes programs. Examples include bash, zsh, fish, and PowerShell. It runs on a terminal but is an independent process from the terminal.

The main roles of a shell are:

  • Command parsing (token splitting, redirect processing, etc.)
  • Environment variable management
  • Process launching and control (job control; a job is a set of processes)
  • Script functionality provision

TUI (Text User Interface)

A character-based interface that provides an interactive UI using the entire screen. Unlike regular CLI (shells) that execute commands line by line, TUIs control the entire screen and achieve a rich user experience using cursor movement, colors, borders, menus, forms, etc.

Differences between TUI and CLI:

Item CLI (Shell, etc.) TUI
Input method Line-based (confirmed with Enter)
Input buffer processed in canonical mode
Key-based (processed immediately when pressed)
Processed in non-canonical (raw) mode
Screen usage Text flows sequentially to standard output Entire screen (rectangular area) freely redrawn and updated
Cursor control Automatically advances to next line (basically continuous output) Can move to any position with ANSI escapes
Echo back Enabled (input characters automatically displayed) Disabled (drawn by app as needed)
Terminal mode Canonical Mode
= Line editing and signal processing enabled
Raw Mode
= Input passed directly to app
Examples bash, zsh, fish, Python REPL, etc. vim, less, htop, nmtui, etc.

POSIX (Portable Operating System Interface)

A standard specification for maintaining compatibility across Unix-like systems. Established by IEEE (Institute of Electrical and Electronics Engineers), it defines APIs for system calls, terminal control, file I/O, threads, etc. Many commands and system calls in macOS and Linux are POSIX-compliant.

POSIX-compliant OSes include:

  • Linux (Ubuntu, Debian, Red Hat, etc.)
  • macOS (Darwin)
  • BSD systems (FreeBSD, OpenBSD, etc.)
  • Solaris, AIX

Latest specification:

POSIX Terminal Interface

The standard API defined by POSIX for terminal input/output control. The termios structure and its related functions (tcgetattr(), tcsetattr(), etc.) are used to configure terminal modes, define special characters, set baud rate (data transfer speed in serial communication), etc. This standardization allows the same code to work across POSIX-compliant OSes.

Specification documentation:

Unix Terminal Interface

The traditional mechanism for terminal control in Unix-like OSes. The TTY driver manages terminal devices within the kernel, and the line discipline handles input/output processing (line editing, echo back, special character processing, etc.). POSIX is a standardization of this Unix terminal interface.

TTY (TeleTYpewriter)

TTY is a general term for terminal devices in Unix-like OSes. Originally it was a device for connecting physical teletypewriters (electric typewriters), but now it refers to terminal devices in general, including virtual terminals (PTY).

TTY Line Discipline

Line discipline is a software layer in the kernel of Unix-like OSes that sits between the TTY driver and user processes, handling terminal input/output processing.

The roles of line discipline include:

  1. Line editing functionality

    • Delete characters with Backspace
    • Delete entire line with Ctrl+U
    • Delete word with Ctrl+W
  2. Echo back

    • Automatically send input characters back to the terminal
    • Allow users to confirm their input
  3. Special character processing

    • Ctrl+C → Send SIGINT signal
    • Ctrl+Z → Send SIGTSTP signal (suspend process)
    • Ctrl+D → EOF (end of input)
  4. Character conversion

    • Newline code conversion (CR ↔ LF)
    • Uppercase/lowercase conversion (older systems)
  5. Input buffering

    • Canonical mode: Buffer line by line until Enter key
    • Non-canonical mode: Pass immediately character by character

termios

A POSIX standard terminal control structure. It manages the following settings:

  • Input mode (c_iflag): Newline conversion, flow control, etc.
  • Output mode (c_oflag): Output processing settings
  • Control mode (c_cflag): Baud rate, character size, etc.
  • Local mode (c_lflag): Echo, canonical mode, signal generation, etc.
  • Special characters (c_cc): Definition of Ctrl+C, Ctrl+Z, EOF, etc.
  • Timeout (VMIN, VTIME): Read control in non-canonical mode

In TUI development, termios is used to implement Raw Mode.

ioctl (Input/Output Control)

ioctl is a general-purpose device control system call in Unix-like OSes. Through a file descriptor, it instructs device drivers to perform special operations that cannot be done with normal read/write operations.

Main operations include:

  • Getting and setting termios
  • Getting window size

tcgetattr / tcsetattr

High-level API functions for terminal control defined in the POSIX standard. They allow writing more portable and readable code than using the ioctl system call directly.

On POSIX-compliant systems, using tcgetattr/tcsetattr is recommended. You can write code without worrying about platform differences (differences in constant names, etc.).

In Go, since there's no wrapper for tcgetattr/tcsetattr in the standard library, you have the following options:

  1. Use golang.org/x/term (high-level)
import "golang.org/x/term"

// Get current settings
oldState, err := term.GetState(fd)

// Set to Raw Mode
newState, err := term.MakeRaw(fd)

// Restore
err := term.Restore(fd, oldState)
Enter fullscreen mode Exit fullscreen mode
  1. Use golang.org/x/sys/unix directly with ioctl (low-level)
import "golang.org/x/sys/unix"

// Equivalent to tcgetattr
termios, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)

// Equivalent to tcsetattr
err := unix.IoctlSetTermios(fd, unix.TIOCSETA, termios)
Enter fullscreen mode Exit fullscreen mode

The golang.org/x/term package internally uses ioctl from golang.org/x/sys/unix and abstracts away platform differences.

PTY (Pseudo Terminal)

A virtual terminal device used by terminal emulators. In practice, two device files operate as a pair:

  • Master side: The side operated by the terminal emulator
  • Slave side: The side where shells and TUI apps connect

In POSIX.1-2024 (IEEE Std 1003.1-2024), the master side is called "manager" and the slave side is called "subsidiary."

Since shells and TUI apps treat the slave side as a "real terminal," they can use the same API (termios, etc.) as physical terminals. This means applications don't need to be aware of whether it's a physical or virtual terminal.

Examples of device files:

  • Linux: /dev/pts/0, /dev/pts/1...
  • macOS: /dev/ttys000, /dev/ttys001...

ANSI Escape Sequence

Special string commands for controlling terminal display. Control codes beginning with the ESC character (\x1b or \033) that instruct cursor movement, color changes, screen clearing, etc.

Main control sequences include:

  • \x1b[31m: Change to red text
  • \x1b[2J: Clear screen
  • \x1b[H: Move cursor to top-left (1,1)
  • \x1b[10;20H: Move cursor to row 10, column 20

In TUI development, these sequences are output directly to achieve screen rendering, partial updates, and coloring. Standardized as ANSI X3.64 and implemented in VT100 terminals, leading to widespread adoption.

Terminal I/O Flow

flowchart TB
    U[ユーザー] --> TERM[ターミナルエミュレータ(iTerm2, xterm など)]
    TERM --> PTY[仮想端末 (PTY master <-> slave)]
    PTY --> TTY[TTY + Line discipline (ICANON / ECHO / ISIG)]
    TTY --> APP[TUIアプリ(bash, vim, htop, etc.)]
    APP -->|termios設定変更| TTY
    APP -->|出力 (ANSIエスケープ)| TERM
    TERM -->|描画| U
Enter fullscreen mode Exit fullscreen mode

Terminal Behavior to Control in TUI Development

TUI (Text User Interface) applications don't interpret commands like shells do, but instead directly handle terminal (TTY) I/O control.

Specifically, they use the termios API to change terminal mode settings (e.g., disable canonical mode, disable echo, etc.) and use ANSI escape sequences to render and update the entire screen.

In a normal shell environment, the terminal is set to canonical mode (ICANON), where user input is buffered line by line and passed to the program after confirmation with Enter key.

$ stty -a | grep icanon
lflags: icanon isig iexten echo echoe echok echoke -echonl echoctl # Canonical mode enabled
Enter fullscreen mode Exit fullscreen mode

Therefore, characters being typed are automatically echoed to the screen and sent to the shell when Enter is pressed:

$ ls
example.txt
Enter fullscreen mode Exit fullscreen mode

On the other hand, TUI applications like vim or less switch the terminal to non-canonical mode.

This allows key input to be passed to the application immediately, character by character, and the application handles its own input processing and screen rendering.

$ vim

# Execute in another terminal
| $ ps aux                 | grep vim # Check vim's terminal |
| $ stty -a < /dev/ttys049 | grep icanon                     |
lflags: -icanon -isig -iexten -echo -echoe echok echoke -echonl echoctl # Canonical mode disabled
Enter fullscreen mode Exit fullscreen mode

Overall Picture of Knowledge Needed for TUI Development

To create a TUI app, you need to understand and control the following technical elements:

  1. Terminal mode settings
  2. Input processing
  3. Screen control
  4. Terminal size management
  5. Buffering

In the following sections, we'll explain specifications and implementation methods for these technical elements in detail.

Terminal Mode Settings

The line discipline of a terminal (TTY) is a software layer in the kernel that controls how input and output are handled. There are mainly three operating modes (Canonical/Non-Canonical/Raw), which can be switched by setting flags in the termios structure.

All these modes are implemented by the TTY line discipline in the kernel. The termios and stty commands are interfaces for changing this line discipline's settings.

Canonical Mode (Cooked Mode)

The normal terminal input mode and the terminal's default setting. It has line editing functionality, input is buffered line by line, and passed to the application after confirmation with Enter key.

Main characteristics:

  • Line-based input buffering
  • Line editing functionality (Backspace, Ctrl+U, Ctrl+W, etc.)
  • Echo back (automatic display of input characters)
  • Special character processing (Ctrl+C, Ctrl+Z, Ctrl+D, etc.)
  • Newline code conversion (\r\n)

Use cases:

  • Normal shell operations (bash, zsh, etc.)
  • Interactive programs

Cooked Mode is another name for Canonical Mode.

Non-Canonical Mode

A mode with ICANON disabled.

Line editing functionality is provided by the TTY line discipline in the kernel, but in non-canonical mode this processing is disabled and must be handled by the application.

Main characteristics:

  • Immediate input character by character
  • No line editing functionality (Backspace is just a character)
  • Echo and signal processing can optionally remain enabled

Use cases:

  • Input with timeout
  • CLI tools with custom input control

Since non-canonical mode alone may still have echo or signal processing enabled, use Raw mode if you want complete control.

Raw Mode

A mode that almost completely disables terminal input/output conversion and control. Commonly used in TUI applications.

Main characteristics:

  • Disables all processing including special characters and newline conversion
  • Input is passed to the application as a byte stream as-is
  • No echo
  • No signal generation (Ctrl+C treated as character)

Use cases:

  • TUI apps (vim, less, htop, etc.)
  • Games, custom screen control tools

stty's -cooked is synonymous with raw, and Raw Mode is treated as the opposite of Cooked Mode.

Mode Comparison Table

Item Canonical (Cooked) Non-Canonical Raw
Input buffering Line-based Character-based Character-based
Line editing Enabled Disabled Disabled
Echo back Enabled Configurable* Disabled
Special chars (signals) Enabled Configurable* Disabled
Newline conversion Enabled Configurable* Disabled
Main uses Shell, interactive input Custom CLI TUI, games

Input Processing

Input processing involves analyzing "what key was pressed."

You need to process input other than normal characters, such as arrow keys, function keys, and mouse events. These are sent as multi-byte escape sequences.

In TUI apps, you need to parse these escape sequences and perform appropriate operations.

Special Key Escape Sequences

Key Sequence Byte Sequence
ESC[A \x1b[A
ESC[B \x1b[B
ESC[C \x1b[C
ESC[D \x1b[D
Home ESC[H \x1b[H
End ESC[F \x1b[F
Page Up ESC[5~ \x1b[5~
Page Down ESC[6~ \x1b[6~
F1-F4 ESC[OP-ESC[OS \x1b[OP, etc.

Control Characters

Character ASCII Description
Ctrl+C 3 SIGINT
Ctrl+D 4 EOF
Ctrl+Z 26 SIGTSTP
Enter 13 (CR) / 10 (LF) Newline
Tab 9 Tab
Backspace 127 (DEL) / 8 (BS) Backspace
ESC 27 Escape

Screen Control (ANSI Escape Sequences)

Screen control instructs "what to display where."

You need to perform all operations necessary for TUI rendering, such as moving the cursor to any position on the screen, changing colors, and clearing the screen.

Main control sequences are as follows.

Cursor Control

Sequence Description
ESC[H Move cursor to home position (1,1)
ESC[{row};{col}H Move to specified position (1-indexed)
ESC[{n}A Move n rows up
ESC[{n}B Move n rows down
ESC[{n}C Move n columns right
ESC[{n}D Move n columns left
ESC[s Save cursor position
ESC[u Restore cursor position
ESC[?25l Hide cursor
ESC[?25h Show cursor

Screen Clear

Sequence Description
ESC[2J Clear entire screen
ESC[H Move cursor to home
ESC[K Clear from cursor to end of line
ESC[1K Clear from start of line to cursor
ESC[2K Clear entire line

Colors and Styles

Basic Styles

Sequence Description
ESC[0m Reset all attributes
ESC[1m Bold
ESC[4m Underline
ESC[7m Reverse

Foreground Color (Text Color)

Sequence Color
ESC[30m Black
ESC[31m Red
ESC[32m Green
ESC[33m Yellow
ESC[34m Blue
ESC[35m Magenta
ESC[36m Cyan
ESC[37m White

Background Color

Sequence Color
ESC[40m Black
ESC[41m Red
ESC[42m Green
ESC[43m Yellow
ESC[44m Blue
ESC[45m Magenta
ESC[46m Cyan
ESC[47m White

Extended Color Mode

Sequence Description
ESC[38;5;{n}m Foreground (n: 0-255)
ESC[48;5;{n}m Background (n: 0-255)
ESC[38;2;{r};{g};{b}m Foreground (RGB True Color)
ESC[48;2;{r};{g};{b}m Background (RGB True Color)

Alternate Screen Buffer

Sequence Description
ESC[?1049h Switch to alternate screen buffer (used by vim/less, etc.)
ESC[?1049l Return to normal screen buffer

Terminal Size Management

TUI apps need to render according to the terminal size. They also need to redraw when users change the window size.

Terminal size is managed by the winsize structure held in the kernel's TTY structure. When the size changes, the terminal emulator notifies via ioctl(TIOCSWINSZ), and the kernel sends SIGWINCH to connected processes.

sequenceDiagram
    participant User as User
    participant Term as Terminal Emulator (iTerm2, xterm, etc.)
    participant PTY as PTY (master ↔ slave)
    participant Kernel as Kernel TTY Layer
    participant Proc as Process (bash, vim, less, etc.)

    User->>Term: Resize window
    Term->>PTY: ioctl(TIOCSWINSZ) (notify new rows/cols)
    PTY->>Kernel: Update PTY slave winsize
    Kernel->>Proc: Send SIGWINCH signal
    Proc->>Proc: Get new size with ioctl(TIOCGWINSZ) and redraw
Enter fullscreen mode Exit fullscreen mode

Buffering

When sending many control sequences, sending them one by one is slow. By buffering and flushing all at once, you can prevent screen flicker and improve performance.


These are the five elements of terminal control needed for TUI development. In the next section, we'll see how to actually implement these in Go.

Learning TUI Implementation in Go

Here we'll explain how to actually implement the five technical elements explained in the previous section in Go.

Through a simple ~230-line implementation example using the golang.org/x/term package (high-level API), you can learn the basics of TUI development.

The implementation considers each technical element to make it easy to learn the overall picture of knowledge needed for terminals.

Implemented Features

1. Terminal Mode Settings

  • Set to Raw Mode with term.MakeRaw()
  • Save and restore settings with term.GetState()/term.Restore()
  • Always restore to original state on program exit

2. Input Processing

  • Read key input byte by byte
  • Parse escape sequences to recognize arrow keys
  • Process control characters like Ctrl+C

3. Screen Control (ANSI Escape Sequences)

  • Cursor movement (\033[{row};{col}H)
  • Screen clear (\033[2J\033[H)
  • Color setting (foreground color)

4. Terminal Size Management

  • Get current size with unix.IoctlGetWinsize()
  • Detect window size changes with SIGWINCH signal

5. Buffering

  • Buffer output with bufio.Writer
  • Accumulate multiple drawing operations in buffer
  • Prevent flicker by reflecting to screen all at once with Flush()

Implementation

package main

import (
    "bufio"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"

    "golang.org/x/term"
)

// Structure to manage terminal state
type Terminal struct {
    fd       int
    oldState *term.State
    width    int
    height   int
    writer   *bufio.Writer
}

// 1. Terminal mode settings
func NewTerminal() (*Terminal, error) {
    fd := int(os.Stdin.Fd())

    // Save current settings
    oldState, err := term.GetState(fd)
    if err != nil {
        return nil, err
    }

    // Set to Raw Mode
    _, err = term.MakeRaw(fd)
    if err != nil {
        return nil, err
    }

    // Get initial size
    width, height := getTerminalSize(fd)

    return &Terminal{
        fd:       fd,
        oldState: oldState,
        width:    width,
        height:   height,
        writer:   bufio.NewWriter(os.Stdout),
    }, nil
}

// Restore to original state on exit
func (t *Terminal) Restore() {
    t.writer.WriteString("\033[?25h") // Show cursor
    t.writer.WriteString("\033[0m")   // Reset color
    t.writer.Flush()
    term.Restore(t.fd, t.oldState)
}

// 4. Terminal size management
func getTerminalSize(fd int) (width, height int) {
    width, height, err := term.GetSize(fd)
    if err != nil {
        return 80, 24 // Default values
    }
    return width, height
}

func (t *Terminal) UpdateSize() {
    t.width, t.height = getTerminalSize(t.fd)
}

// 2. Input processing
func (t *Terminal) ReadKey() (rune, string, error) {
    buf := make([]byte, 1)
    _, err := os.Stdin.Read(buf)
    if err != nil {
        return 0, "", err
    }

    // Ctrl+C
    if buf[0] == 3 {
        return 0, "CTRL_C", nil
    }

    // ESC key (escape sequences like arrow keys)
    if buf[0] == 27 {
        // Wait a bit and check if there are more bytes
        seq := make([]byte, 2)
        os.Stdin.Read(seq)

        if seq[0] == '[' {
            switch seq[1] {
            case 'A':
                return 0, "UP", nil
            case 'B':
                return 0, "DOWN", nil
            case 'C':
                return 0, "RIGHT", nil
            case 'D':
                return 0, "LEFT", nil
            }
        }
        return 0, "ESC", nil
    }

    // Normal character
    return rune(buf[0]), "", nil
}

// 3. Screen control (ANSI Escape Sequences)
func (t *Terminal) Clear() {
    t.writer.WriteString("\033[2J\033[H")
}

func (t *Terminal) MoveTo(row, col int) {
    t.writer.WriteString(fmt.Sprintf("\033[%d;%dH", row, col))
}

func (t *Terminal) SetColor(fg int) {
    t.writer.WriteString(fmt.Sprintf("\033[%dm", fg))
}

func (t *Terminal) Write(s string) {
    t.writer.WriteString(s)
}

// 5. Buffering
func (t *Terminal) Flush() {
    t.writer.Flush()
}

func main() {
    // Terminal check
    if !term.IsTerminal(int(os.Stdin.Fd())) {
        fmt.Fprintln(os.Stderr, "Error: Must be run in an interactive terminal")
        os.Exit(1)
    }

    // Initialize
    term, err := NewTerminal()
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to initialize: %v\n", err)
        os.Exit(1)
    }
    defer term.Restore()

    // Detect window size changes
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGWINCH)
    go func() {
        for range sigCh {
            term.UpdateSize()
        }
    }()

    // Cursor position
    x, y := term.width/2, term.height/2

    // Main loop
    for {
        // Clear screen
        term.Clear()

        // Title
        term.MoveTo(1, term.width/2-10)
        term.SetColor(36) // Cyan
        term.Write("TUI Demo (press 'q' to quit)")

        // Display information
        term.MoveTo(3, 2)
        term.SetColor(33) // Yellow
        term.Write(fmt.Sprintf("Terminal Size: %dx%d", term.width, term.height))

        term.MoveTo(4, 2)
        term.Write(fmt.Sprintf("Cursor Position: (%d, %d)", x, y))

        // Draw frame
        for row := 5; row < term.height-1; row++ {
            term.MoveTo(row, 1)
            term.SetColor(34) // Blue
            term.Write("|")
            term.MoveTo(row, term.width)
            term.Write("|")
        }

        // Display cursor (marker)
        term.MoveTo(y, x)
        term.SetColor(32) // Green
        term.Write("●")

        // Instructions
        term.MoveTo(term.height, 2)
        term.SetColor(37) // White
        term.Write("Arrow keys: move | q: quit")

        // Flush buffer (reflect to screen all at once)
        term.Flush()

        // Wait for key input
        ch, key, err := term.ReadKey()
        if err != nil {
            break
        }

        // Process key
        switch key {
        case "CTRL_C":
            return
        case "UP":
            if y > 5 {
                y--
            }
        case "DOWN":
            if y < term.height-1 {
                y++
            }
        case "LEFT":
            if x > 2 {
                x--
            }
        case "RIGHT":
            if x < term.width-1 {
                x++
            }
        }

        if ch == 'q' || ch == 'Q' {
            return
        }

        // Wait a bit (to detect resize events)
        time.Sleep(50 * time.Millisecond)
    }
}
Enter fullscreen mode Exit fullscreen mode

Execution

go run main.go
Enter fullscreen mode Exit fullscreen mode

Operation instructions:

  • Arrow keys: Move cursor (●)
  • q: Quit
  • Ctrl+C: Quit
  • Window resize: Automatically detected (reflected on next key input)

Implementation Details

Terminal Structure

type Terminal struct {
    fd       int // File descriptor
    oldState *term.State // Original terminal settings
    width    int // Terminal width
    height   int // Terminal height
    writer   *bufio.Writer // Buffered Writer
}
Enter fullscreen mode Exit fullscreen mode

The Terminal structure manages the terminal's state.

fd is a file descriptor that points to the file being operated on.

In Unix-like OSes, I/O (files, terminals, sockets, etc.) is managed by integer identification numbers.

Here are the standard file descriptor numbers and names:

Number Name Description
0 stdin Standard input (keyboard input)
1 stdout Standard output (screen output)
2 stderr Standard error output

The file descriptor for standard input can be obtained as follows:

fd := int(os.Stdin.Fd()) // Get stdin's file descriptor
Enter fullscreen mode Exit fullscreen mode

You can use fd to manipulate terminal settings:

// Get current terminal settings
term.GetState(fd)

// Set to Raw Mode
term.MakeRaw(fd)

// Get terminal size
term.GetSize(fd)
Enter fullscreen mode Exit fullscreen mode

Raw Mode Settings

Raw Mode is set as follows:

oldState, _ := term.GetState(fd)
term.MakeRaw(fd)
defer term.Restore(fd, oldState)
Enter fullscreen mode Exit fullscreen mode

When setting Raw Mode, if you don't restore to the original state using term.Restore(), it won't return to the original state when the program exits.

Buffering Optimization

writer := bufio.NewWriter(os.Stdout)
writer.WriteString("...") // Accumulate multiple draws in buffer
writer.Flush() // Reflect to screen all at once to prevent flicker
Enter fullscreen mode Exit fullscreen mode

When buffering, flush the buffer using writer.Flush().

Size Change Detection with SIGWINCH

SIGWINCH is a signal sent when the window size changes.

When receiving SIGWINCH, call term.UpdateSize() to update the window size.

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGWINCH)
go func() {
    for range sigCh {
        term.UpdateSize()
    }
}()
Enter fullscreen mode Exit fullscreen mode

Lower-Level Implementation: Using golang.org/x/sys/unix

The golang.org/x/term package internally uses golang.org/x/sys/unix. Here we'll look at a lower-level implementation that directly manipulates each flag of termios.

In this implementation, you can learn:

  • Direct manipulation of each flag in the termios structure (Iflag, Oflag, Lflag, Cflag, Cc)
  • Using the ioctl system call (IoctlGetTermios/IoctlSetTermios)
  • API calls equivalent to tcgetattr/tcsetattr

By comparing with the high-level implementation, you can understand what golang.org/x/term is doing internally.

package main

import (
    "bufio"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"

    "golang.org/x/sys/unix"
)

// Low-level implementation that directly manipulates termios
// Can learn in a form close to golang.org/x/term's internal implementation

// Structure to manage terminal state
type Terminal struct {
    fd       int
    oldState unix.Termios // Hold termios structure directly
    width    int
    height   int
    writer   *bufio.Writer
}

// 1. Terminal mode settings (low-level)
func NewTerminal() (*Terminal, error) {
    fd := int(os.Stdin.Fd())

    // Equivalent to tcgetattr: Get current termios settings
    oldState, err := unix.IoctlGetTermios(fd, unix.TIOCGETA)
    if err != nil {
        return nil, fmt.Errorf("failed to get termios: %w", err)
    }

    // Create Raw Mode settings
    newState := *oldState

    // Input flags (c_iflag)
    newState.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK |
        unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON

    // Output flags (c_oflag)
    newState.Oflag &^= unix.OPOST

    // Local flags (c_lflag)
    newState.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON |
        unix.ISIG | unix.IEXTEN

    // Control flags (c_cflag)
    newState.Cflag &^= unix.CSIZE | unix.PARENB
    newState.Cflag |= unix.CS8

    // Control characters (c_cc)
    newState.Cc[unix.VMIN] = 1  // Read at least 1 byte
    newState.Cc[unix.VTIME] = 0 // No timeout

    // Equivalent to tcsetattr: Apply new settings
    if err := unix.IoctlSetTermios(fd, unix.TIOCSETA, &newState); err != nil {
        return nil, fmt.Errorf("failed to set raw mode: %w", err)
    }

    // Get initial size
    width, height := getTerminalSize(fd)

    return &Terminal{
        fd:       fd,
        oldState: *oldState,
        width:    width,
        height:   height,
        writer:   bufio.NewWriter(os.Stdout),
    }, nil
}

// Restore to original state on exit
func (t *Terminal) Restore() {
    t.writer.WriteString("\033[?25h") // Show cursor
    t.writer.WriteString("\033[0m")   // Reset color
    t.writer.Flush()

    // Equivalent to tcsetattr: Restore to original settings
    unix.IoctlSetTermios(t.fd, unix.TIOCSETA, &t.oldState)
}

// 4. Terminal size management (direct ioctl call)
func getTerminalSize(fd int) (width, height int) {
    // Get window size with TIOCGWINSZ ioctl
    ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
    if err != nil {
        return 80, 24 // Default values
    }
    return int(ws.Col), int(ws.Row)
}

func (t *Terminal) UpdateSize() {
    t.width, t.height = getTerminalSize(t.fd)
}

// 2. Input processing
func (t *Terminal) ReadKey() (rune, string, error) {
    buf := make([]byte, 1)
    _, err := unix.Read(t.fd, buf) // Use system call directly
    if err != nil {
        return 0, "", err
    }

    // Ctrl+C
    if buf[0] == 3 {
        return 0, "CTRL_C", nil
    }

    // ESC key (escape sequences like arrow keys)
    if buf[0] == 27 {
        // Wait a bit and check if there are more bytes
        seq := make([]byte, 2)
        unix.Read(t.fd, seq)

        if seq[0] == '[' {
            switch seq[1] {
            case 'A':
                return 0, "UP", nil
            case 'B':
                return 0, "DOWN", nil
            case 'C':
                return 0, "RIGHT", nil
            case 'D':
                return 0, "LEFT", nil
            }
        }
        return 0, "ESC", nil
    }

    // Normal character
    return rune(buf[0]), "", nil
}

// 3. Screen control (ANSI Escape Sequences)
func (t *Terminal) Clear() {
    t.writer.WriteString("\033[2J\033[H")
}

func (t *Terminal) MoveTo(row, col int) {
    t.writer.WriteString(fmt.Sprintf("\033[%d;%dH", row, col))
}

func (t *Terminal) SetColor(fg int) {
    t.writer.WriteString(fmt.Sprintf("\033[%dm", fg))
}

func (t *Terminal) Write(s string) {
    t.writer.WriteString(s)
}

// 5. Buffering
func (t *Terminal) Flush() {
    t.writer.Flush()
}

func main() {
    // Initialize
    term, err := NewTerminal()
    if err != nil {
        fmt.Fprintf(os.Stderr, "Failed to initialize: %v\n", err)
        os.Exit(1)
    }
    defer term.Restore()

    // Detect window size changes (SIGWINCH)
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGWINCH)
    go func() {
        for range sigCh {
            term.UpdateSize()
        }
    }()

    // Cursor position
    x, y := term.width/2, term.height/2

    // Main loop
    for {
        // Clear screen
        term.Clear()

        // Title
        term.MoveTo(1, term.width/2-15)
        term.SetColor(36) // Cyan
        term.Write("TUI Demo (Low-level termios API)")

        // termios settings explanation
        term.MoveTo(3, 2)
        term.SetColor(33) // Yellow
        term.Write("Using unix.IoctlGetTermios/IoctlSetTermios")

        term.MoveTo(4, 2)
        term.Write(fmt.Sprintf("Terminal Size: %dx%d", term.width, term.height))

        term.MoveTo(5, 2)
        term.Write(fmt.Sprintf("Cursor Position: (%d, %d)", x, y))

        // Explanation of configured flags
        term.MoveTo(7, 2)
        term.SetColor(37) // White
        term.Write("Raw Mode flags:")
        term.MoveTo(8, 4)
        term.Write("- ICANON off: Line buffering disabled")
        term.MoveTo(9, 4)
        term.Write("- ECHO off: Echo back disabled")
        term.MoveTo(10, 4)
        term.Write("- ISIG off: Signal generation disabled")
        term.MoveTo(11, 4)
        term.Write("- VMIN=1, VTIME=0: Read 1 byte immediately")

        // Draw frame
        for row := 13; row < term.height-1; row++ {
            term.MoveTo(row, 1)
            term.SetColor(34) // Blue
            term.Write("|")
            term.MoveTo(row, term.width)
            term.Write("|")
        }

        // Display cursor (marker)
        term.MoveTo(y, x)
        term.SetColor(32) // Green
        term.Write("●")

        // Instructions
        term.MoveTo(term.height, 2)
        term.SetColor(37) // White
        term.Write("Arrow keys: move | q: quit")

        // Flush buffer (reflect to screen all at once)
        term.Flush()

        // Wait for key input
        ch, key, err := term.ReadKey()
        if err != nil {
            break
        }

        // Process key
        switch key {
        case "CTRL_C":
            return
        case "UP":
            if y > 13 {
                y--
            }
        case "DOWN":
            if y < term.height-1 {
                y++
            }
        case "LEFT":
            if x > 2 {
                x--
            }
        case "RIGHT":
            if x < term.width-1 {
                x++
            }
        }

        if ch == 'q' || ch == 'Q' {
            return
        }

        // Wait a bit (to detect resize events)
        time.Sleep(50 * time.Millisecond)
    }
}
Enter fullscreen mode Exit fullscreen mode

With this low-level implementation, you can understand:

  • What each flag in termios specifically controls
  • How POSIX standard's tcgetattr/tcsetattr are implemented in Go
  • Actual usage of the ioctl system call

By understanding both high-level implementation (golang.org/x/term) and low-level implementation (golang.org/x/sys/unix), you can see the complete picture of terminal control.

Summary

In this article, we explained terminal specifications necessary for TUI development, from terminology clarification to implementation.

What We Learned

  1. Organizing terminology and concepts

    • Relationships between Terminal, Shell, TTY, Line Discipline, termios, etc.
    • Position of POSIX and Unix terminal interface
  2. Five elements of TUI development

    • Terminal mode settings (Canonical/Non-Canonical/Raw)
    • Input processing (parsing escape sequences)
    • Screen control (ANSI escape sequences)
    • Terminal size management (SIGWINCH)
    • Buffering (preventing flicker)
  3. Understanding implementation

    • Using high-level API (golang.org/x/term)
    • Direct termios manipulation with low-level API (golang.org/x/sys/unix)
    • Relationship between tcgetattr/tcsetattr and ioctl

Promotion

I'm developing ggc, a TUI/CLI tool for git. The motivation for learning about terminals came from developing this application.

Please give it a star if you like!

References

Specifications and Standards

Wikipedia

Tutorials and Implementations

Top comments (0)