Skip to content

Commit 8de939c

Browse files
committed
nook: add minimapview, the overview render layer over the geometry model
1 parent bd095fd commit 8de939c

3 files changed

Lines changed: 430 additions & 8 deletions

File tree

cmd/nook/ROADMAP.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,19 @@ Roughly in priority order.
8181
the first slice proves that keeps the binary lean before any host
8282
wiring lands.
8383
4. **Minimap.** Optional document overview gutter. Lowest priority;
84-
useful but not load-bearing for the editing experience. The geometry
85-
foundation is in place: `internal/minimap` is a pure, constant-time
86-
`Model` that maps between source lines and minimap rows (`LineRange`,
87-
`RowForLine`, `ViewportWindow` for the highlight band, `LineForRow`
88-
for click-to-scroll), fully unit-tested, no I/O, inside the
89-
first-paint rule. What remains is host wiring: rendering the column
90-
from per-row buffer density, drawing the viewport band, and routing
91-
click-to-scroll.
84+
useful but not load-bearing for the editing experience. Two of the
85+
three pure layers are in place, both fully unit-tested and inside the
86+
first-paint rule. `internal/minimap` is the geometry `Model` that maps
87+
between source lines and minimap rows (`LineRange`, `RowForLine`,
88+
`ViewportWindow` for the highlight band, `LineForRow` for
89+
click-to-scroll). `internal/minimapview` is the render layer the
90+
geometry deliberately left to the host: `LineFill` scores a line's
91+
code density, `Density` collapses per-line fills onto rows, and
92+
`Column` turns a `Model` plus densities into `Cell`s carrying a shade
93+
glyph, the viewport band, and blank-past-document flags. What remains
94+
is the host mount: computing line fills from the buffer each frame,
95+
painting the `Cell`s with theme styles beside the editor, and routing
96+
a click at a y offset through `LineForRow` to a scroll.
9297

