tty

package
v0.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: GPL-3.0 Imports: 13 Imported by: 0

Documentation

Overview

Package tty provides terminal-agnostic primitives: scroll regions, file-descriptor classification (TTY vs pipe vs regular file), and the picker. Terminal status and dimensions are read through the Probe interface, which callers inject, so tests can answer both without opening a pty.

Index

Constants

View Source
const SelectMark = "\u25b8"

SelectMark is the glyph every interactive surface uses to show which row a keypress or a click will act on.

One constant rather than one per surface: the picker and the run's failure band are two lists a reader learns to drive the same way, and they were marking the current row with different characters - so the same gesture looked like a different affordance depending on which one was open. A ">" is a keyboard character standing in for a pointer; a triangle IS one, and it belongs to the same geometric family as the pool gauge's squares.

The glyph only. Each surface pads it to whatever its own row shape needs.

Variables

View Source
var ErrAborted = errors.New("picker: aborted")

ErrAborted is returned when the user presses ESC, Ctrl-C, or Ctrl-D.

View Source
var ErrNotATerminal = errors.New("tty: input needs a terminal on both standard input and the display")

ErrNotATerminal is returned by OpenInput when either end is not a terminal. It is an ordinary answer rather than a failure: a caller checks it and skips the interactive step.

Functions

func CanRender added in v0.4.0

func CanRender(w io.Writer, p Probe) bool

CanRender reports whether escape sequences may be written to w at all: it must be a terminal, and TERM must not declare one that understands none.

TERM=dumb is not a hypothetical. Emacs shell-mode sets it, and the pty behind it IS a terminal - so a descriptor check alone says yes and the cursor addressing, scroll margins and color all go out to something that will render them as literal garbage. That is the artifacting this whole package is supposed to be incapable of.

This is the gate for anything that MOVES the cursor or reserves rows. WantsColor adds NO_COLOR on top for the narrower question of styling.

func ClearScreen added in v0.4.0

func ClearScreen(w io.Writer) error

ClearScreen erases the screen and homes the cursor, the repaint a full-screen refresh loop issues before redrawing (`magus status --watch`).

This is not the alternate screen buffer: scrollback is preserved, so a user who scrolls up after quitting still sees what came before. That restraint is deliberate and is the same rule [region] follows.

func ClipBytes added in v0.4.0

func ClipBytes(msg string, nBytes int) string

ClipBytes returns msg shortened to fit nBytes, ending in an ellipsis when truncation happened.

nBytes bounds the whole result, ellipsis included, and is a BYTE budget: exact for the ASCII this package emits and conservative for anything else. Already styled text needs ClipCols instead, or escape bytes eat the budget and the cut lands inside a sequence.

Truncation never splits a UTF-8 sequence: a rune straddling the cut is dropped whole.

func ClipCols added in v0.4.0

func ClipCols(s string, cols int) string

ClipCols shortens s to cols DISPLAY COLUMNS, counting only what the reader can see and never cutting inside an escape sequence.

[Clip] counts bytes, which is exact for the plain text it was written for and catastrophic for text that is already styled. A single colored cell is one column and about fifteen bytes, so a byte budget cuts a colored row long before it is actually too wide - in the middle of an escape - after which the terminal reads the following text as parameters and swallows it. That is a corrupted screen, repainted several times a second.

Escape sequences are copied through whole and cost nothing against the budget, so the styling of the part that survives is preserved along with any reset that closes it.

func Colorize added in v0.4.0

func Colorize(s string, sgr SGR) string

Colorize wraps s in an SGR sequence and closes it again, so no caller composes escapes by hand or forgets the reset. An empty sgr returns s untouched, which lets a caller pass a color it computed conditionally without branching.

Callers that already know output is not a terminal should skip this entirely rather than pass an empty code; see WantsColor.

func Cols added in v0.4.0

func Cols(s string) int

Cols is the display width of s, escape sequences excluded.

Exported because internal/cache composes rows for this package's layout, so it has to measure them the same way - and measuring them its own way is exactly how this bug reached four copies.

func Copy added in v0.4.0

func Copy(w io.Writer, text string) (sent int, err error)

Copy puts text on the SYSTEM clipboard using OSC 52, reporting whether it was attempted and how much was sent.

This exists because a two-column band cannot be drag-selected cleanly and no arrangement of it can be: terminals select LINEARLY, so a drag down one column takes the other column, the divider and the frame with it. Rectangular select exists but is modifier-gated, absent from several terminals, and something a reader has to already know.

So magus does not ask anyone to select. A keypress puts the exact text on the clipboard, with no frame, no padding, no divider and no escape sequences - and OSC 52 travels through ssh and tmux, which is where a build actually runs.

