Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codeeditor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 70 additions & 1 deletion codeminimap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
}
}

Expand Down
10 changes: 5 additions & 5 deletions codeminimap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
Expand All @@ -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)")
}
})
Expand All @@ -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")
}
})
Expand Down
4 changes: 2 additions & 2 deletions mvvm_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions popupframe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
24 changes: 21 additions & 3 deletions textview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions textview_minimap.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading
Loading