Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

ui.gno

10.20 Kb · 281 lines
  1// Package ui is the shared display vocabulary for r/moul realms: the small set
  2// of rendering decisions that every realm was making on its own, made once.
  3//
  4// It deliberately does NOT re-export markdown primitives. Headings, bold,
  5// lists, code blocks and links already have an owner in
  6// [p/moul/md](/p/moul/md/v0); import that alongside this package. What lives
  7// here is only what had no owner and was therefore copy-pasted:
  8//
  9//   - Addr: one address shortening for the whole namespace. Eleven realms had
 10//     their own shortAddr with four different truncation rules, so the same
 11//     account rendered differently depending on which realm you opened.
 12//   - Inline / Cell / Excerpt: markdown escaping, delegated to
 13//     [p/nt/markdown/sanitize](/p/nt/markdown/sanitize/v0). Eight realms
 14//     hand-rolled a seven-pair strings.NewReplacer that misses most of what
 15//     the real escaper handles. Excerpt adds the length cap those previews
 16//     also need, in the order that is safe: cut, then escape.
 17//   - Action: a clickable call link instead of prose telling the reader to go
 18//     type a function name.
 19//   - Table, Empty, Podium: the last recurring scraps.
 20//
 21// # Escaping contract
 22//
 23// Table cells and Action titles are markdown, not plain text. Anything that
 24// came from a user must go through [Cell] (inside a table) or [Inline]
 25// (anywhere else) before it is handed to this package. Output of [Addr],
 26// [AddrFull] and [Podium] is already safe and must NOT be escaped again.
 27package ui
 28
 29import (
 30	"strings"
 31
 32	"gno.land/p/moul/helplink/v0"
 33	"gno.land/p/moul/md/v0"
 34	"gno.land/p/nt/markdown/sanitize/v0"
 35)
 36
 37// Ellipsis is the character placed between the kept head and tail of a
 38// shortened string.
 39const Ellipsis = "…"
 40
 41// Head and Tail are how many characters [Addr] and [Short] keep on each side.
 42//
 43// A gno.land address is 40 characters ("g1" plus 38 of bech32 data), so
 44// 8+1+4 renders it as g1manfre…dlf5: enough of the head to recognise a
 45// familiar account, enough of the tail to tell two similar ones apart.
 46const (
 47	Head = 8
 48	Tail = 4
 49)
 50
 51// minShorten is the length below which shortening cannot save a character:
 52// Head + len(Ellipsis) + Tail. Strings at or under it are returned unchanged,
 53// which is why the rule never produces output longer than its input.
 54const minShorten = Head + 1 + Tail
 55
 56// Addr renders an address shortened and in backticks: `g1manfre…dlf5`.
 57//
 58// This is the default way to show an address. It is monospace (an address is
 59// opaque data, not prose), and the backticks make it inert markdown, so it is
 60// safe in a table cell or anywhere else without further escaping.
 61func Addr(a address) string {
 62	return md.InlineCode(AddrText(a))
 63}
 64
 65// AddrFull renders an address in full, in backticks. Use it where the reader
 66// needs to copy the value; use [Addr] everywhere else.
 67func AddrFull(a address) string {
 68	return md.InlineCode(a.String())
 69}
 70
 71// AddrText renders an address shortened, with no backticks. Use it inside a
 72// link title or another construct where backticks would not render.
 73func AddrText(a address) string {
 74	return Short(a.String())
 75}
 76
 77// AddrOf is [Addr] for an address already in its string form, which is how
 78// realms hold one when it is an avl key or a stored field.
 79func AddrOf(s string) string {
 80	return md.InlineCode(Short(s))
 81}
 82
 83// Short shortens any string with the house rule: unchanged when shortening
 84// would not save a character, otherwise head + ellipsis + tail.
 85//
 86// It is the same rule [Addr] uses, exposed for the non-address strings realms
 87// also truncate: URLs, handles, commitment hashes.
 88func Short(s string) string {
 89	return ShortN(s, Head, Tail)
 90}
 91
 92// ShortN is [Short] with an explicit head and tail. A negative head or tail is
 93// treated as zero. When head+tail cannot save a character against the input,
 94// the input is returned unchanged.
 95func ShortN(s string, head, tail int) string {
 96	if head < 0 {
 97		head = 0
 98	}
 99	if tail < 0 {
100		tail = 0
101	}
102	// Count runes, not bytes: slicing a multi-byte string by byte offset
103	// splits a rune and emits replacement characters.
104	r := []rune(s)
105	if len(r) <= head+1+tail {
106		return s
107	}
108	return string(r[:head]) + Ellipsis + string(r[len(r)-tail:])
109}
110
111// Inline escapes user-supplied text for an inline markdown context: a
112// sentence, a list item, a link title.
113//
114// It delegates to sanitize.InlineText, which strips bidi and zero-width
115// characters, folds newlines to spaces so the text cannot escape its line,
116// and applies the full CommonMark inline escape. Never concatenate
117// user-supplied text into rendered output without this.
118func Inline(s string) string {
119	return sanitize.InlineText(s)
120}
121
122// Cell escapes user-supplied text for a markdown table cell: [Inline] plus
123// tab and pipe handling, so the value cannot open a new column.
124func Cell(s string) string {
125	return sanitize.TableCell(s)
126}
127
128// Excerpt is [Inline] for a string too long to show whole: it keeps the first
129// width runes, appends [Ellipsis], and escapes what it kept.
130//
131// Use it for a preview of user-written prose where only the beginning carries
132// meaning: the first line of a post in an index, a note on a board, a comment
133// in a list. For an identifier whose tail has to stay recognisable, an
134// address, a hash, a URL, use [Short] instead, which keeps both ends.
135//
136// The order is the whole point, and it is what a call site gets wrong.
137// Escaping inserts backslashes, so cutting an ALREADY-escaped string can
138// strand a trailing lone backslash that escapes whatever chrome follows it.
139// Excerpt cuts first, on a rune boundary so a multi-byte character is never
140// split, then escapes. [Ellipsis] stays outside the escaper: it is this
141// package's chrome, not the user's text.
142func Excerpt(s string, width int) string {
143	if width < 0 {
144		width = 0
145	}
146	r := []rune(s)
147	// The same threshold as [ShortN] with no tail: cutting is only worth doing
148	// when it saves a character, so a string one rune over is kept whole.
149	if len(r) <= width+1 {
150		return Inline(s)
151	}
152	return Inline(string(r[:width])) + Ellipsis
153}
154
155// Action renders a clickable call to a function of the current realm.
156//
157//	ui.Action("Play cell 4", "Move", "gameID", "7", "cell", "4")
158//
159// Arguments are key/value pairs, as in p/moul/txlink. The title is escaped,
160// which is the one thing helplink.Func does not do.
161func Action(title, fn string, args ...string) string {
162	return helplink.Func(Inline(title), fn, args...)
163}
164
165// ActionIn is [Action] against another realm, given as the full package path
166// that realm declares on the module line of its gnomod.toml.
167//
168// This doc deliberately contains no example path, not even a placeholder one.
169// gnopm scans doc comments the way it scans imports, so any package path
170// spelled out in a comment becomes a dependency of the package holding it: one
171// that is neither live nor in the workspace BLOCKS publishing this package and
172// everything that imports it, and naming a live one is no better, since it
173// drags an unrelated realm into every publish plan. This comment previously
174// named a path that deversioning had already removed, which blocked the whole
175// ui dependency tree on every chain.
176func ActionIn(pkgPath, title, fn string, args ...string) string {
177	return helplink.Realm(pkgPath).Func(Inline(title), fn, args...)
178}
179
180// Empty renders the placeholder shown where a list would be: an italic line,
181// newline-terminated, so it drops into a Render body as-is.
182//
183//	ui.Empty("No games yet.")  // "_No games yet._\n"
184//
185// The message is realm chrome, not user input, and is not escaped.
186func Empty(msg string) string {
187	return md.Italic(msg) + "\n"
188}
189
190// Podium returns the medal for a zero-based rank, or "" past third place.
191func Podium(rank int) string {
192	switch rank {
193	case 0:
194		return "\U0001F947" // 🥇
195	case 1:
196		return "\U0001F948" // 🥈
197	case 2:
198		return "\U0001F949" // 🥉
199	default:
200		return ""
201	}
202}
203
204// Table accumulates markdown table rows and renders them.
205//
206// Cells are markdown, not plain text: pass [Addr] and friends straight
207// through, and wrap anything user-supplied in [Cell] first.
208//
209// Table renders the GFM table itself rather than delegating to
210// [p/moul/mdtable](/p/moul/mdtable/v0), which unconditionally rewrites "|" to
211// "&#124;" in every cell. Stacked on [Cell], which already emits the GFM
212// escape "\|", that double-escapes into a stray backslash ("a\&#124;b"). One
213// escaping stage is the only way to get this right, and it has to be the one
214// that knows whether the text is user input.
215//
216//	t := ui.NewTable("#", "X", "O", "Status")
217//	t.Row("7", ui.Addr(x), ui.Addr(o), "Turn: X")
218//	return t.String()
219type Table struct {
220	headers []string
221	rows    [][]string
222}
223
224// NewTable starts a table with the given header cells.
225func NewTable(headers ...string) *Table {
226	return &Table{headers: headers}
227}
228
229// Row appends a row and returns the table, so calls can be chained.
230//
231// A row with fewer cells than there are headers is padded with empty cells; a
232// longer row is kept as-is, which renders as a ragged table rather than
233// silently dropping data.
234func (t *Table) Row(cells ...string) *Table {
235	if n := len(t.headers); len(cells) < n {
236		padded := make([]string, n)
237		copy(padded, cells)
238		cells = padded
239	}
240	t.rows = append(t.rows, cells)
241	return t
242}
243
244// Len reports how many rows have been added.
245func (t *Table) Len() int { return len(t.rows) }
246
247// String renders the table. A table with no rows renders as "", so a caller
248// can fall back to [Empty] with a single check on [Table.Len].
249func (t *Table) String() string {
250	if len(t.rows) == 0 {
251		return ""
252	}
253	var sb strings.Builder
254	sb.WriteString("| " + strings.Join(t.headers, " | ") + " |\n")
255	sb.WriteString("|" + strings.Repeat(" --- |", len(t.headers)) + "\n")
256	for _, r := range t.rows {
257		sb.WriteString("| " + strings.Join(r, " | ") + " |\n")
258	}
259	return sb.String()
260}
261
262// OrEmpty renders the table, or the placeholder when it has no rows.
263func (t *Table) OrEmpty(msg string) string {
264	if len(t.rows) == 0 {
265		return Empty(msg)
266	}
267	return t.String()
268}
269
270// Join concatenates parts, skipping empty ones, with sep between them. It is
271// the small piece of glue every Render needs to assemble optional sections
272// without emitting stray separators.
273func Join(sep string, parts ...string) string {
274	kept := make([]string, 0, len(parts))
275	for _, p := range parts {
276		if p != "" {
277			kept = append(kept, p)
278		}
279	}
280	return strings.Join(kept, sep)
281}