Only ever on an explicit keystroke. A tool that wrote the clipboard on its own would clobber whatever the reader had copied, which is hostile.

func EraseLines added in v0.4.0

func EraseLines(w io.Writer, n int) error

EraseLines erases n lines ending at the cursor and leaves the cursor at column 0 of the topmost erased line, the redraw step for an in-place list that grows and shrinks (the picker).

n <= 0 writes nothing, so a first paint needs no special case.

func Fd added in v0.4.0

func Fd(w io.Writer) (uintptr, bool)

Fd returns the file descriptor backing w, and whether w has one at all. A bytes.Buffer, an io.Pipe writer, and a network connection all report false.

The boolean is not decoration: descriptor 0 is stdin, a perfectly real terminal, so a lone uintptr has no spare value to mean "none". Returning the two separately keeps a caller from probing stdin when it meant to ask about a buffer.

func Hyperlink(text, uri string) string

Hyperlink makes text a real clickable link to uri, for terminals that support OSC 8 - iTerm2, kitty, VTE, WezTerm, Windows Terminal - and returns text unchanged for everything else.

It is worth having where a mouse cannot reach: the reserved zone can be hit-tested because magus knows where it drew, but the SCROLLING transcript belongs to the terminal, and a click there is the user selecting text. A hyperlink is the only way to make something up there actionable, and it needs no mouse capture at all, survives into scrollback, and stays copy-pasteable as plain text.

Callers gate on WantsHyperlinks first; this composes the sequence and does not decide whether to.

func IsTerminalReader added in v0.4.0

func IsTerminalReader(r *os.File, p Probe) bool

IsTerminalReader reports whether r is a terminal the user is typing at, rather than a pipe, a file, or /dev/null. Callers use it to fail fast with a clear message instead of blocking on a read that will never see input.

Takes a Probe like every other predicate here, so a test can answer it without a pty. It was the only one that could not be redirected.

func IsTerminalWriter added in v0.4.0

func IsTerminalWriter(w io.Writer, p Probe) bool

IsTerminalWriter reports whether w is a terminal according to p. It is the writer-shaped form of Probe.IsTerminal: a writer with no descriptor is never a terminal, which is the check every caller would otherwise hand-roll around Fd.

func MakeRaw added in v0.4.0

func MakeRaw(fd uintptr) (restore func() error, err error)

MakeRaw switches the terminal behind fd into raw mode, so a reader receives each keystroke as it is typed rather than a line at a time, and returns the function that puts the terminal back.

Restoring is not optional: a process that exits from raw mode leaves the user's shell without echo, which looks like a hung terminal. Callers defer the returned function on every path.

It is returned as a closure rather than an opaque state value so a caller cannot restore the wrong descriptor, and so the "how" of restoring lives here instead of at each call site.

func Pick

func Pick(ctx context.Context, in *os.File, out io.Writer, p Probe, items []string, opts PickOptions) (int, error)

Pick blocks until the user selects an item or aborts. On success it returns the index into the original items slice. On abort it returns -1 and ErrAborted.

Takes its descriptors and probe like every other entry point in this package rather than reaching for os.Stdin and os.Stderr itself. It was the one interactive surface a test could not redirect, which is exactly backwards for the one that reads keys.

func Prose added in v0.4.0

func Prose(w io.Writer, p Probe, sentences ...string)

Prose writes sentences to w as one paragraph folded to w's width.

It takes the sentences separately so the source carries one per line and a grep for any of them matches. Hand-wrapping a paragraph across consecutive Fprintln calls splits it at whatever column the author's editor was showing, which fixes the line breaks to one terminal width and leaves no whole sentence to search for.

func ProseItem added in v0.4.0

func ProseItem(w io.Writer, p Probe, label string, sentences ...string)

ProseItem writes label and its sentences as one paragraph whose continuation lines hang under the label, the shape a flag or subcommand list wants.

label carries its own padding, so alignment across a list stays the caller's to choose and the indent is whatever the label occupies.

func ReleaseStderr added in v0.4.0

func ReleaseStderr() error

ReleaseStderr stops the process's notification band and hands back every leased row on standard error. It is the exit-path counterpart to the lazy singletons above, and is safe to call when neither was ever used.

func RenderPick added in v0.4.0

func RenderPick(out io.Writer, p Probe, items []string, opts PickOptions, filter string, cursor int) string

RenderPick returns the block a picker WOULD draw for the given state, without opening a terminal or running an input loop.

