main ab6686c

renderer.go RawHistoryFolder

1package html
2
3import (
4	"fmt"
5	"math"
6	"unicode/utf8"
7
8	"github.com/bnema/vev-vt/core"
9)
10
11type Renderer struct {
12	limits     Limits
13	generation uint64
14	pending    *transaction
15	committed  core.Frame
16	// spare is a frame page no update or transaction references. Prepare
17	// copies a core.Frame source into it; Commit and Abort hand a page back.
18	spare       core.Frame
19	cursor      Cursor
20	initialized bool
21	// Encoder scratch reused across updates; never referenced by an Update.
22	ends      []int
23	text      []byte
24	rowStarts []int
25}
26
27// CellSource is the read-only semantic grid consumed by HTML rendering.
28type CellSource = core.CellSource
29
30type transactionState uint8
31
32const (
33	transactionPending transactionState = iota
34	transactionCommitted
35	transactionAborted
36)
37
38type transaction struct {
39	renderer   *Renderer
40	generation uint64
41	state      transactionState
42	update     Update
43	json       []byte
44	frame      core.Frame
45	cursor     Cursor
46}
47
48// PreparedDraw owns one immutable update and its speculative renderer state.
49type PreparedDraw struct {
50	tx *transaction
51}
52
53func New(options Options) (*Renderer, error) {
54	limits, err := normalizeLimits(options.Limits)
55	if err != nil {
56		return nil, err
57	}
58	return &Renderer{limits: limits, generation: 1}, nil
59}
60
61// Prepare validates and encodes a speculative update. The renderer advances
62// only after Commit. Abort preserves the previous committed shadow.
63func (r *Renderer) Prepare(source CellSource, damage []core.Damage, reset bool, cursor Cursor) (*PreparedDraw, error) {
64	if r == nil {
65		return nil, fmt.Errorf("html: nil renderer")
66	}
67	if r.pending != nil {
68		return nil, ErrPendingDraw
69	}
70	frame, err := r.materializeCellSource(source)
71	if err != nil {
72		return nil, err
73	}
74	update, encoded, err := r.prepareUpdate(frame, damage, reset, &cursor)
75	if err != nil {
76		r.spare = frame
77		return nil, err
78	}
79
80	tx := &transaction{
81		renderer:   r,
82		generation: r.generation,
83		state:      transactionPending,
84		update:     update,
85		json:       encoded,
86		frame:      frame,
87		cursor:     cursor,
88	}
89	r.pending = tx
90	return &PreparedDraw{tx: tx}, nil
91}
92
93func (r *Renderer) prepareUpdate(frame core.Frame, damage []core.Damage, reset bool, cursor *Cursor) (Update, []byte, error) {
94	scratch, err := r.validateFrame(frame)
95	if err != nil {
96		return Update{}, nil, err
97	}
98	*cursor = normalizeWrapPendingCursor(*cursor, frame.Width)
99	if err := validateCursor(*cursor, frame.Width, frame.Height); err != nil {
100		return Update{}, nil, err
101	}
102	snapshot := reset || !r.initialized || r.committed.Width != frame.Width || r.committed.Height != frame.Height || damageRequiresSnapshot(damage, frame.Width, frame.Height)
103	update, err := r.buildUpdate(frame, scratch, snapshot, *cursor)
104	if err != nil {
105		return Update{}, nil, err
106	}
107	encoded, err := encodeBoundedUpdate(update, r.limits.MaxGeneratedBytes)
108	if err != nil {
109		return Update{}, nil, err
110	}
111	return update, encoded, nil
112}
113
114// validateFrame checks limits and cell invariants. It returns a one-row
115// scratch buffer that the caller may reuse for the rest of this Prepare.
116func (r *Renderer) validateFrame(frame core.Frame) ([]core.Cell, error) {
117	if frame.Width <= 0 || frame.Height <= 0 {
118		return nil, fmt.Errorf("html: invalid frame size %dx%d", frame.Width, frame.Height)
119	}
120	if frame.Width > math.MaxInt/frame.Height {
121		return nil, fmt.Errorf("%w: frame cell count overflows int", ErrLimitExceeded)
122	}
123	cells := frame.Width * frame.Height
124	if cells > r.limits.MaxCells {
125		return nil, fmt.Errorf("%w: frame has %d cells, limit is %d", ErrLimitExceeded, cells, r.limits.MaxCells)
126	}
127	if frame.Height > r.limits.MaxRowsPerUpdate {
128		return nil, fmt.Errorf("%w: frame has %d rows, limit is %d", ErrLimitExceeded, frame.Height, r.limits.MaxRowsPerUpdate)
129	}
130	if err := frame.Validate(); err != nil {
131		return nil, fmt.Errorf("html: validate frame: %w", err)
132	}
133	scratch := make([]core.Cell, frame.Width)
134	for y := range frame.Height {
135		row := readRow(frame, y, scratch)
136		for x := 0; x < frame.Width; x++ {
137			cell := row[x]
138			if err := validateCoreStyle(cell.Style); err != nil {
139				return nil, fmt.Errorf("html: cell (%d,%d): %w", x, y, err)
140			}
141			if cell.Continuation {
142				if cell.Rune != 0 {
143					return nil, fmt.Errorf("html: cell (%d,%d): wide continuation contains a rune", x, y)
144				}
145				if x == 0 || row[x-1].Continuation || core.RuneWidth(row[x-1].Rune) != 2 {
146					return nil, fmt.Errorf("html: cell (%d,%d): orphan wide continuation", x, y)
147				}
148				if !cell.Style.Equal(row[x-1].Style) {
149					return nil, fmt.Errorf("html: cell (%d,%d): wide continuation style differs from its head", x, y)
150				}
151				continue
152			}
153			if cell.Rune == 0 {
154				continue
155			}
156			if !utf8.ValidRune(cell.Rune) {
157				return nil, fmt.Errorf("html: cell (%d,%d): invalid Unicode scalar", x, y)
158			}
159			width := core.RuneWidth(cell.Rune)
160			switch width {
161			case 1:
162			case 2:
163				if x+1 >= frame.Width || !row[x+1].Continuation {
164					return nil, fmt.Errorf("html: cell (%d,%d): wide rune lacks continuation", x, y)
165				}
166			case 0:
167				return nil, fmt.Errorf("html: cell (%d,%d): unsupported zero-width rune", x, y)
168			default:
169				return nil, fmt.Errorf("html: cell (%d,%d): unsupported rune width %d", x, y, width)
170			}
171		}
172	}
173	return scratch, nil
174}
175
176// materializeCellSource reads a source into an owned frame. A core.Frame is
177// copied into the renderer's spare page; any other source is copied cell by
178// cell.
179func (r *Renderer) materializeCellSource(source CellSource) (core.Frame, error) {
180	if source == nil {
181		return core.Frame{}, fmt.Errorf("html: nil cell source")
182	}
183	switch frame := source.(type) {
184	case core.Frame:
185		if err := frame.Validate(); err != nil {
186			return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
187		}
188		return r.copyFrame(frame), nil
189	case *core.Frame:
190		if frame == nil {
191			return core.Frame{}, fmt.Errorf("html: nil cell source")
192		}
193		if err := frame.Validate(); err != nil {
194			return core.Frame{}, fmt.Errorf("html: validate frame: %w", err)
195		}
196		return r.copyFrame(*frame), nil
197	}
198	width, height := source.Columns(), source.Rows()
199	if width <= 0 || height <= 0 {
200		return core.Frame{}, fmt.Errorf("html: invalid cell source size %dx%d", width, height)
201	}
202	clone := core.NewFrame(width, height)
203	for y := range height {
204		for x := range width {
205			clone.Set(x, y, source.Cell(x, y))
206		}
207	}
208	return clone, nil
209}
210
211// copyFrame copies a validated frame into the spare page, which the returned
212// frame then owns. CopyFrom always succeeds here: src passed Validate.
213func (r *Renderer) copyFrame(src core.Frame) core.Frame {
214	frame := r.spare
215	r.spare = core.Frame{}
216	frame.CopyFrom(src)
217	return frame
218}
219
220// maxRetainedRows bounds the row count whose encoder scratch the renderer
221// keeps between updates.
222const maxRetainedRows = 80
223
224// normalizeWrapPendingCursor maps the deferred-wrap one-past-end column
225// produced after writing the last column with autowrap enabled onto the
226// last visible column. It leaves every in-bounds cursor untouched.
227func normalizeWrapPendingCursor(cursor Cursor, width int) Cursor {
228	if cursor.Column == width && cursor.Row >= 0 {
229		cursor.Column = width - 1
230	}
231	return cursor
232}
233
234func validateCursor(cursor Cursor, width, height int) error {
235	if cursor.Row < 0 || cursor.Row >= height || cursor.Column < 0 || cursor.Column >= width {
236		return fmt.Errorf("html: cursor (%d,%d) outside %dx%d frame", cursor.Column, cursor.Row, width, height)
237	}
238	if cursor.Style > 6 {
239		return fmt.Errorf("html: invalid cursor style %d", cursor.Style)
240	}
241	if !cursor.StyleSet && cursor.Style != 0 {
242		return fmt.Errorf("html: cursor style must be zero when unset")
243	}
244	return nil
245}
246
247func damageRequiresSnapshot(damage []core.Damage, width, height int) bool {
248	for _, item := range damage {
249		switch item.Kind {
250		case core.DamageText, core.DamageClear:
251			if item.X < 0 || item.Y < 0 || item.Width <= 0 || item.Height <= 0 || item.X > width-item.Width || item.Y > height-item.Height {
252				return true
253			}
254		case core.DamageScrollUp, core.DamageFullRedraw:
255			return true
256		default:
257			return true
258		}
259	}
260	return false
261}
262
263// readRow copies logical row y into buf and returns the filled prefix. It is
264// the allocation-free equivalent of core.Frame.Row.
265func readRow(frame core.Frame, y int, buf []core.Cell) []core.Cell {
266	row := buf[:frame.Width]
267	for x := range row {
268		row[x] = frame.Cell(x, y)
269	}
270	return row
271}
272
273func (r *Renderer) buildUpdate(frame core.Frame, scratch []core.Cell, snapshot bool, cursor Cursor) (Update, error) {
274	update := Update{
275		SchemaVersion: UpdateSchemaVersion,
276		Width:         frame.Width,
277		Height:        frame.Height,
278		Snapshot:      snapshot,
279		Rows:          make([]RowUpdate, 0),
280		Styles:        make([]Style, 0),
281		Cursor:        cursor,
282	}
283	styleIDs := make(map[Style]int)
284	// Every row appends its runs to cells and their text to text; ends[i]
285	// is the text offset after cells[i]. Rows and texts are sliced out once
286	// at the end, so an update costs one cell slice and one string.
287	// cells is owned by the update and sized for one full row (an
288	// incremental update usually changes one or a few rows). The other
289	// buffers are renderer scratch: the text is copied into one string.
290	cells := make([]CellUpdate, 0, frame.Width)
291	ends, text, rowStarts := r.ends[:0], r.text[:0], r.rowStarts[:0]
292	if cap(ends) < frame.Width {
293		ends = make([]int, 0, frame.Width)
294		text = make([]byte, 0, frame.Width)
295	}
296	if cap(rowStarts) < min(frame.Height, maxRetainedRows) {
297		rowStarts = make([]int, 0, min(frame.Height, maxRetainedRows))
298	}
299	defer func() {
300		// Keep scratch for frames up to the common 240x80 size only, so one
301		// huge snapshot does not pin its buffers for the renderer lifetime.
302		const maxRetainedCells = 240 * maxRetainedRows
303		if cap(ends) <= maxRetainedCells && cap(text) <= 4*maxRetainedCells && cap(rowStarts) <= maxRetainedRows {
304			r.ends, r.text, r.rowStarts = ends, text, rowStarts
305		}
306	}()
307	for y := range frame.Height {
308		if !snapshot && core.RowsEqualAt(frame, y, r.committed, y) {
309			continue
310		}
311		row := readRow(frame, y, scratch)
312		if len(update.Rows) >= r.limits.MaxRowsPerUpdate {
313			return Update{}, fmt.Errorf("%w: update rows exceed limit %d", ErrLimitExceeded, r.limits.MaxRowsPerUpdate)
314		}
315		rowStarts = append(rowStarts, len(cells))
316		update.Rows = append(update.Rows, RowUpdate{Row: y})
317		var err error
318		cells, ends, text, err = encodeRow(row, cells, ends, text, &update.Styles, styleIDs, r.limits.MaxStyles)
319		if err != nil {
320			return Update{}, err
321		}
322	}
323	all := string(text)
324	offset := 0
325	for i := range cells {
326		cells[i].Text = all[offset:ends[i]]
327		offset = ends[i]
328	}
329	for i := range update.Rows {
330		next := len(cells)
331		if i+1 < len(rowStarts) {
332			next = rowStarts[i+1]
333		}
334		update.Rows[i].Cells = cells[rowStarts[i]:next:next]
335	}
336	return update, nil
337}
338
339// encodeBoundedUpdate encodes one update and enforces MaxGeneratedBytes on
340// the exact encoded size. The previous conservative estimate could reject
341// compact updates well below the configured limit.
342func encodeBoundedUpdate(update Update, limit int) ([]byte, error) {
343	// The hint never exceeds limit, so an oversized update cannot reserve its
344	// full estimate before the limit rejects it. limit+1 would overflow for
345	// math.MaxInt.
346	encoded, err := appendUpdateJSON(make([]byte, 0, min(estimateUpdateJSONSize(update), limit)), update)
347	if err != nil {
348		return nil, fmt.Errorf("html: encode update: %w", err)
349	}
350	if len(encoded) > limit {
351		return nil, fmt.Errorf("%w: generated update is %d bytes, limit is %d", ErrLimitExceeded, len(encoded), limit)
352	}
353	return encoded, nil
354}
355
356// runText returns the single-column printable ASCII text of cell, treating a
357// blank cell as a space, or false when the cell cannot join a text run.
358func runText(cell core.Cell) (byte, bool) {
359	switch {
360	case cell.Continuation:
361		return 0, false
362	case cell.Rune == 0:
363		return ' ', true
364	case cell.Rune >= 0x20 && cell.Rune < 0x7f:
365		return byte(cell.Rune), true
366	default:
367		return 0, false
368	}
369}
370
371// encodeRow appends one CellUpdate per text run of a row to cells, its text
372// to text, and the text end offset to ends. Adjacent printable ASCII or blank
373// cells with equal styles merge into one run whose Width equals its byte
374// length. Every other cell (wide or non-ASCII) stays a single entry, so
375// browsers never rely on a fallback font's advance for column alignment.
376// Text fields are filled by the caller.
377func encodeRow(row []core.Cell, cells []CellUpdate, ends []int, text []byte, styles *[]Style, styleIDs map[Style]int, maxStyles int) ([]CellUpdate, []int, []byte, error) {
378	for x := 0; x < len(row); x++ {
379		cell := row[x]
380		if cell.Continuation {
381			continue
382		}
383		style := styleFromCore(cell.Style)
384		styleID, ok := styleIDs[style]
385		if !ok {
386			if len(*styles) >= maxStyles {
387				return cells, ends, text, fmt.Errorf("%w: update styles exceed limit %d", ErrLimitExceeded, maxStyles)
388			}
389			styleID = len(*styles)
390			styleIDs[style] = styleID
391			*styles = append(*styles, style)
392		}
393		if first, ok := runText(cell); ok {
394			start := x
395			text = append(text, first)
396			for x+1 < len(row) && row[x+1].Style.Equal(cell.Style) {
397				next, ok := runText(row[x+1])
398				if !ok {
399					break
400				}
401				text = append(text, next)
402				x++
403			}
404			cells = append(cells, CellUpdate{Column: start, Width: x - start + 1, Style: styleID})
405		} else {
406			text = utf8.AppendRune(text, cell.Rune)
407			cells = append(cells, CellUpdate{Column: x, Width: core.RuneWidth(cell.Rune), Style: styleID})
408		}
409		ends = append(ends, len(text))
410	}
411	return cells, ends, text, nil
412}
413
414func validateCoreStyle(style core.Style) error {
415	const knownAttrs = core.AttrDim | core.AttrUnderline | core.AttrBlink | core.AttrStrikethrough
416	if style.Attrs & ^knownAttrs != 0 {
417		return fmt.Errorf("unknown style attribute bits %#x", style.Attrs&^knownAttrs)
418	}
419	if style.UnderlineStyle > core.UnderlineDashed {
420		return fmt.Errorf("invalid underline style %d", style.UnderlineStyle)
421	}
422	if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > 255) {
423		return fmt.Errorf("invalid foreground index %d", style.Foreground)
424	}
425	if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > 255) {
426		return fmt.Errorf("invalid background index %d", style.Background)
427	}
428	if !style.HasUnderlineColorRGB && style.HasUnderlineColor && (style.UnderlineColor < 0 || style.UnderlineColor > 255) {
429		return fmt.Errorf("invalid underline color index %d", style.UnderlineColor)
430	}
431	return nil
432}
433
434func styleFromCore(style core.Style) Style {
435	return Style{
436		Bold:           style.Bold,
437		Italic:         style.Italic,
438		Inverse:        style.Inverse,
439		Dim:            style.Attrs&core.AttrDim != 0,
440		Blink:          style.Attrs&core.AttrBlink != 0,
441		Strikethrough:  style.Attrs&core.AttrStrikethrough != 0,
442		Underline:      style.Attrs&core.AttrUnderline != 0,
443		UnderlineStyle: style.UnderlineStyle,
444		Foreground:     colorFromCore(style.Foreground, style.HasForegroundRGB, style.ForegroundRGB),
445		Background:     colorFromCore(style.Background, style.HasBackgroundRGB, style.BackgroundRGB),
446		UnderlineColor: underlineColorFromCore(style),
447	}
448}
449
450// Prepare validates every indexed color before these uint8 conversions.
451func colorFromCore(index int, hasRGB bool, rgb core.RGB) Color {
452	if hasRGB {
453		return Color{Kind: ColorRGB, RGB: RGB{R: rgb.R, G: rgb.G, B: rgb.B}}
454	}
455	if index >= 0 {
456		return Color{Kind: ColorIndexed, Index: uint8(index)}
457	}
458	return Color{Kind: ColorDefault}
459}
460
461func underlineColorFromCore(style core.Style) Color {
462	if style.HasUnderlineColorRGB {
463		return Color{Kind: ColorRGB, RGB: RGB{R: style.UnderlineColorRGB.R, G: style.UnderlineColorRGB.G, B: style.UnderlineColorRGB.B}}
464	}
465	if style.HasUnderlineColor {
466		return Color{Kind: ColorIndexed, Index: uint8(style.UnderlineColor)}
467	}
468	return Color{Kind: ColorDefault}
469}
470
471// Update returns an owned copy that remains valid after later renderer work.
472func (p *PreparedDraw) Update() Update {
473	if p == nil || p.tx == nil {
474		return Update{}
475	}
476	return cloneUpdate(p.tx.update)
477}
478
479// JSON returns an owned canonical JSON representation of Update.
480func (p *PreparedDraw) JSON() []byte {
481	if p == nil || p.tx == nil {
482		return nil
483	}
484	return append([]byte(nil), p.tx.json...)
485}
486
487// Commit atomically advances the renderer shadow exactly once.
488func (p *PreparedDraw) Commit() error {
489	if p == nil || p.tx == nil || p.tx.renderer == nil {
490		return ErrStaleDraw
491	}
492	tx := p.tx
493	if tx.state != transactionPending {
494		return ErrFinalizedDraw
495	}
496	r := tx.renderer
497	if tx.generation != r.generation || r.pending != tx {
498		return ErrStaleDraw
499	}
500	r.spare = r.committed
501	r.committed = tx.frame
502	r.cursor = tx.cursor
503	r.initialized = true
504	r.pending = nil
505	tx.state = transactionCommitted
506	return nil
507}
508
509// Abort finalizes the transaction without advancing the renderer shadow.
510func (p *PreparedDraw) Abort() error {
511	if p == nil || p.tx == nil || p.tx.renderer == nil {
512		return ErrStaleDraw
513	}
514	tx := p.tx
515	if tx.state != transactionPending {
516		return ErrFinalizedDraw
517	}
518	r := tx.renderer
519	if tx.generation != r.generation || r.pending != tx {
520		return ErrStaleDraw
521	}
522	r.spare = tx.frame
523	r.pending = nil
524	tx.state = transactionAborted
525	return nil
526}
527
528// Reset invalidates any prepared draw and clears committed state.
529func (r *Renderer) Reset() {
530	if r == nil {
531		return
532	}
533	r.generation++
534	r.pending = nil
535	r.committed = core.Frame{}
536	r.spare = core.Frame{}
537	r.cursor = Cursor{}
538	r.initialized = false
539}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close