9398
## How to pick the next slice
9499

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Package minimapview renders nook's minimap column: it owns the "what a row
2+
// looks like" half of the overview gutter that the minimap package deliberately
3+
// leaves to the host. Given a minimap.Model (where each row sits) and the code
4+
// density of the document's lines (how full each line is), it produces the
5+
// per-row cells the host paints — the shade ramp that stands in for content and
6+
// the viewport highlight band. Like minimap itself it is pure geometry over
7+
// numbers: no I/O, no buffer access, no lipgloss, constant-time per frame, so it
8+
// stays inside nook's first-paint rule. The host computes a fill per line once
9+
// from the buffer, maps it onto rows with Density, turns the rows into Cells
10+
// with Column, and applies its own theme styles to Glyph and InViewport.
11+
package minimapview
12+
13+
import "github.com/truffle-dev/glyph/cmd/nook/internal/minimap"
14+
15+
// Shade ramp, from empty to full. A row's code density picks one glyph: a blank
16+
// region reads as space, a dense region as a full block, with three shades in
17+
// between so the overview has depth instead of an on/off silhouette.
18+
const (
19+
shadeEmpty = ' '
20+
shadeLight = '░' // U+2591
21+
shadeMedium = '▒' // U+2592
22+
shadeDark = '▓' // U+2593
23+
shadeFull = '█' // U+2588
24+
)
25+
26+
// Cell is the drawable state of one minimap row, ready for the host to style.
27+
// Glyph is the density shade; InViewport marks the rows under the highlight
28+
// band; Blank marks rows past the end of a short document, which paint empty
29+
// regardless of density.
30+
type Cell struct {
31+
Glyph rune
32+
InViewport bool
33+
Blank bool
34+
}
35+
36+
// Shade maps a code-density fraction to the overview ramp glyph. Values are
37+
// clamped to [0,1]; exactly zero is the empty glyph, and the four filled bands
38+
// split the remainder into quarters so a barely-filled row still shows.
39+
func Shade(density float64) rune {
40+
switch {
41+
case density <= 0:
42+
return shadeEmpty
43+
case density <= 0.25:
44+
return shadeLight
45+
case density <= 0.5:
46+
return shadeMedium
47+
case density <= 0.75:
48+
return shadeDark
49+
default:
50+
return shadeFull
51+
}
52+
}
53+
54+
// LineFill returns the code density of a single source line in [0,1]: the
55+
// fraction of its runes that are not whitespace. A blank or whitespace-only
56+
// line reads as empty (0), a line with no whitespace as full (1), and typical
57+
// code somewhere between so indentation and sparse lines read lighter than
58+
// dense ones. Rune-counted, not byte-counted, so multi-byte source measures
59+
// the same as ASCII.
60+
func LineFill(line string) float64 {
61+
total := 0
62+
code := 0
63+
for _, r := range line {
64+
total++
65+
if !isSpace(r) {
66+
code++
67+
}
68+
}
69+
if total == 0 {
70+
return 0
71+
}
72+
return float64(code) / float64(total)
73+
}
74+
75+
func isSpace(r rune) bool {
76+
return r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\v' || r == '\f'
77+
}
78+
79+
// Density collapses per-line fills onto minimap rows for a Model. fills[i] is
80+
// the code density of logical line i (see LineFill); the host computes it once
81+
// from the buffer. Each returned entry is the mean fill of the lines its row
82+
// covers (minimap.LineRange), so a downsampled row reads as the average density
83+
// of the block it stands for. The result is always m.MapHeight long (empty when
84+
// the map is inactive); rows past a short document average to 0. A line index
85+
// beyond len(fills) counts as 0 so a stale-but-shorter fills slice degrades to
86+
// a lighter overview rather than panicking.
87+
func Density(m minimap.Model, fills []float64) []float64 {
88+
if m.MapHeight <= 0 {
89+
return nil
90+
}
91+
out := make([]float64, m.MapHeight)
92+
if !m.Active() {
93+
return out
94+
}
95+
for row := 0; row < m.MapHeight; row++ {
96+
start, end := m.LineRange(row)
97+
if end <= start {
98+
continue // past the document: leave 0
99+
}
100+
sum := 0.0
101+
for line := start; line < end; line++ {
102+
if line >= 0 && line < len(fills) {
103+
sum += fills[line]
104+
}
105+
}
106+
out[row] = sum / float64(end-start)
107+
}
108+
return out
109+
}
110+
111+
// Column builds the per-row cells for the whole minimap. density should be
112+
// m.MapHeight long (as Density returns); a shorter slice treats the missing
113+
// rows as empty and a longer one ignores the tail, so the caller never has to
114+
// size-match exactly. Rows whose LineRange is empty (past a short document) are
115+
// Blank; rows inside m.ViewportWindow() are InViewport. The result is always
116+
// m.MapHeight long, or empty when the column has no rows.
117+
func Column(m minimap.Model, density []float64) []Cell {
118+
if m.MapHeight <= 0 {
119+
return nil
120+
}
121+
cells := make([]Cell, m.MapHeight)
122+
vStart, vEnd := m.ViewportWindow()
123+
for row := 0; row < m.MapHeight; row++ {
124+
c := Cell{Glyph: shadeEmpty}
125+
if !m.Active() {
126+
c.Blank = true
127+
cells[row] = c
128+
continue
129+
}
130+
start, end := m.LineRange(row)
131+
if end <= start {
132+
c.Blank = true
133+
} else {
134+
d := 0.0
135+
if row < len(density) {
136+
d = density[row]
137+
}
138+
c.Glyph = Shade(d)
139+
}
140+
if row >= vStart && row < vEnd {
141+
c.InViewport = true
142+
}
143+
cells[row] = c
144+
}
145+
return cells
146+
}
147+
148+
// Runes returns just the glyph column, one row per line, newline-joined and
149+
// with no viewport styling. It is the plain-text view of a Column — handy for
150+
// tests and for a host that paints the overview without color.
151+
func Runes(cells []Cell) string {
152+
if len(cells) == 0 {
153+
return ""
154+
}
155+
out := make([]rune, 0, len(cells)*2)
156+
for i, c := range cells {
157+
if i > 0 {
158+
out = append(out, '\n')
159+
}
160+
out = append(out, c.Glyph)
161+
}
162+
return string(out)
163+
}

0 commit comments

Comments
 (0)