For the documentation renderer, which publishes pictures of this surface: a hand-written imitation drifts from the real thing silently, and the drift gate cannot see it because it compares the renderer against itself. Driving the real composition means a change to the picker's frame shows up in the docs as a stale-SVG failure.

func ResetMouseTracking added in v0.4.0

func ResetMouseTracking(w io.Writer, p Probe) error

ResetMouseTracking turns mouse reporting off unconditionally.

Input.Close restores a session this process knows it opened; this is the process-exit counterpart, and it is not redundant - a deferred Close does not run on os.Exit or a SIGKILL, and a process that dies with tracking on leaves the user unable to select text in their own shell with nothing explaining why.

Disabling a mode that was never enabled is a no-op in every terminal, so this is safe on every exit path. Unlike DECSTBM these sequences do not move the cursor, so they need no save/restore bracket.

No-op when w cannot render escapes at all, so callers do not branch. TERM=dumb never had tracking enabled - Input refuses to open there - so there is nothing to disable, and writing it anyway puts literal garbage on a terminal that declares it understands none.

func ResetScrollMargins added in v0.4.0

func ResetScrollMargins(w io.Writer, p Probe) error

ResetScrollMargins clears any DECSTBM margins on w unconditionally.

region.Release restores a region this process knows it opened. This is the process-exit counterpart, for the case where a run may have reserved a region somewhere that the exiting code does not hold a handle to. Margins belong to the terminal rather than to whoever set them, so one reset on the way out restores the user's shell regardless of which component was responsible.

It runs on every exit path, including commands that never opened a region, so it must be inert on a terminal it did not touch. DECSTBM homes the cursor, so a bare reset is not inert - on `magus help` it left the cursor at row 1 for the shell to draw its prompt over the help text. Hence the save/restore bracket, taken and released inside one write, per the register rule in [region].

No-op when w cannot render escapes at all, so callers do not have to branch.

Gated on CanRender rather than on a bare descriptor check, which is what its own doc asks for: this MOVES the cursor, and TERM=dumb is a real terminal that renders the sequence as literal garbage. Nothing is lost by skipping it there - no component that reserves rows will run under TERM=dumb either, so there is no margin left to reset.

func StdinIsTerminal

func StdinIsTerminal() bool

StdinIsTerminal is IsTerminalReader for the process's own standard input.

func WantsColor added in v0.4.0

func WantsColor(w io.Writer, p Probe) bool

WantsColor reports whether output written to w should carry ANSI color: w must be a terminal, and NO_COLOR must be unset.

This is one question with one answer, so it lives in one place. Before this existed, the cache's log handler, the doctor command, and the status grid each decided it independently, and only two of the three honored NO_COLOR.

See https://no-color.org: any non-empty value disables color. TERM=dumb disables it too, via CanRender - this function's documentation has always said so, and until now only the NO_COLOR half was true.

func WantsHyperlinks(w io.Writer, p Probe) bool

WantsHyperlinks reports whether OSC 8 hyperlinks may be written to w.

Deliberately NOT gated on NO_COLOR: that variable is about color, and a link is not color - stripping it would take away a way to reach something rather than tone the output down. It IS gated on the two terminals known to render the sequence badly rather than swallow it:

  • TERM=dumb, which by definition handles no escape at all.
  • screen, whose OSC pass-through mangles the sequence and leaves the URI visible in the output. tmux is fine and is not excluded.

Everything else either honors OSC 8 or ignores an unknown OSC cleanly, which is what makes emitting it safe rather than a gamble on the reader's terminal.

Types

type Align added in v0.4.0

type Align int

Align says which edge a Span is laid out from.

const (
	// AlignLeft packs a span after the previous left-aligned one, from column 1.
	AlignLeft Align = iota
	// AlignRight packs a span against the right edge, after any other
	// right-aligned spans on the row.
	AlignRight
)

type Event added in v0.4.0

type Event struct {
	Kind EventKind

	// Key and Rune carry a keyboard event.
	Key  Key
	Rune rune

	// Button, Row, Col, Press and Clicks carry a mouse event. Row and Col are
	// 1-based absolute terminal coordinates, the same space [Zone.HitTest]
	// takes, so a click maps to a leased row without any translation.
	Button MouseButton
	Row    int
	Col    int
	Press  bool
	// Motion reports that the pointer MOVED to Row/Col rather than that a
	// button changed state. Button is MouseNone unless one is held.
	Motion bool
	// Clicks is 1 for a single click and 2 for a double click. The TERMINAL
	// does not report double clicks - it reports presses - so this is timed
	// here, from the interval and cell of the previous press.
	Clicks int
}

Event is one thing the user did.

type EventKind added in v0.4.0

