GoGUI: A Pure-Go, Zero-CGO GUI That Draws Web-Style UI Directly on OpenGL
Pure Go, Zero CGO: A Web-Style GUI Framework Without WebView
6.45 MB → 4.54 MB → 1.52 MB; one rectangle = 6 vertices, 22 floats; rounded corners, strokes, shadows, frosted glass, supersampling, and Chinese typography all drawn by hand. This article breaks down GoGUI's rendering pipeline, layout engine, font atlas, and 9 pits that ground me into the floor.
Repo: https://github.com/2252688138/Go-gui Size: 129 Go source files, ~38,000 lines (including examples and tools), MIT license.
0. Why build another GUI wheel
When writing desktop programs in Go, there are three obvious paths:
| Option | Representative | Pros | Cons |
|---|---|---|---|
| Bind native controls | walk, Win32 wrappers | Native system look | Windows only, styling barely customizable |
| Frontend + WebView | Wails, Tauri approach | Mature frontend ecosystem | Extra runtime layer, large package, IPC latency |
| Pure Go self-drawn | Fyne, gio | No browser kernel, small package | Controls and Web look must be built yourself |
What I actually want is specific:
- Single file, no runtime libs: copy one .exe and run; no WebView2 (browser component optional), no VC++ runtime;
- Web look: rounded corners, gradients, shadows, frosted glass, flex layout, dark/light themes;
- Chinese text must be crisp: small font size with horizontal and vertical strokes not blurry; IME must work;
- Programmable styling: not CSS string parsing; type-safe chained API;
- Performance control: no redraw when static; no dropped frames on scroll and animation.
So came GoGUI: the ui layer is completely platform-agnostic; underneath it uses purego to dynamically load Win32/WGL, uses OpenGL 3.3 for 2D batched rendering, and x/image/font/opentype for glyph rasterization. Zero cgo.
1. See results first
Real screenshots (1180×760, dark theme; images hosted in repo ui/assets, if Juejin doesn't hotlink, find original at same name in local path):
No HTML, no CSS, no WebView in the main rendering; all drawn by OpenGL.
Minimal runnable program:
package main
import "gogui/ui"
func main() {
ui.Run(ui.Options{
Title: "My App",
Width: 1180,
Height: 760,
FontFamily: "Microsoft YaHei UI",
Background: "#070a12",
}, view)
}
func view() *ui.Node {
return ui.Column(
ui.Text("Console").St(ui.St().Text(26, 700, "#eef2ff")),
ui.Button("New", func() { /* click callback */ }).
St(ui.St().Text(13, 600, "#fff").PadXY(18, 9).R(10).
BgGrad("#6366f1", "#8b5cf6", 90).
Shadow(18, 6, 0, "rgba(99,102,241,0.45)")),
).St(ui.St().Padding(28).GapOf(20))
}
go run ./cmd/demo
# release build without console window
go build -ldflags "-H=windowsgui" -o app.exe ./cmd/demo
2. Architecture: Five layers, zero cgo
cmd/gallery example app (8 reusable interfaces)
ui/ declarative component tree / style DSL / layout / events / theme
internal/gpu OpenGL 3.3 batched renderer + GLSL
internal/text opentype glyph rasterization / atlas / metrics
internal/win32 Win32 + WGL + COM bindings (purego dynamic load)
internal/geom geometry primitives
The key to zero cgo is github.com/ebitengine/purego:
// pseudocode: get function address from DLL at runtime, then wrap as Go function
lib := purego.Dlopen("opengl32.dll", purego.RTLD_NOW)
var wglCreateContext func(hdc uintptr) uintptr
purego.RegisterLibFunc(&wglCreateContext, lib, "wglCreateContext")
The cost is real:
- No headers or type checking; function signatures, struct layouts, and calling conventions are all hand-written;
- COM interfaces require manually arranging vtable slots; one wrong slot leads to access violation (later there's a bloodbath);
- Some ABI details (e.g., float parameters going through XMM registers) have no built-in support on the Go side, requiring assembly trampolines.
The payoff: the whole program is a single .exe, no MinGW, no MSVC runtime, and cross-compilation won't get stuck because of cgo.
2.1 Render target isn't a "canvas", it's a set of Draw instructions
The ui layer compiles the component tree into draw instructions each frame: shapes, text, clip push/pop. gpu.Renderer consumes them in order, flushing only when type, font, texture, or clip region changes, merging into as few glDrawArrays as possible.
// internal/gpu/renderer.go (simplified)
func (r *Renderer) Draw(instrs []Instr) {
for _, in := range instrs {
switch in.Kind {
case InstrShape:
r.pushShape(in.Shape)
case InstrText:
r.pushText(in.Text)
case InstrClipPush:
r.clipSaved = append(r.clipSaved, in.Clip)
r.setClip(in.Clip)
case InstrClipPop:
// restore previous clip (nested intersection)
}
}
}
2.2 One rectangle = 6 vertices × 22 floats
Shape vertex format:
pos(2) | local(2) | color(4) | color2(4) | halfSize(2) | radius4(4) | params(4)
local is coordinates relative to rectangle center, halfSize is half width/height, radius4 supports four different corner radii, params packs feather/mode/strokeWidth/glow. One rectangle = 6 vertices, 528 bytes.
Why not "one draw call per style"? Because a screen with hundreds of rounded cards, tags, and strokes would crush the driver. After batching, a screen typically takes only single-digit to a few dozen submissions.
3. Declarative UI: Rebuild every frame, no diff
GoGUI's view() is a pure function: on every redraw it reconstructs the entire component tree, and the framework re-measures / re-arranges / re-paints. No virtual DOM, no diff, no Key reuse (Keys only preserve scroll offset and interaction state).
var current = "dashboard"
ui.Button("Library", func() {
current = "components"
ui.RequestRedraw() // next frame rebuild → re-layout → repaint
}).St(ui.St().Text(13, 500, "#8b93ad").PadXY(14, 10).R(10))
This model works thanks to three designs:
1) State is external. Components don't hold state; state lives in caller-level package variables or structs. Components are just "projections of current state."
2) Callbacks are deferred one frame. Click callbacks are not executed immediately in the event callback, but cached to next frame and triggered based on the latest component tree:
// ui/app.go comment:
// Click callbacks fire next frame based on latest tree (avoid stale pointer), no need to set Key on nodes
Otherwise the *Node captured by the closure is invalid after tree rebuild; worst case, it modifies the wrong node or becomes a dangling pointer.
3) Redraw requests are atomic. ui.RequestRedraw() can be called from any goroutine:
// any goroutine can call; only sets a flag, doesn't touch UI cross-thread
func RequestRedraw() {
for _, a := range snapshotApps() {
a.redrawRequest.Store(true)
}
}
Main loop condition roughly:
if a.dirty || a.redrawRequest.Swap(false) || needAnim || a.hoverProbe {
// rebuild + layout + draw
}
When static, no animation, no input, no draw frames are produced. Idle polling also drops from 120Hz to 60Hz to avoid spinning the message pump and burning CPU.
3.1 Event routing: hit test + bubbling
Mouse events first do a hit test, then bubble from deepest node up to nearest interactive ancestor. Scroll container offsets are stored in App by node Key, so:
ui.Scroll(content).K("scroll-main")
Scroll containers must have a unique Key, otherwise two scroll areas share the same offset.
4. Layout engine: Flutter-style Constraints + two phases
Layout happens in two steps:
- measure: bottom-up, compute sizes (text uses opentype metrics, images read only file headers);
- arrange: top-down, position nodes and distribute remaining space for
Grow.
Constraint model is Flutter-style:
type Constraints struct {
MinW, MaxW, MinH, MaxH float32
}
Row / Column default to AlignStretch, consistent with CSS align-items: stretch.
4.1 Why flex needs to be measured twice
First pass measures all children using parent's available size and sums fixed items; second pass distributes remaining main-axis space by Grow ratio, only re-measuring children without explicit main-axis size.
// ui/layout.go (simplified)
if growSum > 0 {
free := availW - fixedMain
if !horiz {
free = availH - fixedMain
}
if free < 0 {
free = 0
}
for _, c := range n.Children {
if c.Style.Grow <= 0 || c.Style.PosAbs {
continue
}
// items with explicit size don't need re-measure
if horiz && (c.Style.Width > 0 || c.Style.WidthPct > 0 || c.Style.Basis > 0) {
continue
}
share := free * c.Style.Grow / growSum
measureNode(lc, c, Constraints{0, share, 0, availH}, n)
}
}
Here's an easy pit: when measuring children, don't set a MaxW cap on the main axis.
// measurement constraints: main axis has no upper limit (same as CSS flex), cross axis uses parent's available size.
// If main axis also has a cap, child's percentage sizes will be computed against parent's full width,
// causing "auto-width" containers to inflate.
childC := func() Constraints {
if horiz {
return Constraints{0, 0, 0, availH} // MaxW = 0, unlimited
}
return Constraints{0, availW, 0, 0}
}
Similarly, percentage sizes need to be recomputed at arrange stage because the parent may be stretched under AlignStretch, and the old measure-stage size would make Wp(100) overflow.
4.2 Content-type containers are not clipped by parent height
Children inside Scroll must stretch to natural content height, otherwise content gets truncated to viewport height and can't scroll. Wrap / Grid / Masonry also follow this:
content := n.Kind == KWrap || n.Kind == KGrid || n.Kind == KMasonry
inScroll := parent != nil && parent.Kind == KScroll
if c.MaxH > 0 && h > c.MaxH && !st.Scrollable && !content && !inScroll {
h = c.MaxH
}
4.3 More than flex
| Constructor | Capability |
|---|---|
Grid |
Equal-width column grid, fixed column count or adaptive by column width |
Wrap |
flex-wrap: wrap, supports space-between within rows |
Masonry |
Waterfall, items fall into currently shortest column |
EqualRow / EqualCol |
Equal division of main axis |
Pick / BP |
Responsive branch based on viewport width |
ui.Grid(tiles...).St(ui.St().ColWidth(180).GapOf(12))
ui.Wrap(tags...).St(ui.St().GapOf(8)).JustifyTo(ui.AlignSpaceBetween)
ui.Masonry(cards...).St(ui.St().ColsN(3).GapOf(12))
ui.Pick(narrow, wide, wide, wide)
5. Rendering: One SDF shader for rounded corners, strokes, shadows
Rounded rectangles use an analytic signed distance field (SDF):
float sdRoundBox(vec2 p, vec2 b, vec4 r) {
r.xy = (p.x > 0.0) ? r.xy : r.zw;
r.x = (p.y > 0.0) ? r.x : r.y;
vec2 q = abs(p) - b + r.x;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
}
Fragment shader splits into three paths by mode:
float d = sdRoundBox(vLocal, vHalf, vRadius);
if (glow > 0.0) {
// shadow: SDF expands glow pixels outward, distance falloff approximates Gaussian
float k = clamp(-d / max(glow, 0.001), 0.0, 1.0);
d = d + glow * (1.0 - k);
}
if (mode < 0.5) {
alpha = 1.0 - smoothstep(-feather, feather, d); // fill
} else if (mode < 1.5) {
alpha = 1.0 - smoothstep(-feather, feather,
abs(d + strokeW * 0.5) - strokeW * 0.5); // stroke
} else {
alpha = (1.0 - smoothstep(-glow, glow, d)) * 0.9; // shadow
}
One draw completes fill, stroke, and shadow simultaneously. Shadow isn't a separate texture and doesn't need a blur pass: the CPU side expands the rectangle by spread + blur; the fragment does distance falloff.
feather defaults to 0.7 logical pixels, combined with smoothstep for anti-aliased edges:
r.appendQuad(rs, sh.Radius, sh.Fill, Paint{A: -1}, 0.7, 0, 0)
5.1 Gradients and batching
Gradients interpolate two vertex colors; enabled when vColor2.a >= 0:
vec4 col = vColor;
if (vColor2.a >= 0.0) {
col = mix(vColor, vColor2, vT.x);
}
fragColor = vec4(col.rgb, col.a * alpha);
Batches split on four conditions:
- instruction type change (shape → text → image);
- font change (each glyph atlas is an independent texture);
- image texture change;
- clip region change (
glScissoris global state).
Nested clips intersect; setting the same rectangle repeatedly doesn't break batch:
func (r *Renderer) setClip(c geom.Rect) {
if r.clipOn && r.clipCur == c {
return // common in nested scroll containers, avoid useless flush
}
r.flush()
// glScissor origin is bottom-left, flip y
}
5.2 Vertex buffer: orphan + SubData
Early implementation called glBufferData(data) every frame to reallocate the whole buffer; scrolling caused frequent driver allocations and sync with previous frame. Now it only grows when capacity is insufficient; otherwise orphans the buffer and overwrites with glBufferSubData.
func (r *Renderer) uploadVerts(vbo *uint32, capBytes *int, data []float32) {
n := len(data) * 4
glBindBuffer(GL_ARRAY_BUFFER, *vbo)
if *capBytes < n {
*capBytes = n + n/2 + 4096
}
// orphan: break dependency with previous frame
glBufferAlloc(GL_ARRAY_BUFFER, *capBytes, GL_STREAM_DRAW)
glBufferSubData(GL_ARRAY_BUFFER, 0, data)
}
Also, initial vertex slice capacity changed from "reserve for 60k vertex hard cap" to "reserve for typical screen":
maxVertsShapeInitial = 8192
maxVertsTextInitial = 20480
Old implementation allocated 60000×22×4B ≈ 5.3MB + 60000×9×4B ≈ 2.2MB of Go slices on window open, but a real screen usually has only hundreds to a few thousand vertices.
6. Text: Chinese clarity is the lifeline of self-drawn GUI
Text pipeline:
x/image/font/opentypereads TTF/TTC, gets glyph outlines;- Vector outlines rasterize to 8-bit coverage;
- Written into RGBA8 glyph atlas (normal glyphs as
(v,v,v,255), emoji with real color); - Vertex carries
mode;mode=1outputs texture color directly; - One quad per glyph, UV points to atlas.
6.1 Atlas strategy: start at 256², grow on demand, LRU eviction
This is the biggest memory win.
Old version had fixed 2048² atlas per font = 16MB. A page using a dozen font sizes/weights would eat over 100MB.
Now:
| Stage | Atlas | Per-font memory |
|---|---|---|
| Start | 256² | 256 KB |
| Overflow | 512² | 1 MB |
| Normal cap | 1024² | 4 MB |
| Raster scale ≥ 1.75 (supersampling) | 2048² | 16 MB |
Font instance cap is 24; LRU eviction frees corresponding textures:
const maxFonts = 24
func (fc *fontCache) evictOldest() {
// find least recently used font
if f, ok := fc.fonts[oldKey]; ok {
if fc.onEvict != nil {
fc.onEvict(f) // upper layer releases atlas texture
}
f.Close()
delete(fc.fonts, oldKey)
}
// wrap cache keyed by *Font; closing font must invalidate all
clearWrapMemo()
}
Real measurement (GOGUI_MEMSTATS=1, gallery runtime sample output):
[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0
13 font instances, atlas total 3.2MB. With old fixed 2048², atlases alone would be 13 × 16MB = 208MB.
6.2 Only upload dirty rectangles
When new glyphs appear, no longer re-upload the whole atlas; record dirty rects and use glTexSubImage2D for partial updates:
if x0, y0, x1, y1, dirty := f.TakeDirty(); dirty {
r.uploadAtlasRegion(f, t, x0, y0, x1, y1)
}
Whole-row regions upload directly (contiguous memory); otherwise copy row-by-row into a reused buffer and submit. Scrolling constantly reveals new text; this removes the stutter from continuous full uploads.
6.3 Font files use memory mapping
msyh.ttc and similar are mapped with MapViewOfFile; data is handled by system page cache, not Go heap.
6.4 Glyph gamma: why self-drawn text always looks "washed out"
This is the most hidden pit.
Browsers (Skia / DirectWrite) apply gamma correction to coverage before blending in sRGB space, effectively boosting mid-level coverage: a pixel at brightness 0.5 appears near 0.7 in the browser.
Self-drawn rendering usually does straight sRGB blending, so the same coverage looks paler and vaguer; this is especially visible in small Chinese horizontal/vertical strokes.
The fix is to apply gamma to coverage:
float lum = dot(vColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if (uTextGamma != 1.0 && lum > 0.5) {
a = pow(a, uTextGamma);
}
Only bright text (dark background, light chars) is compensated; dark chars on light background already blend linearly and don't need lifting. Measured 1/1.8 ≈ 0.55 is closest to browser appearance; can be overridden with GOGUI_TEXT_GAMMA.
6.5 Emoji fallback chain
When main font lacks a glyph, fall back in order:
Microsoft YaHei UI → Segoe UI Symbol → GDI color Emoji (Segoe UI Emoji)
After GDI renders to 32bpp DIB, a second white mask fills dark pixels' alpha, then writes into atlas; vertex mode=1 outputs color directly.
6.6 One input box, half of an IME blood-and-tears history
A self-drawn input box isn't just "draw a rectangle + collect keyboard events". It must handle:
- Cursor position: stored by rune, not byte (a Chinese character is 3 bytes);
- Focus:
St().FocusOn()+ globalfocusKey; clicking blank area clears focus; - Password masking: displays
•••, but cursor position still computed by real rune count; - Edit keys: ← → Home End Backspace Delete;
- Enter submit: address bar, search box need
InputSubmit; - IME: composition string, candidate window position, dedup on commit.
IME's three message segments must be wired end-to-end:
case EvCompositionStart:
a.imeText, a.imeCaret = "", 0
case EvComposition:
a.imeText, a.imeCaret = ev.Text, ev.Caret // GCS_COMPSTR
case EvCompositionEnd:
a.imeText, a.imeCaret = "", 0
GCS_COMPSTRcomposition string is exposed viaui.ComposingText()/ComposingCaret(); input box shows pre-edit text with highlight block, and cursor doesn't blink during composition;GCS_RESULTSTRdispatchesEvCharper character on commit, and no longer generates duplicateWM_CHAR, otherwise one Chinese character would be typed twice;- Focused input box position is synced to system via
ImmSetCompositionWindow/ImmSetCandidateWindow, so candidate window follows cursor instead of staying at top-left.
// ui/widgets.go (simplified)
if comp := ComposingText(); comp != "" && !password {
cp := ComposingCaret()
cr := []rune(comp)
cells = append(cells, Text(string(cr[:cp])).St(St().Text(13, 400, ColText).BgC(ColBgSoft).R(3)))
cells = append(cells, Text(string(cr[cp:])).St(St().Text(13, 400, ColText).BgC(ColHover).R(3)))
showCaret = false // don't blink cursor during composition
}
7. Supersampling: solving "blurry text" and "rough edges" together
Rounded-corner SDF anti-aliasing has only a 1-pixel transition band; small font strokes are worse. The idea of full-frame SSAA is straightforward:
Render the whole frame at N× resolution into an off-screen FBO, then average-downsample back to window pixels.
ui.SetSuperSample(ui.SSAuto) // 2× when DPI < 150%, else 1× (recommended)
ui.SetSuperSample(ui.SS200) // force 2×
ui.SetSuperSample(ui.SSOff) // 1:1, lowest GPU cost
// also SS125 / SS150
Downsample is a 4×4 sub-band box filter:
vec2 base = uTexel * (uSpan * 0.125);
vec4 c = vec4(0.0);
for (int y = 0; y < 4; y++) {
for (int x = 0; x < 4; x++) {
vec2 o = vec2(float(x) * 2.0 - 3.0, float(y) * 2.0 - 3.0) * base;
c += texture(uTex, vUV + o);
}
}
c *= 0.0625;
Logical layout, hit testing, and mouse coordinates stay unchanged; only render resolution changes. But there are four counter-intuitive details.
7.1 Edge transition band must be constant in device pixels
feather was originally 0.7 logical pixels. At 2× supersampling, the canvas is 2× resolution, so 0.7 logical pixels become 1.4 device pixels; after downsampling, edges get softer instead of sharper—supersampling wasted.
So CPU side divides the transition band by the multiplier:
// fill/stroke SDF edge transition width stays constant in device pixels
if r.ssaa > 1.01 && mode < 1.5 && feather > 0 {
feather /= r.ssaa
}
Fragment also must lower the lower bound from 0.5 to 0.25, otherwise the tightened 0.35 at 2× gets lifted back to 0.5 and half the sharpness is eaten.
7.2 Glyph gamma doesn't converge by multiplier
At first I assumed: after supersampling averaging N² samples, gamma compensation should weaken to 1-(1-g)/ss. Result: at 2× text looked grayer and fuzzier than 1:1.
Why: the downsampler is linear averaging of sub-samples; gamma compensation applied after averaging isn't distorted. So all multipliers use the same compensation value; SSAA only improves geometry/edge precision, not stroke solidity.
7.3 Box averaging makes 1px details "soft"
Mathematically box downsampling is correct, but 1px horizontal/vertical strokes and 1px borders lose contrast after averaging; the human eye judges it as "less sharp than 1:1."
So a light unsharp pass is added after downsampling:
if (uSharpen > 0.0) {
vec2 o = uTexel * uSpan;
vec4 blur = (texture(uTex, vUV + vec2(o.x, 0.0)) +
texture(uTex, vUV - vec2(o.x, 0.0)) +
texture(uTex, vUV + vec2(0.0, o.y)) +
texture(uTex, vUV - vec2(0.0, o.y))) * 0.25;
c = clamp(c + (c - blur) * uSharpen, 0.0, 1.0);
}
Default 0.4 (GOGUI_SS_SHARPEN adjustable); only recovers a small part of the smoothed high-frequency, keeping anti-aliasing without white halos.
7.4 Pixel budget and semi-transparent backgrounds
- Pixel budget
8192×4320: on very large windows, auto-step down2× → 1.5× → 1.25× → 1×to avoid fill-rate explosion on 4K/5K; - Downsample is a full-screen overwrite; must disable blending, otherwise semi-transparent background would mix again with old content in the target;
- Glyph atlas auto-expands to 2048² when raster scale ≥ 1.75, otherwise at 2× it fills up quickly and triggers "clear and reuse", causing UV invalidation within same frame and missing glyphs;
- Runtime hot-switch rebuilds render target and glyph atlas; suitable for putting a clarity option in settings page.
8. Multimedia: play mp4 without cgo
Playing audio/video doesn't necessarily require linking FFmpeg dynamic libraries. GoGUI's approach: treat ffmpeg as a subprocess.
p, err := ui.NewMediaPlayer("movie.mkv") // or m3u8 direct link
if err != nil { /* ffmpeg not installed / no video stream / link unreachable */ }
defer p.Close() // release ffmpeg process and audio device
p.Play()
ui.VideoPlayer("vp", p).St(ui.St().H(420))
Internally two independent pipelines:
| Pipeline | Implementation |
|---|---|
| Video | ffmpeg -f rawvideo -pix_fmt rgba → pipe → frame ring (max 48MB / 2 sec) → fetch frame by play position → glTexSubImage2D partial texture update |
| Audio | second ffmpeg process outputs s16le / 48k / stereo → WinMM waveOut |
8.1 Use audio as master clock
Video frame rate and audio sample rate can't be aligned exactly. When there's a track, the player uses waveOut played sample count as clock (MediaClock); picture follows audio position to fetch frames; playback speed uses atempo filter to rebuild the whole audio chain.
This is far more stable than "estimate play position with time.Now()"—the latter drifts hundreds of milliseconds after a few minutes.
8.2 Dragging progress bar shouldn't restart ffmpeg on every event
Initial implementation did -ss restart on every mousemove, causing CPU to max out and video to freeze.
Current strategy is MediaScrub:
- During drag, only record target position, return existing frame immediately;
- After decode wait budget falls below 80ms, allow grabbing one preview frame;
- On release, do a single
-ssseek, restarting only once.
Dragging no longer stutters; the cost is preview frames aren't perfectly synced during drag—a worthwhile trade-off in a player.
8.3 Fall back to system decode without ffmpeg
ui.OpenMedia first probes PATH for ffmpeg / ffprobe; if absent, uses Windows Media Foundation:
os.Setenv("GOGUI_DECODER", "system")
src, _ := ui.NewMediaPlayer("movie.mp4")
fmt.Println(src.Info().Backend) // system
| ffmpeg backend | system decode backend | |
|---|---|---|
| Containers | mp4/mkv/mov/avi/webm/flv/ts/m3u8 | system-supported containers |
| Codecs | basically all | installed MFTs |
| Playback speed | supported (atempo) | only 1x |
| Drag preview | single-frame grab | keeps last frame |
MF pitfall mentioned earlier: all IMFSourceReader calls must be serialized on a single locked thread (win32.MFRun), otherwise creating a second reader causes access violation.
9. Components: reimplementing part of the browser
Self-drawn frameworks easily become "demo looks great, but you're missing ten widgets once you use it." So ui/widgets.go implements common controls, with all state held by the caller—components have no hidden state, can be copied out and used.
9.1 Markdown renderer
ui.Stack(
ui.Scroll(
ui.Markdown("md", src, ui.MarkdownOptions{Width: 760}),
).K("scroll-md"),
ui.MarkdownLightbox(), // click image for fullscreen preview
)
Supports: headings / paragraphs / bold / italic / strikethrough / inline code / links / images / ordered and unordered lists / task lists / blockquotes and GitHub callouts / code blocks (line numbers + syntax highlight + copy + fold over 22 lines) / tables / horizontal rules / [toc] / [tabs] / [details] / [card] / [badge] / [progress] / [gallery], plus math formulas and Mermaid.
Parser, syntax highlighter, and keyword tables are all hand-written, without goldmark or chroma. Code highlighting covers go / js / ts / py / json / bash / sql / html / css / yaml—ten languages, enough and tiny.
9.2 Charts: 1 draw call when data unchanged
chart := ui.NewChart(ui.ChartLine, labels,
ui.Series{Name: "This week", Values: week, Color: "#6366f1"},
ui.Series{Name: "Last week", Values: last, Color: "#22d3ee"},
)
chart.Smooth = true
chart.OnPick = func(series, index int) { /* click data point */ }
ui.ChartView("trend", chart).St(ui.St().H(300))
Charts aren't re-rasterized every frame; they cache one RGBA texture keyed by "node Key + size + scale + data fingerprint":
- Data unchanged: 1 draw call per frame, zero CPU rasterization;
- Cache total budget 24MB, evict least recently used beyond that, texture destroyed with window;
- Hover highlight and click hit-test reuse the same layout result (
chart.cache.hitS / hitI), so callbacks don't recompute.
Supports line / area / bar / pie / donut / radar / scatter; stacked, smooth (Catmull-Rom), custom axis range, OnPick click callback.
9.3 Other built-ins
| Category | Components |
|---|---|
| Form | Input PasswordInput Checkbox Radio Switch Slider Progress Ring |
| Display | Card Title Divider Tag Dot Avatar Badge KeyValue Empty Skeleton Spinner |
| Navigation | Tabs TabsBar Breadcrumb Accordion |
| Data | Table (fixed/elastic column width, zebra stripes, full-row click) ListItem |
| Overlay | Modal MenuPanel TooltipOf Toast Banner |
| Media | VideoPlayer MusicPlayer (with spectrum / cover / playlist) |
| Browser | WebView + self-drawn address bar / status line |
ui/COMPONENTS.md lists file ownership and minimal examples for each component; import by file module.
10. Performance checklist: what's saved is frame time
| Optimization | How | Effect |
|---|---|---|
| On-demand redraw | choose one of dirty / redrawRequest / animBusy / hoverProbe |
no draw frames when static |
| Minimize not-render | skip frame when window minimized | no GPU burn in background |
| Skip offscreen | scroll containers only traverse visible subtree | smoother long-list scrolling |
| Hover dedup | mouse move only does hit test; no redraw if hover target unchanged | no jitter when mouse passes |
| VBO reuse | orphan + SubData when capacity sufficient | reduces driver allocation and sync |
| Atlas partial upload | dirty rect glTexSubImage2D |
new glyphs no longer full re-upload |
| Async image decode | background goroutine decode (max 4 concurrent), UI thread only uploads texture | many images don't stutter |
| Font mmap | MapViewOfFile |
not on Go heap |
| Media frame ring cap | 48MB / 2 sec | memory controlled for long videos |
GOGUI_MEMSTATS=1 prints memory composition every 120 frames:
[gogui] frames=120 heap=19.4MB inuse=20.6MB sys=27.7MB gc=2 fonts=13 atlas=3.2MB images=0
11. Pitfall collection: things documentation won't write
11.1 DirectComposition vtable slots are reversed
To let WebView2 web content be covered by self-drawn menus, DComp composition is needed. dcomp runtime's vtable puts "animated" variants before "float" variants, opposite to SDK headers:
SetOffsetX(float) = 4
SetOffsetY(float) = 6
SetClip = 14
Float versions of rect clip (left/right/top/bottom/corner radii) all on even slots (4/6/8/10/12…26)
Using 3/5/7/9/11/13 would treat float as animation pointer and dereference, crashing at addr=0x50. This took a long time to find because the crash site didn't look related to DComp.
11.2 Go linker only accepts one .rsrc section
Icon and version info must be generated by the same syso:
too many .rsrc sections
tools/winres directly outputs COFF .syso, writing RT_ICON + RT_GROUP_ICON + RT_VERSION into one .rsrc section; no windres, no cgo.
11.3 NSIS script containing Chinese must be UTF-8 with BOM
Otherwise makensis errors:
Bad text encoding
11.4 InstallDirRegKey pollutes directory page
It stuffs old directory into $INSTDIR before .onInit, causing "install for all users" to mistakenly use current user's old path. Correct approach: call SetScope only when switching scope.
11.5 Don't assign $INSTDIR inside install Section
That overwrites the path user manually selected on directory page. Custom path must be preserved fully; scope switching only switches between two default directories.
11.6 WebView2's HWND sits above OpenGL content
In HWND hosting mode, web pixels don't enter the GL batch, so:
- can't be covered by menus / Modals;
- can't do 3D transforms;
- rounded corners only via
SetWindowRgnhard clip.
Current strategy: temporarily hide web page when overlay expands; try DComp composition (GOGUI_COMPOSE=1, experimental), auto-fallback on failure.
11.7 WM_DROPFILES has no dragover
System only posts a message on release, no dragenter/dragover, so hover highlight isn't possible. For hover feedback, must use OLE IDropTarget.
11.8 Media Foundation must be serial on same thread
IMFSourceReader is extremely sensitive to cross-thread scheduling. All calls (create / ReadSample / Seek / Release) must go through win32.MFRun serialized on one locked thread, otherwise creating a second reader causes access violation.
11.9 1px white border on borderless windows
NoTitleBar windows need to explicitly set DWMWA_BORDER_COLOR to DWMWA_COLOR_NONE, otherwise DWM draws a 1px white system border, very jarring on dark interface.
12. Size and release: 6.45MB → 1.52MB
build.ps1 sets release parameters:
./build.ps1 # gallery -> dist\gallery.exe
./build.ps1 -Target all # gallery + demo + tools
./build.ps1 -Upx # plus UPX -9
./build.ps1 -Dev # dev build: keep symbols + console logs
./build.ps1 -Installer # build + NSIS installer
./build.ps1 -Bump patch -Installer
Measured (gallery, go1.26 windows/amd64):
| Build | Size | Notes |
|---|---|---|
go build (default) |
6.45 MB | with symbol table, debug info, local paths |
build.ps1 |
4.54 MB | −30%, strips symbols/debug info and build paths |
build.ps1 -Upx |
1.52 MB | −76%, self-extracts on start (occasional AV false positive) |
Key flags: -trimpath -buildvcs=false, -s -w -H windowsgui -buildid=, CGO_ENABLED=0.
12.1 Version number written in one place
VERSION.txt is the single source; one build injects into three locations:
| Location | Injection | Read |
|---|---|---|
| Runtime | -ldflags -X gogui/internal/version.Number=... |
version.String() |
| exe file properties | tools/winres generates syso |
right-click → Details |
| Installer | makensis /DVERSION=... |
version in "Apps & features" |
version.String() // v0.1.0
version.Title() // GoGUI v0.1.0 (window title)
version.Full() // v0.1.0 · release · 1a2b3c4 · 2026-10-29T03:49:04Z
12.2 Installer supports two scopes
| Option | Install dir | Registry | Permission |
|---|---|---|---|
| Install for me only (default) | %LOCALAPPDATA%\Programs\GoGUI |
HKCU | no admin |
| Install for all users | %ProgramFiles%\GoGUI |
HKLM | UAC re-entry if not elevated |
Silent install:
GoGUI-0.1.0-setup.exe /S /currentuser /D=D:\Apps\GoGUI
GoGUI-0.1.0-setup.exe /S /allusers
/D must be last argument, no quotes around path.
13. Cross-platform: swap backend without changing business code
internal/gpu.Backend is abstracted; adding a platform only needs window layer + render layer:
| Platform | Window layer | Render layer | Status |
|---|---|---|---|
| Windows | Win32 | OpenGL 3.3 | done |
| macOS | Cocoa + CAMetalLayer | Metal / OpenGL 4.1 | planned |
| Linux | X11 / Wayland | OpenGL / Vulkan | planned |
| Android | ANativeWindow + EGL | GLES 3 | planned |
| Web | canvas | WebGL 2 + WASM | adapter layer in repo |
WASM side reuses same ui tree: internal/gpu/glport_wasm.go provides WebGL2 shader/program/buffer/texture wrappers, ui/*_wasm.go provides platform stubs.