From c52fa0a93b3efd5fc160cfea4a68674ee6163abd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 09:25:08 +0200 Subject: [PATCH] feat: minimap for CodeEditor (TextView.ShowMinimap), with line marks TextView gains ShowMinimap (off by default; needs ShowScrollbars): the existing CodeMinimap now sits between the text and the vertical scrollbar, fed with the editor's own token colours. The visible range is the slider: dragging it keeps the pointer on the same spot, pressing elsewhere centres the window on that line. It takes its own columns from the text window and hides itself in a window narrower than four minimap widths. CodeMinimap gains Marks (error / warning / match ticks on its right edge) and TextView/CodeEditor gain SetMinimapMarks; search matches from SetMatchHighlights are marked automatically. The slider outline and the match ticks keep >= 3:1 against the minimap panel (the accent is pulled toward OnSurface when it would not), the red/amber ticks are chosen per theme lightness for the same bound. Co-Authored-By: Claude Opus 5.5 --- codeeditor.go | 1 + codeminimap.go | 71 +++++++++++++- codeminimap_test.go | 10 +- mvvm_gate_test.go | 4 +- popupframe.go | 16 +++ textview.go | 24 ++++- textview_minimap.go | 127 ++++++++++++++++++++++++ textview_minimap_test.go | 207 +++++++++++++++++++++++++++++++++++++++ textview_scroll.go | 6 +- 9 files changed, 452 insertions(+), 14 deletions(-) create mode 100644 textview_minimap.go create mode 100644 textview_minimap_test.go diff --git a/codeeditor.go b/codeeditor.go index 5a8a30e..4821a46 100644 --- a/codeeditor.go +++ b/codeeditor.go @@ -152,6 +152,7 @@ func NewCodeEditor(initial string) *CodeEditor { func (c *CodeEditor) Draw(p painter.Painter, theme *Theme) { c.lastTheme = theme c.refresh(theme) + c.TextView.mmSpans = c.cacheSpans // the minimap paints the editor's own token colours c.buildMatchBands(theme) c.TextView.Draw(p, theme) // The completion popup floats over the editor at the caret; drawn last so diff --git a/codeminimap.go b/codeminimap.go index 641638e..ff87179 100644 --- a/codeminimap.go +++ b/codeminimap.go @@ -10,6 +10,53 @@ import ( "github.com/go-widgets/painter" ) +// MarkKind says what a [MinimapMark] flags; it picks the tick's colour. +type MarkKind int + +const ( + // MarkMatch is a search match (drawn in the theme's accent). + MarkMatch MarkKind = iota + // MarkError is a build or lint error. + MarkError + // MarkWarning is a warning. + MarkWarning +) + +// MinimapMark flags one 0-based buffer Line on a [CodeMinimap] (and, through +// [CodeEditor.SetMinimapMarks], on a CodeEditor's minimap). +type MinimapMark struct { + Line int + Kind MarkKind +} + +// minimapMarkColor is the tick colour for kind on a minimap whose panel is the +// theme's SurfaceAlt: the accent for a match, and a red / amber chosen for the +// panel's lightness so the tick keeps WCAG 2.2 1.4.11's 3:1 against it in the +// light and the dark theme. +func minimapMarkColor(theme *Theme, kind MarkKind) RGBA { + dark := relativeLuminance(theme.SurfaceAlt) < 0.18 + switch kind { + case MarkError: + if dark { + return RGB(0xFF, 0x6B, 0x6B) + } + return RGB(0xC6, 0x28, 0x28) + case MarkWarning: + if dark { + return RGB(0xFF, 0xC1, 0x07) + } + return RGB(0x9A, 0x5B, 0x00) + } + return minimapAccent(theme) +} + +// minimapAccent is the theme's accent, pulled toward OnSurface when it would sit +// under 3:1 on the minimap panel (SurfaceAlt) — the slider's outline and the +// match ticks are what a person looks for on it. +func minimapAccent(theme *Theme) RGBA { + return ensureContrast(theme.Accent, theme.SurfaceAlt, theme.OnSurface, 3) +} + // CodeMinimap is a VS Code-style code overview: a scaled-down thumbnail of a // source buffer where each line is a row of tiny coloured segments — one short // segment per run of non-space characters, coloured by the syntax token under @@ -41,6 +88,14 @@ type CodeMinimap struct { // need not set it. OnScrollToLine func(line int) + // Marks are line markers drawn as short ticks on the overview's right edge — + // the overview-ruler role of VS Code's minimap: a build error, a warning, a + // search match, each at the row its line maps to. A host (or the CodeEditor + // that embeds the minimap) refreshes them before a paint; nil draws none. They + // are painted over the token segments and under the viewport band, and cost + // one fillRect each, so thousands of matches stay cheap. + Marks []MinimapMark + lines []string // reference to the editor buffer lines spans [][]TextSpan // per-line syntax spans (may be shorter/nil) top int // first visible buffer line (editor scroll offset) @@ -222,6 +277,20 @@ func (m *CodeMinimap) Draw(p painter.Painter, theme *Theme) { fillRect(p, seg.X, seg.Y, seg.W, seg.H, c) }) + // Line marks (errors, warnings, matches): a tick on the right edge of the row + // each line maps to, so a mark is visible whatever the sampling. + if len(m.Marks) > 0 { + rowH, displayRows := m.metrics() + tickW := atLeast1(scaled(3)) + for _, mk := range m.Marks { + if mk.Line < 0 || mk.Line >= n { + continue + } + row := rowForLine(mk.Line, displayRows, n) + fillRect(p, r.X+r.W-tickW, r.Y+row*rowH, tickW, atLeast1(rowH-scaled(1)), minimapMarkColor(theme, mk.Kind)) + } + } + // Viewport indicator: a translucent accent band over the visible range, // mapped through the SAME fixed-row geometry so it lines up with the rows. if m.visible > 0 { @@ -242,7 +311,7 @@ func (m *CodeMinimap) Draw(p painter.Painter, theme *Theme) { vh = contentH - (vy - r.Y) } fillRect(p, r.X, vy, r.W, vh, RGBA{R: theme.Accent.R, G: theme.Accent.G, B: theme.Accent.B, A: 0x33}) - strokeRect(p, r.X, vy, r.W, vh, theme.Accent) + strokeRect(p, r.X, vy, r.W, vh, minimapAccent(theme)) } } diff --git a/codeminimap_test.go b/codeminimap_test.go index 8a13985..bbbf74c 100644 --- a/codeminimap_test.go +++ b/codeminimap_test.go @@ -162,11 +162,11 @@ func TestCodeMinimapViewportBand(t *testing.T) { m.Update(tenLines, nil, 2, 3) // displayRows=10, rowH=3 → band y6..14 buf := makeSurface(w, h) m.Draw(newP(buf, w), theme) - if !scanHasColor(buf, w, 0, 6, w-1, 14, theme.Accent) { + if !scanHasColor(buf, w, 0, 6, w-1, 14, minimapAccent(theme)) { t.Fatal("viewport band stroke (Accent) not painted over the visible range") } // No band above the visible range (rows 0..1 carry only OnSurface glyphs). - if scanHasColor(buf, w, 0, 0, w-1, 5, theme.Accent) { + if scanHasColor(buf, w, 0, 0, w-1, 5, minimapAccent(theme)) { t.Fatal("Accent painted above the visible range — band misplaced") } }) @@ -182,7 +182,7 @@ func TestCodeMinimapViewportBand(t *testing.T) { // The clamped band's bottom stroke lands at y=5 (0+6-1); an unclamped // 3-px band would have its bottom stroke at y=2, so Accent at y=5 proves // the minimum-height clamp fired. - if !scanHasColor(buf, w, 0, 5, w-1, 5, theme.Accent) { + if !scanHasColor(buf, w, 0, 5, w-1, 5, minimapAccent(theme)) { t.Fatal("min-height clamp did not extend the band to scaled(6)") } }) @@ -196,12 +196,12 @@ func TestCodeMinimapViewportBand(t *testing.T) { buf := makeSurface(w, h) m.Draw(newP(buf, w), theme) // Clamped: band bottom stroke at y=29 (27+3-1), inside the content. - if !scanHasColor(buf, w, 0, 27, w-1, 29, theme.Accent) { + if !scanHasColor(buf, w, 0, 27, w-1, 29, minimapAccent(theme)) { t.Fatal("band not painted at the bottom of the content") } // Unclamped it would have spilled to y=30..32 (past the 30-tall minimap); // assert nothing painted there. - if scanHasColor(buf, w, 0, 30, w-1, h-1, theme.Accent) { + if scanHasColor(buf, w, 0, 30, w-1, h-1, minimapAccent(theme)) { t.Fatal("band spilled past the content height — bottom clamp failed") } }) diff --git a/mvvm_gate_test.go b/mvvm_gate_test.go index 0ada05c..953c495 100644 --- a/mvvm_gate_test.go +++ b/mvvm_gate_test.go @@ -119,7 +119,7 @@ var mvvmMigratedWidgets = map[string]map[string]bool{ // reactive state at all. Its only exported field, OnScrollToLine, is a func // hook (auto-allowed), so the empty config set enforces that no imperative // state field is ever added. - "CodeMinimap": {}, + "CodeMinimap": {"Marks": true}, // Entry: its committed contents live on the Text() Observable; the cursor // index and the in-flight IME preview are unexported internal editing state; // OnSubmit is an action func hook. Placeholder + Mask are set-once @@ -202,7 +202,7 @@ var mvvmMigratedWidgets = map[string]map[string]bool{ // unexported internal editing state. Highlighter/RowBackground are func hooks // (auto-allowed). Decorations (host-driven co-editor carets), ShowLineNumbers // and GutterColor are set-once display config. - "TextView": {"Decorations": true, "ShowLineNumbers": true, "GutterColor": true, "ShowScrollbars": true}, + "TextView": {"Decorations": true, "ShowLineNumbers": true, "GutterColor": true, "ShowScrollbars": true, "ShowMinimap": true}, // Scroll/overlay/structural batch: each widget's reactive state is now unexported // behind an Observable accessor — ScrollView's offsetX/offsetY, Scrollbar's offset, // Paned's position, ContextMenu's open, CommandPalette's visible, DropZone's hover, diff --git a/popupframe.go b/popupframe.go index 7649fd2..49639fb 100644 --- a/popupframe.go +++ b/popupframe.go @@ -54,6 +54,22 @@ func PopupBorder(theme *Theme) RGBA { return theme.OnSurface } +// ensureContrast returns c if it reaches min contrast against ground, otherwise +// c pulled toward the colour toward in 4% steps until it does (toward itself when +// no mix gets there). It keeps c's hue wherever it can, so an accent stays an +// accent while becoming legible on a pale or dark panel. +func ensureContrast(c, ground, toward RGBA, min float64) RGBA { + if contrastRatio(c, ground) >= min { + return c + } + for i := 1; i <= 25; i++ { + if m := blendRGBA(toward, c, float64(i)*0.04); contrastRatio(m, ground) >= min { + return m + } + } + return toward +} + // strokePopupBorder outlines a popup's rectangle with [PopupBorder]. func strokePopupBorder(p painter.Painter, r Rect, theme *Theme) { strokeRect(p, r.X, r.Y, r.W, r.H, PopupBorder(theme)) diff --git a/textview.go b/textview.go index e0f1163..44c3a07 100644 --- a/textview.go +++ b/textview.go @@ -136,6 +136,21 @@ type TextView struct { // before the field existed. CodeEditor turns it on. See textview_scroll.go. ShowScrollbars bool + // ShowMinimap, when true (and ShowScrollbars is), shows a minimap — a + // scaled-down overview of the whole buffer with the visible range as a slider — + // left of the vertical scrollbar. Zero value false. See textview_minimap.go. + ShowMinimap bool + + // minimap and friends: the embedded overview widget, the syntax spans the + // CodeEditor feeds it, the caller's marks (+ scratch buffer for marks plus + // matches), and the slider-grab state of a press-drag on it. + minimap *CodeMinimap + mmSpans [][]TextSpan + mmMarks []MinimapMark + mmBuf []MinimapMark + mmGrab int + mmDrag bool + // hscroll is the horizontal scroll offset in device pixels (ScrollX); only // non-zero while ShowScrollbars is true. colsOK/colsMax cache the widest line in // runes so the scrollbar geometry is O(1) per draw instead of a pass over the @@ -382,6 +397,7 @@ func (t *TextView) Draw(p painter.Painter, theme *Theme) { clipR.W = r.W - 1 - scrollbarTrack() txtR.W -= 1 + scrollbarTrack() } + txtR.W -= t.minimapW() if hb { clipR.H = r.H - 1 - scrollbarTrack() txtR.H = clipR.H @@ -455,6 +471,7 @@ func (t *TextView) Draw(p painter.Painter, theme *Theme) { withClip(p, txtR, func() { eachRow(func(i, y int) { rowB(i, y); rowD(i, y) }) }) } t.drawScrollbars(p, theme, vb, hb) + t.drawMinimap(p, theme) // With scrollbars a caret (or co-editor caret) scrolled out of the text area // is not drawn: it would land on the gutter, a scrollbar or the border. inView := func(cx, cy int) bool { @@ -552,8 +569,8 @@ func (t *TextView) OnEvent(ev Event) { switch ev.Kind { case EventClick: t.Focused().Set(true) - if t.barPress(ev) { - return // the click landed on a scrollbar, not on the text + if t.barPress(ev) || t.minimapPress(ev) { + return // the click landed on a scrollbar or the minimap, not on the text } if len(t.lines) == 0 { return @@ -578,8 +595,9 @@ func (t *TextView) OnEvent(ev Event) { case EventMouseUp: t.vdrag.release() t.hdrag.release() + t.mmDrag = false case EventMouseDrag: - if t.barDrag(ev) { + if t.barDrag(ev) || t.minimapDrag(ev) { return // a scrollbar thumb is being dragged, not a text selection } if len(t.lines) == 0 { diff --git a/textview_minimap.go b/textview_minimap.go new file mode 100644 index 0000000..0d3e152 --- /dev/null +++ b/textview_minimap.go @@ -0,0 +1,127 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "github.com/go-widgets/painter" + +// TextView / CodeEditor minimap. +// +// With [TextView.ShowScrollbars] and [TextView.ShowMinimap] both set, a +// [CodeMinimap] — the scaled-down picture of the whole buffer the toolkit already +// had as a standalone widget — sits between the text and the vertical scrollbar, +// the arrangement of VS Code's and Sublime's. The visible range is the slider; +// pressing on the slider and dragging moves it with the pointer kept at the same +// spot on it, pressing anywhere else centres the viewport on that line, and the +// wheel over it scrolls as everywhere else. The overview is the "fit" kind of VS +// Code's minimap: one 1-px-per-character, 3-px-per-line picture that samples one +// line per row once the buffer is taller than the window (the standalone widget's +// documented behaviour), so its cost is bounded by the window height, never by the +// buffer length. Errors, warnings and search matches are ticks on its right edge. +// +// The minimap only exists when the text area stays wide enough to be worth +// reading (a window narrower than four minimap widths hides it), and it takes its +// own columns from the text window like the scrollbars do, so it never covers +// text, the gutter or a bar. + +// minimapWidth is the minimap's logical width: 1 px per character, so about 80 +// columns of code. +const minimapWidth = 90 + +// SetMinimapMarks replaces the line marks (errors, warnings …) the minimap shows, +// next to the search matches it shows by itself. +func (t *TextView) SetMinimapMarks(marks []MinimapMark) { t.mmMarks = marks } + +// MinimapMarks returns the marks last set by SetMinimapMarks. +func (t *TextView) MinimapMarks() []MinimapMark { return t.mmMarks } + +// minimapW is the minimap's width, 0 when it is off or the window is too narrow. +func (t *TextView) minimapW() int { + if !t.ShowScrollbars || !t.ShowMinimap { + return 0 + } + w := scaled(minimapWidth) + if t.Bounds().W < 4*w { + return 0 + } + return w +} + +// minimapRect is the minimap's rectangle in widget-local pixels: the inner +// height, left of the vertical bar when one shows. +func (t *TextView) minimapRect() Rect { + r := t.Bounds() + v, _ := t.bars() + w := t.minimapW() + x := r.W - 1 - w + if v { + x -= scrollbarTrack() + } + return Rect{X: x, Y: 1, W: w, H: r.H - 2} +} + +// syncMinimap points the embedded CodeMinimap at the current window and buffer +// (the minimap reads the editor's slices by reference; nothing is copied). +func (t *TextView) syncMinimap() *CodeMinimap { + if t.minimap == nil { + t.minimap = NewCodeMinimap() + } + r, rc := t.Bounds(), t.minimapRect() + m := t.minimap + m.SetBounds(Rect{X: r.X + rc.X, Y: r.Y + rc.Y, W: rc.W, H: rc.H}) + m.Update(t.lines, t.mmSpans, t.clampedScrollLine(), t.visibleLines()) + return m +} + +// drawMinimap paints the overview with the marks: the caller's, plus one per +// search match. +func (t *TextView) drawMinimap(p painter.Painter, theme *Theme) { + if t.minimapW() == 0 { + return + } + m := t.syncMinimap() + t.mmBuf = append(t.mmBuf[:0], t.mmMarks...) + for _, b := range t.matchBands { + t.mmBuf = append(t.mmBuf, MinimapMark{Line: b.sel.StartLine, Kind: MarkMatch}) + } + m.Marks = t.mmBuf + m.Draw(p, theme) +} + +// minimapPress handles a press on the minimap. True when it landed on it. +func (t *TextView) minimapPress(ev Event) bool { + if t.minimapW() == 0 { + return false + } + rc := t.minimapRect() + if ev.X < rc.X || ev.X >= rc.X+rc.W || ev.Y < rc.Y || ev.Y >= rc.Y+rc.H { + return false + } + m := t.syncMinimap() + line := m.LineAt(t.Bounds().Y + ev.Y) + top, vis := t.clampedScrollLine(), t.visibleLines() + if line >= top && line < top+vis { + t.mmGrab = line - top // on the slider: keep the pointer where it grabbed it + } else { + t.mmGrab = vis / 2 // elsewhere: centre the window on the line + } + t.mmDrag = true + t.minimapScrollTo(line) + return true +} + +// minimapDrag moves the slider with the pointer. True while a drag is active. +func (t *TextView) minimapDrag(ev Event) bool { + if !t.mmDrag { + return false + } + t.minimapScrollTo(t.syncMinimap().LineAt(t.Bounds().Y + ev.Y)) + return true +} + +// minimapScrollTo scrolls so that line sits mmGrab rows below the window top. +func (t *TextView) minimapScrollTo(line int) { + t.ScrollLine().Set(line - t.mmGrab) + t.ScrollLine().Set(t.clampedScrollLine()) +} diff --git a/textview_minimap_test.go b/textview_minimap_test.go new file mode 100644 index 0000000..8928690 --- /dev/null +++ b/textview_minimap_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 the go-widgets/toolkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package toolkit + +import "testing" + +func newMinimapEditor(n int) *CodeEditor { + c := NewCodeEditor(scrollDoc(n, 0, 0)) + c.SetBounds(Rect{X: 5, Y: 7, W: 400, H: 700}) + c.ShowMinimap = true + return c +} + +func TestMinimapOffByDefaultAndWhenNarrow(t *testing.T) { + c := NewCodeEditor(scrollDoc(100, 0, 0)) + c.SetBounds(Rect{W: 400, H: 700}) + if c.minimapW() != 0 { + t.Fatal("CodeEditor must not show a minimap unless asked") + } + c.ShowMinimap = true + if c.minimapW() != scaled(minimapWidth) { + t.Fatalf("minimapW = %d", c.minimapW()) + } + c.SetBounds(Rect{W: 3 * scaled(minimapWidth), H: 240}) + if c.minimapW() != 0 { + t.Fatal("a window narrower than four minimap widths must hide it") + } + plain := NewTextView(scrollDoc(100, 0, 0)) + plain.ShowMinimap = true // needs ShowScrollbars + plain.SetBounds(Rect{W: 400, H: 240}) + if plain.minimapW() != 0 { + t.Fatal("a minimap needs ShowScrollbars") + } +} + +// The minimap sits left of the vertical bar and takes its columns from the text +// window; the horizontal bar and the text stop at it. +func TestMinimapLayoutTakesItsOwnColumns(t *testing.T) { + c := newMinimapEditor(1000) + rc := c.minimapRect() + vg, _ := c.vbarGeom() + if rc.X+rc.W != vg.cross0 { + t.Fatalf("minimap ends at %d, the vertical bar starts at %d", rc.X+rc.W, vg.cross0) + } + if got, want := c.textAreaWidth(), rc.X-c.textLeftInset(); got != want { + t.Fatalf("textAreaWidth = %d, want %d (up to the minimap)", got, want) + } + off := newMinimapEditor(1000) + off.ShowMinimap = false + if off.textAreaWidth()-c.textAreaWidth() != scaled(minimapWidth) { + t.Fatal("the minimap must cost exactly its own width") + } + // A wide document's horizontal track also ends at the minimap. + w := NewCodeEditor(scrollDoc(1000, 1, 400)) + w.SetBounds(Rect{W: 400, H: 240}) + w.ShowMinimap = true + hg, _ := w.hbarGeom() + if hg.trackStart+hg.trackLen != w.minimapRect().X { + t.Fatalf("horizontal track ends at %d, minimap at %d", hg.trackStart+hg.trackLen, w.minimapRect().X) + } +} + +func TestMinimapClickCentresAndSliderDragKeepsGrab(t *testing.T) { + c := newMinimapEditor(150) // fits the minimap without sampling + rc := c.minimapRect() + _ = c.syncMinimap() + rowH := c.minimap.rowH() + vis := c.visibleLines() + // Click on the row of line 100: the window centres there. + c.OnEvent(Event{Kind: EventClick, X: rc.X + 5, Y: rc.Y + 100*rowH + 1}) + if c.CursorLine().Get() != 0 { + t.Fatal("a minimap click moved the caret") + } + if got, want := c.ScrollLine().Get(), 100-vis/2; got != want { + t.Fatalf("centred click: ScrollLine = %d, want %d", got, want) + } + // Press ON the slider 3 rows below its top, drag 20 rows down: the top follows + // by 20, so the pointer stays on the same spot of the slider. + top := c.ScrollLine().Get() + c.OnEvent(Event{Kind: EventClick, X: rc.X + 5, Y: rc.Y + (top+3)*rowH + 1}) + if c.ScrollLine().Get() != top { + t.Fatal("pressing on the slider must not jump") + } + c.OnEvent(Event{Kind: EventMouseDrag, X: rc.X + 5, Y: rc.Y + (top+3+20)*rowH + 1}) + if got := c.ScrollLine().Get(); got != c.maxScrollLine() && got != top+20 { + t.Fatalf("slider drag: ScrollLine = %d, want %d", got, top+20) + } + c.OnEvent(Event{Kind: EventMouseUp}) + if c.mmDrag { + t.Fatal("release must end the drag") + } + // Past the ends it clamps. + c.OnEvent(Event{Kind: EventClick, X: rc.X + 5, Y: rc.Y + 2}) + if c.ScrollLine().Get() != 0 { + t.Fatalf("click at the top: ScrollLine = %d", c.ScrollLine().Get()) + } + c.OnEvent(Event{Kind: EventMouseUp}) + // A click left of the minimap is a text click, not a minimap press. + c.OnEvent(Event{Kind: EventClick, X: rc.X - 4, Y: 40}) + if c.mmDrag { + t.Fatal("a click in the text started a minimap drag") + } +} + +func TestMinimapSamplesLongDocumentsAndStaysMonotonic(t *testing.T) { + c := newMinimapEditor(20000) + rc := c.minimapRect() + _ = c.syncMinimap() + prev := -1 + for y := rc.Y; y < rc.Y+rc.H; y += 2 { + c.OnEvent(Event{Kind: EventClick, X: rc.X + 5, Y: y}) + got := c.ScrollLine().Get() + if got < prev { + t.Fatalf("minimap not monotonic: y=%d -> %d after %d", y, got, prev) + } + prev = got + c.OnEvent(Event{Kind: EventMouseUp}) + } + if prev < 10000 { + t.Fatalf("the bottom of the overview reached only line %d of 20000", prev) + } +} + +func TestMinimapMarksAndMatchesDrawn(t *testing.T) { + for name, th := range popupThemes() { + c := newMinimapEditor(100) + c.SetMinimapMarks([]MinimapMark{{Line: 10, Kind: MarkError}, {Line: 20, Kind: MarkWarning}, {Line: 9999}, {Line: -1}}) + if len(c.MinimapMarks()) != 4 { + t.Fatal("MinimapMarks must return what was set") + } + c.SetMatchHighlights([]Selection{{StartLine: 30, StartCol: 0, EndLine: 30, EndCol: 3}}) + c.ScrollLine().Set(1 << 20) // slider at the bottom, clear of the marks + buf := makeSurface(420, 720) + c.Draw(newP(buf, 420), th) + rc := c.minimapRect() + rowH := c.minimap.rowH() + tick := func(line int) RGBA { + return pixelAt(buf, 420, c.Bounds().X+rc.X+rc.W-1, c.Bounds().Y+rc.Y+line*rowH) + } + for _, k := range []struct { + line int + kind MarkKind + }{{10, MarkError}, {20, MarkWarning}, {30, MarkMatch}} { + want := minimapMarkColor(th, k.kind) + if got := tick(k.line); got != want { + t.Errorf("%s: tick for line %d = %v, want %v", name, k.line, got, want) + } + if r := contrastRatio(want, th.SurfaceAlt); r < 3 { + t.Errorf("%s: mark kind %d vs the minimap panel = %.2f:1, want >= 3:1", name, k.kind, r) + } + } + // The slider's outline (the accent) is likewise >= 3:1 against the panel. + if r := contrastRatio(minimapAccent(th), th.SurfaceAlt); r < 3 { + t.Errorf("%s: slider outline vs panel = %.2f:1", name, r) + } + } +} + +func TestEnsureContrast(t *testing.T) { + white, black, grey := RGB(255, 255, 255), RGB(0, 0, 0), RGB(0x80, 0x80, 0x80) + if got := ensureContrast(black, white, black, 3); got != black { + t.Fatalf("a colour that already passes is kept, got %v", got) + } + light := RGB(0xd0, 0xd0, 0xff) + if got := ensureContrast(light, white, black, 3); contrastRatio(got, white) < 3 { + t.Fatalf("pulled colour %v still under 3:1", got) + } + if got := ensureContrast(grey, grey, grey, 3); got != grey { + t.Fatalf("no mix can help: want the toward colour, got %v", got) + } +} + +func TestMinimapDrawsTokenSegmentsInEditorColours(t *testing.T) { + c := newMinimapEditor(60) + th := DefaultLight() + c.Syntax = nil + buf := makeSurface(420, 260) + c.Draw(newP(buf, 420), th) + if c.minimap.segmentCount(th.OnSurface) == 0 { + t.Fatal("the minimap drew no segments") + } +} + +func BenchmarkEditorMinimapDraw(b *testing.B) { + for _, mm := range []bool{false, true} { + name := "minimap-off" + if mm { + name = "minimap-on" + } + b.Run(name, func(b *testing.B) { + c := newScrollEditor(5000, 100, 200) + c.SetBounds(Rect{W: 600, H: 600}) + c.ShowMinimap = mm + th := DefaultDark() + buf := makeSurface(600, 600) + p := newP(buf, 600) + c.Draw(p, th) + b.ResetTimer() + for i := 0; i < b.N; i++ { + c.ScrollLine().Set(i % 4000) + c.Draw(p, th) + } + }) + } +} diff --git a/textview_scroll.go b/textview_scroll.go index 65b8855..743e123 100644 --- a/textview_scroll.go +++ b/textview_scroll.go @@ -74,7 +74,7 @@ func (t *TextView) bars() (v, h bool) { tr := scrollbarTrack() cw := t.contentWidth() for i := 0; i < 3; i++ { - tw := r.W - t.textLeftInset() - 1 + tw := r.W - t.textLeftInset() - 1 - t.minimapW() if v { tw -= tr } @@ -91,7 +91,7 @@ func (t *TextView) bars() (v, h bool) { // textAreaWidth is the width of the window the text scrolls in. func (t *TextView) textAreaWidth() int { v, _ := t.bars() - w := t.Bounds().W - t.textLeftInset() - 1 + w := t.Bounds().W - t.textLeftInset() - 1 - t.minimapW() if v { w -= scrollbarTrack() } @@ -186,7 +186,7 @@ func (t *TextView) hbarGeom() (sbGeom, bool) { } r := t.Bounds() tr := scrollbarTrack() - trackLen := r.W - t.textLeftInset() - 1 + trackLen := r.W - t.textLeftInset() - 1 - t.minimapW() if v { trackLen -= tr }