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
- Variables
- func CanRender(w io.Writer, p Probe) bool
- func ClearScreen(w io.Writer) error
- func ClipBytes(msg string, nBytes int) string
- func ClipCols(s string, cols int) string
- func Colorize(s string, sgr SGR) string
- func Cols(s string) int
- func Copy(w io.Writer, text string) (sent int, err error)
- func EraseLines(w io.Writer, n int) error
- func Fd(w io.Writer) (uintptr, bool)
- func Hyperlink(text, uri string) string
- func IsTerminalReader(r *os.File, p Probe) bool
- func IsTerminalWriter(w io.Writer, p Probe) bool
- func MakeRaw(fd uintptr) (restore func() error, err error)
- func Pick(ctx context.Context, in *os.File, out io.Writer, p Probe, items []string, ...) (int, error)
- func Prose(w io.Writer, p Probe, sentences ...string)
- func ProseItem(w io.Writer, p Probe, label string, sentences ...string)
- func ReleaseStderr() error
- func RenderPick(out io.Writer, p Probe, items []string, opts PickOptions, filter string, ...) string
- func ResetMouseTracking(w io.Writer, p Probe) error
- func ResetScrollMargins(w io.Writer, p Probe) error
- func StdinIsTerminal() bool
- func WantsColor(w io.Writer, p Probe) bool
- func WantsHyperlinks(w io.Writer, p Probe) bool
- type Align
- type Event
- type EventKind
- type InlineView
- type Input
- type Key
- type Lease
- type Line
- type MouseButton
- type Notifier
- type PickOptions
- type Probe
- type SGR
- type Span
- type Zone
Constants ¶
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 ¶
var ErrAborted = errors.New("picker: aborted")
ErrAborted is returned when the user presses ESC, Ctrl-C, or Ctrl-D.
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
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
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
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
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
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
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
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
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
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 ¶ added in v0.4.0
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
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
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
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
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
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
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
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
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 ¶ added in v0.4.0
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 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.
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
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
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
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
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
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
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
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
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
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.
type Line ¶ added in v0.4.0
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
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
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
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
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
Close stops the sweeper and gives the band back. Idempotent and safe to defer.
func (*Notifier) Notify ¶ added in v0.4.0
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
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
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
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
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
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
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
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
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
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.