type EventKind int

EventKind distinguishes the two things an Input reports.

const (
	EventKey EventKind = iota
	EventMouse
)

type InlineView added in v0.4.0

type InlineView struct {
	// contains filtered or unexported fields
}

InlineView redraws a block of text WHERE IT STANDS, by erasing exactly the lines it drew last time and writing the new frame over them.

It replaces a clear-the-screen-and-reprint loop, which is the wrong shape for a watch view in two ways. It is a takeover: everything the reader had on screen is wiped, and what they were doing before is gone from view even though it survives in scrollback. And it flickers, because erasing the whole screen and rewriting every cell several times a second gives the terminal a blank frame to composite in between.

Erasing only the block leaves the transcript above it untouched and visible, which is the same restraint [region] keeps and the same thing the picker has always done - this is that redraw, factored out.

It is NOT a [region]: there are no scroll margins and no reserved rows, because a watch view does not need to survive other output scrolling past. It needs to sit in the transcript and be redrawn in place, which is cheaper and works at any terminal height.

func NewInlineView added in v0.4.0

func NewInlineView(w io.Writer, p Probe) *InlineView

NewInlineView returns a view that redraws its block in place on w, measured through p.

func (*InlineView) Clear added in v0.4.0

func (v *InlineView) Clear() error

Clear erases the block, leaving the terminal as though the view never drew.

The counterpart to InlineView.Finish, which leaves the frame in place. Both are right, for different views: a status readout is the answer the reader asked for and should stay, while a picker is a question that has been answered and should get out of the way.

func (*InlineView) Finish added in v0.4.0

func (v *InlineView) Finish()

Finish moves off the block so a shell prompt does not land on its last line. The frame is deliberately LEFT on screen: it is the answer the reader asked for, and erasing it on the way out would be the takeover this type avoids.

func (*InlineView) Lines added in v0.4.0

func (v *InlineView) Lines() int

Lines reports how many rows the block currently occupies.

func (*InlineView) Paint added in v0.4.0

func (v *InlineView) Paint(frame string) bool

Paint draws frame in place of the previous one, reporting whether it could.

A false return means the frame does not fit the redraw model and the caller should fall back: erasing in place walks the cursor UPWARD, so a block as tall as the terminal has nowhere to walk back to and would eat the rows above it. Reported rather than handled here because only the caller knows what the alternative is.

func (*InlineView) Reset added in v0.4.0

func (v *InlineView) Reset()

Reset forgets what is on screen, for a caller that cleared the terminal underneath this view and is starting again - the watch loop does this when a frame is too tall to redraw in place and it falls back to erasing the screen.

type Input added in v0.4.0

type Input struct {
	// contains filtered or unexported fields
}

Input owns the terminal's keyboard and mouse for as long as it is open.

IT IS SCOPED ON PURPOSE: enabling mouse reporting takes drag-to-select and the scroll wheel away from the terminal emulator. magus's contract is that ordinary output stays ordinary, so tracking is on only at the moments magus genuinely owns the terminal and off the instant it does not.

Raw mode is set on the descriptor that is actually READ - the step gate calls MakeRaw on stderr then reads stdin, which works only because both usually point at the same device.

Not safe for concurrent use: one goroutine reads.

func OpenInput added in v0.4.0

func OpenInput(in *os.File, out io.Writer, p Probe) (*Input, error)

OpenInput puts in into raw mode, turns on mouse reporting on out, and returns the session that reads from it. Close puts everything back.

Both ends must be terminals: keys are read from in and the modes are written to out, and one without the other is a session that can capture but not respond, or respond but never hear anything.

func (*Input) Close added in v0.4.0

func (i *Input) Close() error

Close turns mouse reporting off and restores the terminal. Idempotent and safe to defer.

Both halves are attempted even if the first fails: leaving tracking on would leave the user unable to select text in their own shell, and leaving raw mode on would leave them with no echo. Neither is acceptable as a consequence of the other going wrong.

func (*Input) CursorPosition added in v0.4.0

func (i *Input) CursorPosition() (row, col int, ok bool)

CursorPosition asks the terminal where the cursor is, in absolute 1-based terminal coordinates.

This is what makes a mouse usable on an INLINE view: a Zone band can be hit-tested because magus chose the rows it drew on, but a view rendered where the cursor happened to be knows its shape and not its position. The terminal is the only thing that knows.

Returns ok=false when the terminal does not answer in time, which is an ordinary answer: the caller keeps its keyboard handling and goes without mouse support rather than failing.

func (*Input) Read added in v0.4.0

func (i *Input) Read(ctx context.Context) (Event, error)

