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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

ui.gno

10.27 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/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/helplink/v0"
 33	"gno.land/p/g1magld5ptgzaktqcexxq0pgq5lmdf0kz6re8hlv/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}