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.1-2024 (IEEE Std 1003.1-2024)
- Online version
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:
-
Line editing functionality
- Delete characters with Backspace
- Delete entire line with Ctrl+U
- Delete word with Ctrl+W
-
Echo back
- Automatically send input characters back to the terminal
- Allow users to confirm their input
-
Special character processing
- Ctrl+C → Send SIGINT signal
- Ctrl+Z → Send SIGTSTP signal (suspend process)
- Ctrl+D → EOF (end of input)
-
Character conversion
- Newline code conversion (CR ↔ LF)
- Uppercase/lowercase conversion (older systems)
-
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:
- 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)
- Use
golang.org/x/sys/unix
directly withioctl
(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)
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
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
Therefore, characters being typed are automatically echoed to the screen and sent to the shell when Enter is pressed:
$ ls
example.txt
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
Overall Picture of Knowledge Needed for TUI Development
To create a TUI app, you need to understand and control the following technical elements:
- Terminal mode settings
- Input processing
- Screen control
- Terminal size management
- 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
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)
}
}
Execution
go run main.go
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
}
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
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)
Raw Mode Settings
Raw Mode is set as follows:
oldState, _ := term.GetState(fd)
term.MakeRaw(fd)
defer term.Restore(fd, oldState)
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
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()
}
}()
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)
}
}
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
-
Organizing terminology and concepts
- Relationships between Terminal, Shell, TTY, Line Discipline, termios, etc.
- Position of POSIX and Unix terminal interface
-
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)
-
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
andioctl
- Using high-level API (
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
- POSIX.1-2024 - General Terminal Interface
- termios(3) - Linux manual page
- TTY Line Discipline - Linux Kernel Documentation
Wikipedia
- Computer terminal
- Terminal emulator
- Text-based user interface
- POSIX terminal interface
- Seventh Edition Unix terminal interface
- Pseudoterminal
- ANSI escape code
Top comments (0)