Read blocks until the user does something, and reports it. It returns ctx.Err() when ctx is cancelled first.

Cancellation is by DEADLINE, not by closing the descriptor: standard input belongs to the process, and a prompt that closed it would take the shell's stdin with it. So the wait for the first byte is a poll under a short read deadline, and ctx is checked between polls - the mechanism Input.CursorPosition already relies on, applied to the wait a person can sit in indefinitely.

The deadline covers ONLY the wait for a sequence's first byte. Once one has arrived the rest is read without one, because a terminal writes an escape sequence in a single write and a deadline firing mid-sequence would leave its tail to be decoded as stray keystrokes - an arrow key arriving as a bracket.

On a descriptor that cannot take a deadline the wait is an ordinary blocking read, so behavior is unchanged where cancellation was never available.

type Key added in v0.4.0

type Key int

Key names a non-printing key. A printing key arrives as KeyRune with the rune in Event.Rune.

const (
	KeyUnknown Key = iota
	KeyRune
	KeyEnter
	KeyEscape
	KeyTab
	KeyBackspace
	KeyUp
	KeyDown
	KeyLeft
	KeyRight
	KeyCtrlC
	KeyCtrlD
	// KeyCtrlN and KeyCtrlP are the emacs-style down/up an incremental
	// selector is expected to answer to, by anyone who has used one.
	KeyCtrlN
	KeyCtrlP
	// KeyCtrlU clears a line of input.
	KeyCtrlU
	// KeyPageUp and KeyPageDown page a bounded viewport. Appended rather than filed beside
	// the arrows so the constants above keep the values they already have.
	KeyPageUp
	KeyPageDown
)

The keys magus acts on. Deliberately not an exhaustive terminal keymap: anything not listed decodes to KeyUnknown and callers ignore it, which is how an unrecognized escape sequence stays inert instead of being guessed at.

type Lease added in v0.4.0

type Lease struct {
	// contains filtered or unexported fields
}

Lease is one consumer's contiguous band of rows inside a Zone. Bands sit in the order they were acquired, top to bottom, so the last consumer to arrive is the one closest to the reader's cursor.

A NIL *Lease is disabled too, exactly like the zero one: a consumer that has not needed rows yet holds nothing, and every method here answers for that without the caller checking.

The zero Lease is the DISABLED lease: it is what Acquire hands back when there is no terminal or no room, and every method on it is a safe no-op. That is what lets a caller write the same code for a TTY and a CI log.

func (*Lease) Enabled added in v0.4.0

func (l *Lease) Enabled() bool

Enabled reports whether this lease has rows on a real terminal. It is a cheap pre-check for a caller deciding whether to COMPUTE something it would only display - the cache asks before sampling pool occupancy - and not the authority on whether a given paint landed. Lease.Set answers that.

Takes the zone's lock because `released` is written under it by Release and Zone.Close, and Close runs from the process exit path - including the signal-driven one - while the run's own goroutines are still asking.

func (*Lease) Grow added in v0.4.0

func (l *Lease) Grow(rows int) error

Grow enlarges this lease to rows.

It returns only an error, deliberately. It used to return a bool as well, in the same position as Lease.Set's and meaning something else - Set's reports whether the paint reached the terminal, Grow's reported whether the model changed - so a caller reading the two alike was wrong at one of them. A caller that needs to know whether it actually got rows asks Lease.Rows, which is unambiguous.

A refusal is not an error: released, already large enough, no descriptor and no room all leave the lease the size it was and report nil. That is the arbitration rule, not a failure.

Growth ONLY: a band that shrank again would reflow the zone every time its content came and went, and a reflow moves the whole screen. Growing on demand lets a consumer claim one row for one notification rather than reserving for its worst case and charging every run for the possibility.

func (*Lease) Release added in v0.4.0

func (l *Lease) Release() error

Release gives this lease's rows back and repaints without them. Idempotent and safe to defer. Releasing the last lease hands the terminal back entirely: margins reset, rows returned.

func (*Lease) Rows added in v0.4.0

func (l *Lease) Rows() int

Rows reports how many rows this lease holds, for a caller bounds-checking an index it got from Zone.HitTest against its own content. A released lease holds none.

func (*Lease) Set added in v0.4.0

func (l *Lease) Set(rows []Line) (rendered bool, err error)

Set replaces this lease's band and repaints the zone, reporting whether the rows actually reached the terminal.

The bool is the point of the signature: only the caller knows what its content IS. A failure line is a RECORD and must be printed plainly when it cannot be pinned; a status line is a VIEW and must be dropped. Returning false keeps that judgment where the knowledge is.

