Skip to content

Commit 98088a5

Browse files
committed
nook: add minimap, the overview-model foundation for the minimap gutter
1 parent 8d9470a commit 98088a5

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Package minimap models nook's document overview gutter: the narrow column
2+
// that shows a whole file downsampled to the editor's height with the current
3+
// viewport highlighted. Like splitlayout, it is pure geometry — no I/O, no
4+
// buffer access, constant-time construction — so it never blocks a frame and
5+
// stays inside nook's first-paint rule. The host feeds it a line count and the
6+
// viewport bounds and asks it three things: which source lines a minimap row
7+
// represents (so the host can render per-row density from the buffer), which
8+
// minimap rows the viewport covers (for the highlight band), and which source
9+
// line a clicked row maps to (for click-to-scroll). What a row looks like is
10+
// the host's concern; where a row sits is this package's.
11+
package minimap
12+
13+
// Model is an immutable snapshot of the overview geometry for one frame. All
14+
// fields are in logical-line space; the host collapses soft wrap before
15+
// filling it in. Construct one per render from the current buffer and viewport
16+
// and throw it away — there is no mutable state to keep.
17+
type Model struct {
18+
// TotalLines is the document's logical line count (>= 0).
19+
TotalLines int
20+
// MapHeight is the number of terminal rows the minimap column occupies.
21+
MapHeight int
22+
// ViewportTop is the 0-based first logical line visible in the editor.
23+
ViewportTop int
24+
// ViewportHeight is the number of logical lines visible in the editor.
25+
ViewportHeight int
26+
}
27+
28+
// Active reports whether the minimap has anything to draw. A zero-height column
29+
// or an empty document has no overview, and every other method degrades to a
30+
// safe empty answer when this is false.
31+
func (m Model) Active() bool {
32+
return m.MapHeight > 0 && m.TotalLines > 0
33+
}
34+
35+
// Downsampled reports whether the document is taller than the column, so each
36+
// minimap row stands in for more than one source line. When false the mapping
37+
// is identity and top-aligned: source line r draws on row r.
38+
func (m Model) Downsampled() bool {
39+
return m.TotalLines > m.MapHeight
40+
}
41+
42+
// LineRange returns the half-open span of source lines [start, end) that
43+
// minimap row draws. Rows past the end of a short document return an empty
44+
// span (start == end) so the host paints them blank. Out-of-range rows return
45+
// an empty span at the document end.
46+
func (m Model) LineRange(row int) (start, end int) {
47+
if !m.Active() || row < 0 || row >= m.MapHeight {
48+
return m.TotalLines, m.TotalLines
49+
}
50+
if !m.Downsampled() {
51+
if row < m.TotalLines {
52+
return row, row + 1
53+
}
54+
return m.TotalLines, m.TotalLines
55+
}
56+
start = row * m.TotalLines / m.MapHeight
57+
end = (row + 1) * m.TotalLines / m.MapHeight
58+
return start, end
59+
}
60+
61+
// RowForLine returns the minimap row that contains source line, clamped into
62+
// [0, MapHeight-1]. It is the inverse of LineRange: RowForLine of a row's start
63+
// line lands back on that row.
64+
func (m Model) RowForLine(line int) int {
65+
if !m.Active() {
66+
return 0
67+
}
68+
if line < 0 {
69+
line = 0
70+
}
71+
if line > m.TotalLines-1 {
72+
line = m.TotalLines - 1
73+
}
74+
var row int
75+
if m.Downsampled() {
76+
// Exact inverse of LineRange's floor(row*Total/Height) tiling: the row
77+
// whose half-open span contains line. A plain line*Height/Total floor
78+
// lands one row early at some tile boundaries, so bias by the tile.
79+
row = ((line+1)*m.MapHeight - 1) / m.TotalLines
80+
} else {
81+
row = line
82+
}
83+
return clamp(row, 0, m.MapHeight-1)
84+
}
85+
86+
// ViewportWindow returns the half-open span of minimap rows [start, end) the
87+
// current viewport covers, for drawing the highlight band. The band is always
88+
// at least one row tall on an active map so it never disappears on a document
89+
// far taller than the column. Returns an empty span when the map is inactive
90+
// or the viewport has no height.
91+
func (m Model) ViewportWindow() (start, end int) {
92+
if !m.Active() || m.ViewportHeight <= 0 {
93+
return 0, 0
94+
}
95+
top := clamp(m.ViewportTop, 0, m.TotalLines-1)
96+
bottom := clamp(m.ViewportTop+m.ViewportHeight-1, 0, m.TotalLines-1)
97+
start = m.RowForLine(top)
98+
end = m.RowForLine(bottom) + 1
99+
if end <= start {
100+
end = start + 1
101+
}
102+
return clamp(start, 0, m.MapHeight), clamp(end, 0, m.MapHeight)
103+
}
104+
105+
// LineForRow returns the source line to scroll to when the user clicks minimap
106+
// row: the first line that row represents, clamped into the document. Used to
107+
// turn a click at a y offset into a scroll target.
108+
func (m Model) LineForRow(row int) int {
109+
if !m.Active() {
110+
return 0
111+
}
112+
start, _ := m.LineRange(clamp(row, 0, m.MapHeight-1))
113+
return clamp(start, 0, m.TotalLines-1)
114+
}
115+
116+
func clamp(v, lo, hi int) int {
117+
if v < lo {
118+
return lo
119+
}
120+
if v > hi {
121+
return hi
122+
}
123+
return v
124+
}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
package minimap
2+
3+
import "testing"
4+
5+
func TestActive(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
m Model
9+
want bool
10+
}{
11+
{"zero", Model{}, false},
12+
{"no height", Model{TotalLines: 100, MapHeight: 0}, false},
13+
{"no lines", Model{TotalLines: 0, MapHeight: 20}, false},
14+
{"negative height", Model{TotalLines: 100, MapHeight: -5}, false},
15+
{"live", Model{TotalLines: 100, MapHeight: 20}, true},
16+
{"single line single row", Model{TotalLines: 1, MapHeight: 1}, true},
17+
}
18+
for _, tt := range tests {
19+
t.Run(tt.name, func(t *testing.T) {
20+
if got := tt.m.Active(); got != tt.want {
21+
t.Fatalf("Active() = %v, want %v", got, tt.want)
22+
}
23+
})
24+
}
25+
}
26+
27+
func TestDownsampled(t *testing.T) {
28+
if !(Model{TotalLines: 100, MapHeight: 20}).Downsampled() {
29+
t.Fatal("100 lines in 20 rows should downsample")
30+
}
31+
if (Model{TotalLines: 5, MapHeight: 10}).Downsampled() {
32+
t.Fatal("5 lines in 10 rows should not downsample")
33+
}
34+
if (Model{TotalLines: 10, MapHeight: 10}).Downsampled() {
35+
t.Fatal("equal lines and rows should not downsample")
36+
}
37+
}
38+
39+
func TestLineRangeInactive(t *testing.T) {
40+
m := Model{TotalLines: 0, MapHeight: 10}
41+
start, end := m.LineRange(0)
42+
if start != 0 || end != 0 {
43+
t.Fatalf("inactive LineRange = (%d,%d), want (0,0)", start, end)
44+
}
45+
}
46+
47+
func TestLineRangeOutOfRange(t *testing.T) {
48+
m := Model{TotalLines: 100, MapHeight: 10}
49+
for _, row := range []int{-1, 10, 999} {
50+
start, end := m.LineRange(row)
51+
if start != 100 || end != 100 {
52+
t.Fatalf("LineRange(%d) = (%d,%d), want (100,100)", row, start, end)
53+
}
54+
}
55+
}
56+
57+
// When the document is shorter than the column, each source line draws on its
58+
// own row (identity, top-aligned) and rows past the end draw blank.
59+
func TestLineRangeNotDownsampled(t *testing.T) {
60+
m := Model{TotalLines: 5, MapHeight: 10}
61+
for row := 0; row < 5; row++ {
62+
start, end := m.LineRange(row)
63+
if start != row || end != row+1 {
64+
t.Fatalf("LineRange(%d) = (%d,%d), want (%d,%d)", row, start, end, row, row+1)
65+
}
66+
}
67+
for row := 5; row < 10; row++ {
68+
start, end := m.LineRange(row)
69+
if start != end {
70+
t.Fatalf("LineRange(%d) = (%d,%d), want empty span", row, start, end)
71+
}
72+
}
73+
}
74+
75+
// A downsampled map tiles every source line across the rows with no gaps and no
76+
// overlaps: row r's end is row r+1's start, the first row starts at 0, and the
77+
// last row ends at TotalLines.
78+
func TestLineRangeDownsampledTiling(t *testing.T) {
79+
m := Model{TotalLines: 100, MapHeight: 10}
80+
prevEnd := 0
81+
for row := 0; row < m.MapHeight; row++ {
82+
start, end := m.LineRange(row)
83+
if start != prevEnd {
84+
t.Fatalf("row %d starts at %d, want %d (contiguous)", row, start, prevEnd)
85+
}
86+
if end < start {
87+
t.Fatalf("row %d span (%d,%d) is inverted", row, start, end)
88+
}
89+
prevEnd = end
90+
}
91+
if prevEnd != m.TotalLines {
92+
t.Fatalf("last row ends at %d, want %d", prevEnd, m.TotalLines)
93+
}
94+
}
95+
96+
// Uneven division must still tile without gaps: 7 lines over 3 rows.
97+
func TestLineRangeUnevenTiling(t *testing.T) {
98+
m := Model{TotalLines: 7, MapHeight: 3}
99+
prevEnd := 0
100+
for row := 0; row < m.MapHeight; row++ {
101+
start, end := m.LineRange(row)
102+
if start != prevEnd {
103+
t.Fatalf("row %d starts at %d, want %d", row, start, prevEnd)
104+
}
105+
prevEnd = end
106+
}
107+
if prevEnd != 7 {
108+
t.Fatalf("tiling covered %d lines, want 7", prevEnd)
109+
}
110+
}
111+
112+
// RowForLine is the inverse of LineRange: the start line of a row maps back to
113+
// that row.
114+
func TestRowForLineInverseOfLineRange(t *testing.T) {
115+
for _, m := range []Model{
116+
{TotalLines: 100, MapHeight: 10},
117+
{TotalLines: 7, MapHeight: 3},
118+
{TotalLines: 10000, MapHeight: 20},
119+
} {
120+
for row := 0; row < m.MapHeight; row++ {
121+
start, end := m.LineRange(row)
122+
if start == end {
123+
continue // blank row on a short doc has no start line
124+
}
125+
if got := m.RowForLine(start); got != row {
126+
t.Fatalf("T=%d M=%d: RowForLine(start=%d) = %d, want %d",
127+
m.TotalLines, m.MapHeight, start, got, row)
128+
}
129+
}
130+
}
131+
}
132+
133+
func TestRowForLineClamps(t *testing.T) {
134+
m := Model{TotalLines: 100, MapHeight: 10}
135+
if got := m.RowForLine(-50); got != 0 {
136+
t.Fatalf("RowForLine(-50) = %d, want 0", got)
137+
}
138+
if got := m.RowForLine(9999); got != m.MapHeight-1 {
139+
t.Fatalf("RowForLine(9999) = %d, want %d", got, m.MapHeight-1)
140+
}
141+
}
142+
143+
func TestRowForLineInactive(t *testing.T) {
144+
if got := (Model{}).RowForLine(5); got != 0 {
145+
t.Fatalf("inactive RowForLine = %d, want 0", got)
146+
}
147+
}
148+
149+
func TestViewportWindowInactive(t *testing.T) {
150+
start, end := (Model{}).ViewportWindow()
151+
if start != 0 || end != 0 {
152+
t.Fatalf("inactive ViewportWindow = (%d,%d), want (0,0)", start, end)
153+
}
154+
}
155+
156+
func TestViewportWindowZeroHeight(t *testing.T) {
157+
m := Model{TotalLines: 100, MapHeight: 10, ViewportTop: 5, ViewportHeight: 0}
158+
start, end := m.ViewportWindow()
159+
if start != 0 || end != 0 {
160+
t.Fatalf("zero-height viewport = (%d,%d), want (0,0)", start, end)
161+
}
162+
}
163+
164+
// On a document far taller than the column the band must never vanish: it is at
165+
// least one row tall even when the viewport spans a fraction of a single row.
166+
func TestViewportWindowBandNeverVanishes(t *testing.T) {
167+
m := Model{TotalLines: 10000, MapHeight: 20, ViewportTop: 0, ViewportHeight: 10}
168+
start, end := m.ViewportWindow()
169+
if end-start < 1 {
170+
t.Fatalf("band = [%d,%d), want at least one row", start, end)
171+
}
172+
}
173+
174+
// A viewport covering the whole document should light the whole column.
175+
func TestViewportWindowFullDocument(t *testing.T) {
176+
m := Model{TotalLines: 50, MapHeight: 10, ViewportTop: 0, ViewportHeight: 50}
177+
start, end := m.ViewportWindow()
178+
if start != 0 || end != m.MapHeight {
179+
t.Fatalf("full-doc window = [%d,%d), want [0,%d)", start, end, m.MapHeight)
180+
}
181+
}
182+
183+
// The band tracks the viewport down the document and stays within the column.
184+
func TestViewportWindowTracksAndClamps(t *testing.T) {
185+
m := Model{TotalLines: 100, MapHeight: 10, ViewportTop: 90, ViewportHeight: 20}
186+
start, end := m.ViewportWindow()
187+
if start < 0 || end > m.MapHeight {
188+
t.Fatalf("window [%d,%d) escapes column [0,%d]", start, end, m.MapHeight)
189+
}
190+
if end <= start {
191+
t.Fatalf("window [%d,%d) is empty", start, end)
192+
}
193+
// Viewport at the bottom should light the last row.
194+
if end != m.MapHeight {
195+
t.Fatalf("bottom viewport window ends at %d, want %d", end, m.MapHeight)
196+
}
197+
}
198+
199+
func TestLineForRowRoundTrip(t *testing.T) {
200+
m := Model{TotalLines: 100, MapHeight: 10}
201+
for row := 0; row < m.MapHeight; row++ {
202+
line := m.LineForRow(row)
203+
if m.RowForLine(line) != row {
204+
t.Fatalf("LineForRow(%d)=%d does not round-trip via RowForLine", row, line)
205+
}
206+
}
207+
}
208+
209+
func TestLineForRowClampsAndStaysInDocument(t *testing.T) {
210+
m := Model{TotalLines: 100, MapHeight: 10}
211+
for _, row := range []int{-5, 0, 9, 50} {
212+
line := m.LineForRow(row)
213+
if line < 0 || line > m.TotalLines-1 {
214+
t.Fatalf("LineForRow(%d) = %d, out of document [0,%d]", row, line, m.TotalLines-1)
215+
}
216+
}
217+
}
218+
219+
func TestLineForRowInactive(t *testing.T) {
220+
if got := (Model{}).LineForRow(3); got != 0 {
221+
t.Fatalf("inactive LineForRow = %d, want 0", got)
222+
}
223+
}
224+
225+
// A single-line, single-row map is the smallest active shape; every method must
226+
// stay in bounds.
227+
func TestSingletonMap(t *testing.T) {
228+
m := Model{TotalLines: 1, MapHeight: 1, ViewportTop: 0, ViewportHeight: 1}
229+
start, end := m.LineRange(0)
230+
if start != 0 || end != 1 {
231+
t.Fatalf("LineRange(0) = (%d,%d), want (0,1)", start, end)
232+
}
233+
if got := m.RowForLine(0); got != 0 {
234+
t.Fatalf("RowForLine(0) = %d, want 0", got)
235+
}
236+
ws, we := m.ViewportWindow()
237+
if ws != 0 || we != 1 {
238+
t.Fatalf("ViewportWindow = [%d,%d), want [0,1)", ws, we)
239+
}
240+
if got := m.LineForRow(0); got != 0 {
241+
t.Fatalf("LineForRow(0) = %d, want 0", got)
242+
}
243+
}

0 commit comments

Comments
 (0)