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// "|" in every cell. Stacked on [Cell], which already emits the GFM
212// escape "\|", that double-escapes into a stray backslash ("a\|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}