Fewer rows than the lease holds leaves the remainder blank, which is what lets an entry disappear; more are dropped.

func (*Lease) Width added in v0.4.0

func (l *Lease) Width() int

Width reports the columns a row of this lease can use, or 0 when it cannot draw. It is what a caller needs to know whether its text FITS - the notifier scrolls a message only when it does not.

type Line added in v0.4.0

type Line struct {
	Text  string
	Style SGR
	Spans []Span
}

Line is one line of a whole-zone repaint.

Text and Style are shorthand for the overwhelmingly common single-span row; Spans is the general form and wins when both are set. They are not two code paths - [Line.spans] normalizes the shorthand into a one-element list and the renderer only ever sees spans - so the convenience cannot drift from the general case.

func (Line) SpansCopy added in v0.4.0

func (r Line) SpansCopy() []Span

SpansCopy returns this line's spans as a fresh slice, normalizing the Text+Style shorthand on the way.

Exported for a caller COMPOSING onto an existing line - the band appends a divider and a second column to rows it has already built. Doing that by hand means re-implementing the shorthand normalization, and the copy is what stops an append from writing into a slice the line still shares.

type MouseButton added in v0.4.0

type MouseButton int

MouseButton names which button an Event carries. Wheel movement arrives as a button because that is how the terminal reports it.

const (
	MouseNone MouseButton = iota
	MouseLeft
	MouseMiddle
	MouseRight
	MouseWheelUp
	MouseWheelDown
)

type Notifier added in v0.4.0

type Notifier struct {
	// contains filtered or unexported fields
}

Notifier renders a stack of expiring notifications - toasts - into a band of a Zone.

A toast has to VANISH with nothing to replace it, which an append-only surface cannot express: a line written into one stays until something newer displaces it, and that is right for a failure and wrong here. So the whole band is re-composited on every change, and expiry simply produces a shorter frame.

Expiry is driven by a sweeper goroutine, because a toast that only expired when the next one arrived would not be a toast - it would be a log that happens to be pinned. That goroutine is also why the Zone mutex matters: the sweeper paints while the caller's own thread may be painting too.

func NewNotifier added in v0.4.0

func NewNotifier(z *Zone, max int) *Notifier

NewNotifier returns a notifier that will draw into the bottom of z, holding at most max notifications at once.

Nothing is claimed and no goroutine starts here. The band is taken on the first notification and grows toward max only as more are actually showing, so a process that never notifies - which, under the rule that only actionable things are worth notifying about, is most of them - pays nothing at all.

A grant that is refused, on a pipe, in CI, or on a terminal with no room, leaves every method a safe no-op, so callers write the same code either way.

func NotifierFor added in v0.4.0

func NotifierFor(w io.Writer) *Notifier

NotifierFor returns the notification band for w: the process-wide StderrNotifier when w IS standard error, and a fresh unshared one otherwise. It is the ZoneFor of notifications and makes the same identity check for the same reason.

func StderrNotifier added in v0.4.0

func StderrNotifier() *Notifier

StderrNotifier returns the process-wide notification band on standard error, creating it on first use.

A singleton for the reason StderrZone is one, and more so: its three consumers - magus's own run events, a term\notify call from a magusfile, and a daemon background job - cannot see each other, run on different threads, and have different lifetimes. Toasts from all of them belong in ONE stack, in arrival order, or the reader gets three competing bands.

Nothing is drawn and no rows are taken until the first Notify.

func (*Notifier) Clear added in v0.4.0

func (n *Notifier) Clear(key string) error

Clear retracts the pinned notification named by key, if it is showing. Retracting one that is not showing is a no-op, so a caller reporting the end of a condition does not have to know whether the start was ever displayed.

func (*Notifier) Close added in v0.4.0

func (n *Notifier) Close() error

Close stops the sweeper and gives the band back. Idempotent and safe to defer.

func (*Notifier) Notify added in v0.4.0

func (n *Notifier) Notify(text string, style SGR, ttl time.Duration) error

Notify pushes a notification onto the stack, to be shown for ttl. A ttl of zero or less never expires on its own.

The newest toast sits at the bottom of the band, closest to the reader. When the stack overflows its rows the OLDEST is dropped: a notification the reader has already had time to see is the right one to lose, and silently discarding the newest would make a burst look like nothing happened.

func (*Notifier) Pin added in v0.4.0

func (n *Notifier) Pin(key, text string, style SGR) error

Pin raises a notification about a CONDITION that is currently true, and keeps it until Notifier.Clear retracts it. Calling it again with the same key updates the text in place rather than stacking a duplicate.

This is the shape most things worth notifying about actually have. A toast earns its place only when the reader has to act, and something a reader must act on is rarely a moment - it is a state that persists until they do something about it: a run stalled behind another process's lock, a daemon that has gone away, credentials that have expired. Reporting those as expiring notifications would be wrong twice over, since the message vanishes while the problem does not, and it reappears on nothing when the problem finally ends.

A pinned notification never expires on a clock and is evicted only when the band is full of other pins. Transient notifications lose their row first.

type PickOptions added in v0.4.0

type PickOptions struct {
	// Prompt is the label drawn before the filter input (e.g. "project").
	Prompt string
	// InitialFilter pre-populates the filter string. The picker filters
	// items against it on first paint.
	InitialFilter string
	// Initial is the index in items that should be highlighted on first
	// paint when the post-filter list is non-empty and contains it.
	Initial int
	// MaxRows caps the visible window of matches. Defaults to 10.
	MaxRows int
	// Query replaces the built-in substring filter with a live lookup, called
	// once per change to the filter text and expected to return the items to
	// show, best first.
	//
	// It exists so a picker can search something larger than the list it was
	// opened with - the knowledge graph rather than the projects a caller can
	// enumerate up front. The caller keeps the mapping from label back to
	// meaning; this type only draws strings.
	//
	// Nil keeps the substring filter, right for an already-complete list.
	//
	// It takes the picker's context and may fail: it is called once per
	// keystroke from inside the input loop, so a lookup that hangs freezes the
	// prompt, and one that errors used to be indistinguishable from one that
	// legitimately matched nothing.
	Query func(ctx context.Context, filter string) ([]string, error)
}

PickOptions configures a single Pick call.

type Probe added in v0.4.0

type Probe interface {
	IsTerminal(fd uintptr) bool
	Size(fd uintptr) (width, height int, err error)
}

Probe answers the two questions a caller needs about a file descriptor before drawing to it: whether it is a terminal the user is typing at (rather than a pipe, regular file, or /dev/null) and how large that terminal is.

The methods take a descriptor rather than an io.Writer so the interface stays independent of io and serves stdin, stdout, and stderr alike; callers resolve the descriptor once with Fd.

var SystemProbe Probe = systemProbe{}

SystemProbe is the production Probe: it asks the operating system. It is stateless and safe for concurrent use, so it is a value rather than something a constructor hands out. Tests inject their own Probe instead of replacing this.

func FixedProbe added in v0.4.0

func FixedProbe(width, height int) Probe

FixedProbe answers as a terminal of the given size, whatever the descriptor.

It exists for the same reason the screen emulator is a package rather than a test file: something has to DRIVE the interactive surfaces outside a terminal - a documentation renderer, a recording - and every one of them stands down without a probe that says there is a terminal to draw on. Tests have always had one; this is that, exported, so a generator can have it too.

type SGR added in v0.4.0

type SGR string

SGR is a set of Select Graphic Rendition parameters - the part of "\x1b[1;31m" between the bracket and the m.

A named type rather than a bare string because the calls that carry one also carry message text: Notify(text, style, ttl) and Pin(key, text, style) put two same-typed strings in different positions, so a transposition used to compile and paint a message that read as a style code. It cannot now.

const (
	SGRBold    SGR = "1"
	SGRDim     SGR = "2"
	SGRRed     SGR = "31"
	SGRGreen   SGR = "32"
	SGRYellow  SGR = "33"
	SGRBoldRed SGR = "1;31"
	// SGRReverse swaps foreground and background. It marks the selected row of
	// an interactive band, and is used instead of a color because it composes:
	// "7;1;31" is the same red the row already had, highlighted.
	SGRReverse     SGR = "7"
	SGRDimGreen    SGR = "2;32"
	SGRDimGrey     SGR = "2;37"
	SGRBrightGreen SGR = "1;32"
)

SGR parameter codes. These name the color, not the meaning: a caller decides that "a cache hit is dim green", because what reads as low signal differs per surface. Shared so the codes themselves are written once.

type Span added in v0.4.0

type Span struct {
	Text  string
	Style SGR
	Align Align
	// Key names this span as a click target, or is empty for ordinary text.
	//
	// It exists so that a row which PRINTS an action is also a place you can
	// click to take it, without the caller doing column arithmetic that would
	// then have to be kept in step with the layout. Callers set a key, ask
	// [Zone.HitSpan] what a click landed on, and never see a column.
	//
	// Keeping the two together is the point: a hint the surface draws and a
	// hint it responds to cannot drift apart if they are the same span.
	Key string
}

Span is one styled, aligned segment of a row.

Spans exist because a row often carries two unrelated things - what is happening on the left, and how to get out of it on the right - and a single string cannot express that. The alignment is resolved at PAINT time rather than by the caller, because only the region knows the terminal's width, and a caller padding to a width it guessed is a caller that is wrong after the first resize.

type Zone added in v0.4.0

type Zone struct {
	// contains filtered or unexported fields
}

Zone is the process's single owner of a terminal's bottom rows, handing out contiguous bands of them as leases.

EVERY field of a Lease is guarded by this type's mutex, including the ones a Lease method reads about itself. The exit path calls Zone.Close from a signal handler while the run's own goroutines are still painting, so an unlocked read of `released` or `rows` is a live race rather than a theoretical one.

It exists because the terminal's scroll margins are ONE GLOBAL SETTING and magus had more than one component setting them: two [region]s at once, each computing row arithmetic from a different height, with only an unconditional ResetScrollMargins on the way out keeping the shell usable. That is a mop, not an owner - it cannot help while the run is still going.

So the margins are set here once, by whoever owns the whole zone, and every consumer asks for rows instead of taking the terminal.

The corollary is that Zone is the concurrency boundary a region deliberately is not: a region is single-threaded by contract and its consumers here are not, so every region access goes through z.mu.

func NewZone added in v0.4.0

func NewZone(w io.Writer, p Probe) *Zone

NewZone returns a Zone over w, measured through p. Nothing is written here.

Pass SystemProbe in production; tests pass their own Probe and a writer with a synthetic descriptor.

func StderrZone added in v0.4.0

func StderrZone() *Zone

StderrZone returns the process-wide owner of standard error's bottom rows.

A package-level singleton rather than something threaded through call sites, because the thing it guards IS process-global: there is one terminal behind stderr, and a second Zone over it would recreate exactly the two-owners problem Zone exists to end. Consumers that cannot see each other - the cache's log handler, a Buzz notify call, a daemon job - have to arrive at the same owner without being introduced.

Tests build their own with NewZone instead of reaching for this.

func ZoneFor added in v0.4.0

func ZoneFor(w io.Writer) *Zone

ZoneFor returns the Zone that owns w's bottom rows: the process-wide StderrZone when w IS standard error, and a fresh unshared Zone otherwise.

The identity check is the point. Sharing an owner is only correct for consumers writing to the same terminal, and "the same terminal" is a property of the descriptor, not of anyone's intent to cooperate. A handler pointed at a log file or a test buffer gets its own Zone and cannot disturb the real one.

func (*Zone) Acquire added in v0.4.0

func (z *Zone) Acquire(rows int) *Lease

Acquire grants rows at the bottom of the zone, or returns a disabled Lease when it cannot.

A grant is REFUSED rather than allowed to shrink the scrolling area below what the leases already present depend on. That asymmetry is the whole arbitration rule: an incumbent that is drawing correctly must not go dark because a newcomer asked for space, so the newcomer degrades instead. A notifier that cannot get toast rows drops its toasts; the run's failure region above it keeps working.

func (*Zone) Close added in v0.4.0

func (z *Zone) Close() error

Close releases every lease and hands the terminal back. It is the process-exit counterpart to Lease.Release, for the exit path that does not hold the individual leases.

func (*Zone) HitSpan added in v0.4.0

func (z *Zone) HitSpan(row, col int) (key string, ok bool)

HitSpan reports which keyed span a click at (row, col) landed on.

Keyed rather than positional: the caller labels the span it draws and gets that label back, so a hint cannot be drawn in one place and hit-tested in another.

Extents are recomputed from the stored line rather than cached at paint time. A click is a human-speed event, so the arithmetic is free, and a cache would need invalidating on every repaint and resize.

func (*Zone) HitTest added in v0.4.0

func (z *Zone) HitTest(row int) (lease *Lease, index int, ok bool)

HitTest maps an absolute terminal row - the coordinate space a mouse event arrives in - to the lease that owns it and the index of that row within the lease's band.

The zone already knows where every band sits because it put them there, so a caller passes Row from an Event and never computes a row itself - there is no second copy of the layout to drift.

Reports false for any row outside the reserved zone, including all the ordinary scrolling output above it: a click there belongs to the terminal's selection.

func (*Zone) SetTitle added in v0.4.0

func (z *Zone) SetTitle(left, right string) (rendered bool, err error)

SetTitle captions the zone's top rule, costing no row.

On the Zone rather than a Lease because the rule is the zone's - there is one box however many leases stack inside it, so a caption is a property of the whole, and two leases both claiming it would fight. The run's status line is the intended caller: it describes the band, not any one band's rows.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL