detached aa12db3

feat(examples): restore the workspace demo in a separate module (#11)

The document workspace demo (toolbar, document list, editor, properties
pane, light and dark themes) runs again in a Wayland window. It lives in
its own examples module so NeferGUI still imports no Wayland code; the
module uses neferclient for the xdg-toplevel window and copies plain
fields between the two libraries, following neferclient's layer example.

The module replaces NeferGUI with the parent directory, so the demo
always builds against the code in the checkout. make examples-check
builds, vets, lints and tests it, and CI runs it.
commit
aa12db37fe78c89ebc1f8a67aa7f30239f368672
author
bnema · · 2026-10-02 03:17 UTC
committer
GitHub · · 2026-10-02 03:17 UTC
parent
19ecdbf
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.6.1
10 files · +652 -1
M .github/workflows/ci.yml +1 -0
@@ -31,3 +31,4 @@ jobs:
3131 - run: make check
3232 - run: make race
3333 - run: make mocks-check
34+ - run: make examples-check
@@ -31,3 +31,4 @@ jobs:
31 - run: make check31 - run: make check
32 - run: make race32 - run: make race
33 - run: make mocks-check33 - run: make mocks-check
34+ - run: make examples-check
M Makefile +4 -1
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check fakes-check perf-check check
1+.PHONY: build test vet race mocks mocks-check fakes-check perf-check check examples-check
22
33 build:
44 CGO_ENABLED=0 go build ./...
@@ -21,3 +21,6 @@ perf-check:
2121 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...
2222 check: build vet test fakes-check perf-check
2323 CGO_ENABLED=0 staticcheck ./...
24+# The examples module: built, vetted and unit-tested without a compositor.
25+examples-check:
26+ cd examples && CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 staticcheck ./... && CGO_ENABLED=0 go test ./...
@@ -1,4 +1,4 @@
1-.PHONY: build test vet race mocks mocks-check fakes-check perf-check check1+.PHONY: build test vet race mocks mocks-check fakes-check perf-check check examples-check
2 2
3 build:3 build:
4 CGO_ENABLED=0 go build ./...4 CGO_ENABLED=0 go build ./...
@@ -21,3 +21,6 @@ perf-check:
21 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...21 CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./...
22 check: build vet test fakes-check perf-check22 check: build vet test fakes-check perf-check
23 CGO_ENABLED=0 staticcheck ./...23 CGO_ENABLED=0 staticcheck ./...
24+# The examples module: built, vetted and unit-tested without a compositor.
25+examples-check:
26+ cd examples && CGO_ENABLED=0 go build ./... && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 staticcheck ./... && CGO_ENABLED=0 go test ./...
M README.md +8 -0
@@ -41,6 +41,14 @@ r.Released(buffer) // when out.ReleaseFD becomes readable
4141
4242 `RendererConfig.Styles` loads your own CSS; default styles work without it. See `ExampleRenderer` and [runtime](docs/runtime.md) for the full contract.
4343
44+## Demo
45+
46+[`examples/demo`](examples/demo) is a document workspace (toolbar, document list, text editor, properties pane, light and dark themes) in a Wayland window, using neferclient as the client. It lives in its own module, so NeferGUI does not depend on neferclient, and it always builds against the NeferGUI code in this checkout:
47+
48+```sh
49+cd examples && go run ./demo
50+```
51+
4452 ## Requirements
4553
4654 Go 1.27, `libvulkan.so.1` and usable fonts. The GPU must export DMA-BUF and support DRM syncobj timelines (kernel 6.6 or later for eventfd waits). The compositor, reached through your Wayland client, must offer linux-dmabuf and linux-drm-syncobj; fractional scaling also needs viewporter. No X11 or software-rendering fallback.
@@ -41,6 +41,14 @@ r.Released(buffer) // when out.ReleaseFD becomes readable
41 41
42 `RendererConfig.Styles` loads your own CSS; default styles work without it. See `ExampleRenderer` and [runtime](docs/runtime.md) for the full contract.42 `RendererConfig.Styles` loads your own CSS; default styles work without it. See `ExampleRenderer` and [runtime](docs/runtime.md) for the full contract.
43 43
44+## Demo
45+
46+[`examples/demo`](examples/demo) is a document workspace (toolbar, document list, text editor, properties pane, light and dark themes) in a Wayland window, using neferclient as the client. It lives in its own module, so NeferGUI does not depend on neferclient, and it always builds against the NeferGUI code in this checkout:
47+
48+```sh
49+cd examples && go run ./demo
50+```
51+
44 ## Requirements52 ## Requirements
45 53
46 Go 1.27, `libvulkan.so.1` and usable fonts. The GPU must export DMA-BUF and support DRM syncobj timelines (kernel 6.6 or later for eventfd waits). The compositor, reached through your Wayland client, must offer linux-dmabuf and linux-drm-syncobj; fractional scaling also needs viewporter. No X11 or software-rendering fallback.54 Go 1.27, `libvulkan.so.1` and usable fonts. The GPU must export DMA-BUF and support DRM syncobj timelines (kernel 6.6 or later for eventfd waits). The compositor, reached through your Wayland client, must offer linux-dmabuf and linux-drm-syncobj; fractional scaling also needs viewporter. No X11 or software-rendering fallback.
A examples/demo/app.css +72 -0
@@ -0,0 +1,72 @@
1+/* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */
2+app.app {
3+ --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5;
4+ --ink: #18181b; --secondary: #52525b; --quiet: #71717a;
5+ --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7;
6+ --control: #fafafa; --action: #27272a; --action-ink: #ffffff;
7+ --disabled: #d4d4d8; --disabled-ink: #52525b;
8+ display: flex; flex-direction: column; width: 100%; height: 100%;
9+ box-sizing: border-box;
10+ background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4;
11+ accent-color: #52525b;
12+}
13+app.app.dark {
14+ --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f;
15+ --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa;
16+ --line: #36363c; --hover: #303035; --selected: #36363c;
17+ --control: #18181b; --action: #e4e4e7; --action-ink: #18181b;
18+ --disabled: #36363c; --disabled-ink: #a1a1aa;
19+ accent-color: #a1a1aa;
20+}
21+app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; }
22+header.header {
23+ display: flex; flex-direction: row; align-items: center; column-gap: 20px;
24+ height: 48px; flex-shrink: 0; padding: 6px 12px;
25+ background: var(--surface); border-bottom: 1px solid var(--line);
26+}
27+heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); }
28+heading.logo { font-size: 18px; font-weight: 700; }
29+section heading { font-size: 16px; }
30+text.muted { color: var(--secondary); }
31+text.hint { color: var(--quiet); font-size: 12px; }
32+text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; }
33+spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; }
34+row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; }
35+aside.sidebar {
36+ display: flex; flex-direction: column; width: 200px; flex-shrink: 0;
37+ padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line);
38+}
39+aside.sidebar > text { margin-left: 12px; }
40+nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; }
41+button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
42+button:hover { background: var(--hover); }
43+button:active { background: var(--selected); }
44+button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible {
45+ outline: 2px solid var(--quiet); outline-offset: 2px;
46+}
47+button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; }
48+button.menu-item:hover { background: var(--hover); }
49+button.menu-item.active { background: var(--selected); font-weight: 600; }
50+button.theme-toggle { width: 104px; font-size: 13px; }
51+main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
52+row.workspace { height: 100%; align-items: stretch; }
53+column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); }
54+row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
55+text.document-title { font-size: 13px; font-weight: 600; }
56+textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
57+aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
58+aside.inspector input, aside.inspector slider { width: 208px; }
59+aside.inspector button { margin-top: 8px; height: 32px; }
60+text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
61+separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; }
62+button:disabled { background: var(--disabled); color: var(--disabled-ink); }
63+column.preferences-pane { padding: 16px 24px; row-gap: 8px; }
64+input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; }
65+input:hover { border-color: var(--quiet); }
66+text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; }
67+radio { padding-top: 6px; padding-bottom: 6px; }
68+slider { width: 320px; margin-top: 8px; }
69+footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; }
70+app.app.compact aside.inspector { padding-top: 8px; }
71+app.app.compact textarea.document-editor { padding: 12px; }
72+app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; }
@@ -0,0 +1,72 @@
1+/* Neutral desktop surfaces; tokens keep both themes on the same hierarchy. */
2+app.app {
3+ --canvas: #fafafa; --surface: #ffffff; --sidebar: #f4f4f5;
4+ --ink: #18181b; --secondary: #52525b; --quiet: #71717a;
5+ --line: #e4e4e7; --hover: #ededf0; --selected: #e4e4e7;
6+ --control: #fafafa; --action: #27272a; --action-ink: #ffffff;
7+ --disabled: #d4d4d8; --disabled-ink: #52525b;
8+ display: flex; flex-direction: column; width: 100%; height: 100%;
9+ box-sizing: border-box;
10+ background: var(--canvas); color: var(--ink); font-size: 14px; line-height: 1.4;
11+ accent-color: #52525b;
12+}
13+app.app.dark {
14+ --canvas: #18181b; --surface: #202023; --sidebar: #1c1c1f;
15+ --ink: #f4f4f5; --secondary: #d4d4d8; --quiet: #a1a1aa;
16+ --line: #36363c; --hover: #303035; --selected: #36363c;
17+ --control: #18181b; --action: #e4e4e7; --action-ink: #18181b;
18+ --disabled: #36363c; --disabled-ink: #a1a1aa;
19+ accent-color: #a1a1aa;
20+}
21+app, header, footer, aside, main, section, row, nav, button, input { box-sizing: border-box; }
22+header.header {
23+ display: flex; flex-direction: row; align-items: center; column-gap: 20px;
24+ height: 48px; flex-shrink: 0; padding: 6px 12px;
25+ background: var(--surface); border-bottom: 1px solid var(--line);
26+}
27+heading { font-size: 24px; font-weight: 600; margin: 0; color: var(--ink); }
28+heading.logo { font-size: 18px; font-weight: 700; }
29+section heading { font-size: 16px; }
30+text.muted { color: var(--secondary); }
31+text.hint { color: var(--quiet); font-size: 12px; }
32+text.eyebrow { color: var(--quiet); font-size: 11px; font-weight: 600; letter-spacing: 1px; }
33+spacer.stretch { flex-grow: 1; min-height: 0; min-width: 0; }
34+row.body { flex-grow: 1; min-height: 0; height: 100%; align-items: stretch; }
35+aside.sidebar {
36+ display: flex; flex-direction: column; width: 200px; flex-shrink: 0;
37+ padding: 24px 12px 20px; background: var(--sidebar); border-right: 1px solid var(--line);
38+}
39+aside.sidebar > text { margin-left: 12px; }
40+nav.menu { display: flex; flex-direction: column; row-gap: 4px; margin-top: 16px; flex-shrink: 0; height: 84px; }
41+button { color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
42+button:hover { background: var(--hover); }
43+button:active { background: var(--selected); }
44+button:focus-visible, input:focus-visible, radio:focus-visible, slider:focus-visible {
45+ outline: 2px solid var(--quiet); outline-offset: 2px;
46+}
47+button.menu-item { height: 40px; flex-shrink: 0; width: 176px; text-align: left; border: 1px solid transparent; padding: 10px 12px; background: transparent; }
48+button.menu-item:hover { background: var(--hover); }
49+button.menu-item.active { background: var(--selected); font-weight: 600; }
50+button.theme-toggle { width: 104px; font-size: 13px; }
51+main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; }
52+row.workspace { height: 100%; align-items: stretch; }
53+column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); }
54+row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); }
55+text.document-title { font-size: 13px; font-weight: 600; }
56+textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; }
57+aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); }
58+aside.inspector input, aside.inspector slider { width: 208px; }
59+aside.inspector button { margin-top: 8px; height: 32px; }
60+text.field-label { margin-top: 16px; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
61+separator { background: var(--line); margin-top: 16px; margin-bottom: 4px; flex-shrink: 0; }
62+button:disabled { background: var(--disabled); color: var(--disabled-ink); }
63+column.preferences-pane { padding: 16px 24px; row-gap: 8px; }
64+input { background: var(--control); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; width: 320px; height: 40px; }
65+input:hover { border-color: var(--quiet); }
66+text.sidebar-note { color: var(--secondary); font-size: 12px; margin-bottom: 4px; }
67+radio { padding-top: 6px; padding-bottom: 6px; }
68+slider { width: 320px; margin-top: 8px; }
69+footer.status-bar { display: flex; flex-direction: row; align-items: center; height: 28px; flex-shrink: 0; padding: 4px 12px; background: var(--surface); border-top: 1px solid var(--line); color: var(--secondary); font-size: 12px; }
70+app.app.compact aside.inspector { padding-top: 8px; }
71+app.app.compact textarea.document-editor { padding: 12px; }
72+app.app.compact button.menu-item { padding-top: 6px; padding-bottom: 6px; }
A examples/demo/main.go +364 -0
@@ -0,0 +1,364 @@
1+// Command demo shows a desktop document workspace (toolbar, document list,
2+// text editor and properties pane, light and dark themes) in a Wayland window.
3+//
4+// NeferGUI draws and neferclient owns the Wayland window; the two libraries do
5+// not import each other, so this file copies plain fields between them. The
6+// glue follows neferclient's examples/layer program, with an xdg-toplevel
7+// window instead of a layer surface.
8+//
9+// Everything runs on one owner goroutine: the loop in run waits for a wake-up,
10+// calls Conn.Dispatch, then renders and presents.
11+package main
12+
13+import (
14+ "context"
15+ _ "embed"
16+ "errors"
17+ "flag"
18+ "fmt"
19+ "os"
20+ "os/signal"
21+ "syscall"
22+ "time"
23+
24+ "github.com/bnema/neferclient"
25+ "github.com/bnema/nefergui"
26+)
27+
28+//go:embed app.css
29+var stylesheet []byte
30+
31+// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
32+// bit order, so the plain bits can be copied.
33+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
34+ neferclient.ModCapsLock | neferclient.ModNumLock
35+
36+type demo struct {
37+ neferclient.NopHandler // methods this program does not need
38+
39+ conn *neferclient.Conn
40+ surf *neferclient.Surface
41+ seat *neferclient.Seat
42+
43+ styles string // path of the stylesheet file for RendererConfig.Styles
44+ r *nefergui.Renderer
45+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
46+ out nefergui.Output
47+ model Model
48+ damage []neferclient.Rect
49+
50+ configured bool
51+ canPresent bool // configured, and the last frame callback fired
52+ haveAcquire bool
53+ cursor neferclient.CursorShape
54+
55+ frames, shown int // -frames limit and frames acknowledged so far
56+ done bool
57+ err error
58+}
59+
60+func main() {
61+ frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
62+ flag.Parse()
63+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64+ defer stop()
65+ if err := run(ctx, *frames); err != nil {
66+ fmt.Fprintln(os.Stderr, "demo:", err)
67+ os.Exit(1)
68+ }
69+}
70+
71+func run(ctx context.Context, frames int) (err error) {
72+ styles, err := writeStyles()
73+ if err != nil {
74+ return err
75+ }
76+ defer os.Remove(styles)
77+
78+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
79+ if err != nil {
80+ return fmt.Errorf("connect: %w", err)
81+ }
82+ d := &demo{conn: conn, styles: styles, frames: frames, cursor: neferclient.CursorDefault, model: Model{
83+ Page: "home", Status: "Ready", Density: "comfortable", Volume: 40,
84+ Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session.",
85+ }}
86+ // Close the connection first (it destroys every Wayland object), then
87+ // free the renderer and its GPU resources.
88+ defer func() {
89+ err = errors.Join(err, conn.Close())
90+ if d.r != nil {
91+ err = errors.Join(err, d.r.Close())
92+ }
93+ }()
94+ if d.surf, err = conn.NewToplevel("NeferGUI — Untitled.txt", 960, 640); err != nil {
95+ return fmt.Errorf("window: %w", err)
96+ }
97+ d.seat = conn.Seat()
98+
99+ var tick <-chan time.Time
100+ for !d.done {
101+ select {
102+ case <-ctx.Done():
103+ return nil // interrupt or SIGTERM: a clean exit
104+ case <-conn.Wake():
105+ if err = conn.Dispatch(d); err != nil {
106+ return fmt.Errorf("dispatch: %w", err)
107+ }
108+ case <-d.rwake: // another goroutine asked for a frame
109+ case <-tick: // a built frame was waiting for the GPU
110+ }
111+ if d.err != nil {
112+ return d.err
113+ }
114+ if err = d.draw(); err != nil {
115+ return err
116+ }
117+ tick = nil
118+ if d.r != nil && d.r.Pending() {
119+ tick = time.After(2 * time.Millisecond)
120+ }
121+ }
122+ return nil
123+}
124+
125+// writeStyles writes the embedded stylesheet to a private temporary file:
126+// RendererConfig.Styles takes a path.
127+func writeStyles() (string, error) {
128+ f, err := os.CreateTemp("", "nefergui-demo-*.css")
129+ if err != nil {
130+ return "", fmt.Errorf("stylesheet: %w", err)
131+ }
132+ _, err = f.Write(stylesheet)
133+ if err = errors.Join(err, f.Close()); err != nil {
134+ os.Remove(f.Name())
135+ return "", fmt.Errorf("stylesheet: %w", err)
136+ }
137+ return f.Name(), nil
138+}
139+
140+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
141+// complete feedback is used; this demo assumes a single GPU.
142+func (d *demo) FeedbackDone(neferclient.SurfaceID) { d.setup() }
143+
144+func (d *demo) Configure(neferclient.SurfaceID, int32, int32) {
145+ if !d.configured { // later configures must not lift the frame-callback gate
146+ d.canPresent = true
147+ }
148+ d.configured = true
149+ d.resize()
150+ d.setup()
151+}
152+
153+func (d *demo) Scale(neferclient.SurfaceID, float64) { d.resize() }
154+
155+func (d *demo) setup() {
156+ fb := d.surf.Feedback()
157+ if d.r != nil || !d.configured || fb == nil {
158+ return
159+ }
160+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Styles: d.styles, Formats: make([]nefergui.Format, len(fb.Formats))}
161+ for i, f := range fb.Formats {
162+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
163+ }
164+ r, err := nefergui.NewRenderer(cfg)
165+ if err != nil {
166+ d.fail(fmt.Errorf("renderer: %w", err))
167+ return
168+ }
169+ d.r, d.rwake = r, r.Wake()
170+ d.resize()
171+}
172+
173+// resize forwards the logical size and scale to the renderer.
174+func (d *demo) resize() {
175+ if d.r == nil {
176+ return
177+ }
178+ w, h, scale := d.surf.Size()
179+ d.r.Resize(int(w), int(h), scale)
180+}
181+
182+// Frame: the compositor showed the last commit; the next Present is allowed.
183+func (d *demo) Frame(neferclient.SurfaceID) {
184+ d.canPresent = true
185+ d.shown++
186+ if d.frames > 0 {
187+ if d.shown >= d.frames {
188+ d.done = true
189+ return
190+ }
191+ d.r.Invalidate()
192+ }
193+}
194+
195+func (d *demo) Closed(neferclient.SurfaceID) { d.done = true }
196+
197+func (d *demo) Error(err error) { d.fail(err) }
198+
199+func (d *demo) fail(err error) {
200+ if d.err == nil {
201+ d.err = err
202+ }
203+}
204+
205+// FDReady: a buffer's release eventfd is readable (id is the buffer).
206+func (d *demo) FDReady(id uint64) {
207+ if err := d.r.Released(id); err != nil {
208+ d.fail(fmt.Errorf("release buffer %d: %w", id, err))
209+ }
210+}
211+
212+// Pointer and Key: neferclient events → nefergui.Input, field by field.
213+func (d *demo) Pointer(ev *neferclient.PointerEvent) {
214+ if d.r == nil {
215+ return
216+ }
217+ in := nefergui.Input{X: ev.X, Y: ev.Y}
218+ switch ev.Kind {
219+ case neferclient.PointerEnter:
220+ in.Kind = nefergui.InputPointerMotion
221+ // The cursor shape is only valid after the enter: set it again.
222+ if err := d.seat.SetCursor(d.cursor); err != nil {
223+ d.fail(fmt.Errorf("cursor: %w", err))
224+ }
225+ case neferclient.PointerMotion:
226+ in.Kind = nefergui.InputPointerMotion
227+ case neferclient.PointerLeave:
228+ in.Kind = nefergui.InputPointerLeave
229+ case neferclient.PointerButton:
230+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
231+ if ev.Pressed {
232+ in.Kind = nefergui.InputPointerPress
233+ }
234+ case neferclient.PointerAxis:
235+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
236+ default:
237+ return
238+ }
239+ d.r.Input(&in)
240+}
241+
242+func (d *demo) Key(ev *neferclient.KeyEvent) {
243+ if d.r == nil {
244+ return
245+ }
246+ d.r.Input(&nefergui.Input{
247+ Kind: nefergui.InputKey,
248+ Keysym: ev.Keysym,
249+ Text: ev.Text, // valid only during the call, as for Input
250+ Pressed: ev.Pressed,
251+ Repeat: ev.Repeat,
252+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
253+ })
254+}
255+
256+func (d *demo) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
257+ if d.r == nil {
258+ return
259+ }
260+ kind := nefergui.InputFocusOut
261+ if focused {
262+ kind = nefergui.InputFocusIn
263+ }
264+ d.r.Input(&nefergui.Input{Kind: kind})
265+}
266+
267+// draw renders a frame when something changed and presents it.
268+func (d *demo) draw() error {
269+ if d.r == nil || !d.canPresent {
270+ return nil
271+ }
272+ ok, err := d.r.Render(&d.out, &d.model, app)
273+ if err != nil {
274+ return fmt.Errorf("render: %w", err)
275+ }
276+ if !ok {
277+ return nil
278+ }
279+ if err = d.present(); err != nil {
280+ return err
281+ }
282+ return d.setCursor()
283+}
284+
285+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
286+func (d *demo) present() error {
287+ out := &d.out
288+ // A resize retired buffers: stop watching and destroy them first.
289+ for _, rt := range out.Retired {
290+ if err := d.conn.UnwatchFD(rt.ReleaseFD); err != nil {
291+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
292+ }
293+ if err := d.surf.DestroyBuffer(rt.Buffer); err != nil {
294+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
295+ }
296+ // The release timeline of a buffer is imported under the buffer's id.
297+ if err := d.surf.DestroyTimeline(rt.Buffer); err != nil {
298+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
299+ }
300+ }
301+ if out.NewBuffer {
302+ buf := neferclient.Buffer{
303+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
304+ PlaneCount: out.PlaneCount,
305+ }
306+ for i, p := range out.Planes {
307+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
308+ }
309+ if err := d.surf.ImportBuffer(out.Buffer, &buf); err != nil {
310+ return fmt.Errorf("import buffer: %w", err)
311+ }
312+ // The release eventfd is stable per buffer: watch it once.
313+ if err := d.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
314+ return fmt.Errorf("watch release fd: %w", err)
315+ }
316+ }
317+ if out.NewTimelines {
318+ if !d.haveAcquire { // the acquire timeline is shared by every buffer
319+ if err := d.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
320+ return fmt.Errorf("import acquire timeline: %w", err)
321+ }
322+ d.haveAcquire = true
323+ }
324+ if err := d.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
325+ return fmt.Errorf("import release timeline: %w", err)
326+ }
327+ }
328+ d.damage = d.damage[:0]
329+ for _, r := range out.Damage {
330+ d.damage = append(d.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
331+ }
332+ err := d.surf.Present(&neferclient.Present{
333+ Buffer: out.Buffer,
334+ AcquireTimeline: out.Acquire.ID,
335+ ReleaseTimeline: out.Release.ID,
336+ AcquirePoint: out.AcquirePoint,
337+ ReleasePoint: out.ReleasePoint,
338+ Damage: d.damage,
339+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
340+ })
341+ if err != nil {
342+ return fmt.Errorf("present: %w", err)
343+ }
344+ d.canPresent = false // until Frame
345+ return nil
346+}
347+
348+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
349+func (d *demo) setCursor() error {
350+ switch d.out.Cursor {
351+ case nefergui.CursorPointer:
352+ d.cursor = neferclient.CursorPointer
353+ case nefergui.CursorText:
354+ d.cursor = neferclient.CursorText
355+ case nefergui.CursorNotAllowed:
356+ d.cursor = neferclient.CursorNotAllowed
357+ default:
358+ d.cursor = neferclient.CursorDefault
359+ }
360+ if err := d.seat.SetCursor(d.cursor); err != nil {
361+ return fmt.Errorf("cursor: %w", err)
362+ }
363+ return nil
364+}
@@ -0,0 +1,364 @@
1+// Command demo shows a desktop document workspace (toolbar, document list,
2+// text editor and properties pane, light and dark themes) in a Wayland window.
3+//
4+// NeferGUI draws and neferclient owns the Wayland window; the two libraries do
5+// not import each other, so this file copies plain fields between them. The
6+// glue follows neferclient's examples/layer program, with an xdg-toplevel
7+// window instead of a layer surface.
8+//
9+// Everything runs on one owner goroutine: the loop in run waits for a wake-up,
10+// calls Conn.Dispatch, then renders and presents.
11+package main
12+
13+import (
14+ "context"
15+ _ "embed"
16+ "errors"
17+ "flag"
18+ "fmt"
19+ "os"
20+ "os/signal"
21+ "syscall"
22+ "time"
23+
24+ "github.com/bnema/neferclient"
25+ "github.com/bnema/nefergui"
26+)
27+
28+//go:embed app.css
29+var stylesheet []byte
30+
31+// guiMods is the set of modifiers NeferGUI knows. Both libraries use the same
32+// bit order, so the plain bits can be copied.
33+const guiMods = neferclient.ModShift | neferclient.ModCtrl | neferclient.ModAlt | neferclient.ModSuper |
34+ neferclient.ModCapsLock | neferclient.ModNumLock
35+
36+type demo struct {
37+ neferclient.NopHandler // methods this program does not need
38+
39+ conn *neferclient.Conn
40+ surf *neferclient.Surface
41+ seat *neferclient.Seat
42+
43+ styles string // path of the stylesheet file for RendererConfig.Styles
44+ r *nefergui.Renderer
45+ rwake <-chan struct{} // Renderer.Wake: nil until the renderer exists
46+ out nefergui.Output
47+ model Model
48+ damage []neferclient.Rect
49+
50+ configured bool
51+ canPresent bool // configured, and the last frame callback fired
52+ haveAcquire bool
53+ cursor neferclient.CursorShape
54+
55+ frames, shown int // -frames limit and frames acknowledged so far
56+ done bool
57+ err error
58+}
59+
60+func main() {
61+ frames := flag.Int("frames", 0, "redraw continuously and exit after this many frames (0: run until closed)")
62+ flag.Parse()
63+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64+ defer stop()
65+ if err := run(ctx, *frames); err != nil {
66+ fmt.Fprintln(os.Stderr, "demo:", err)
67+ os.Exit(1)
68+ }
69+}
70+
71+func run(ctx context.Context, frames int) (err error) {
72+ styles, err := writeStyles()
73+ if err != nil {
74+ return err
75+ }
76+ defer os.Remove(styles)
77+
78+ conn, err := neferclient.Connect(ctx, "") // WAYLAND_DISPLAY
79+ if err != nil {
80+ return fmt.Errorf("connect: %w", err)
81+ }
82+ d := &demo{conn: conn, styles: styles, frames: frames, cursor: neferclient.CursorDefault, model: Model{
83+ Page: "home", Status: "Ready", Density: "comfortable", Volume: 40,
84+ Notes: "Project notes\n\nA native text workspace built with NeferGUI.\n\nSelect text, edit a paragraph, or paste from your clipboard.\nUse the properties pane to try radio buttons and the slider.\n\nEverything stays in memory for this session.",
85+ }}
86+ // Close the connection first (it destroys every Wayland object), then
87+ // free the renderer and its GPU resources.
88+ defer func() {
89+ err = errors.Join(err, conn.Close())
90+ if d.r != nil {
91+ err = errors.Join(err, d.r.Close())
92+ }
93+ }()
94+ if d.surf, err = conn.NewToplevel("NeferGUI — Untitled.txt", 960, 640); err != nil {
95+ return fmt.Errorf("window: %w", err)
96+ }
97+ d.seat = conn.Seat()
98+
99+ var tick <-chan time.Time
100+ for !d.done {
101+ select {
102+ case <-ctx.Done():
103+ return nil // interrupt or SIGTERM: a clean exit
104+ case <-conn.Wake():
105+ if err = conn.Dispatch(d); err != nil {
106+ return fmt.Errorf("dispatch: %w", err)
107+ }
108+ case <-d.rwake: // another goroutine asked for a frame
109+ case <-tick: // a built frame was waiting for the GPU
110+ }
111+ if d.err != nil {
112+ return d.err
113+ }
114+ if err = d.draw(); err != nil {
115+ return err
116+ }
117+ tick = nil
118+ if d.r != nil && d.r.Pending() {
119+ tick = time.After(2 * time.Millisecond)
120+ }
121+ }
122+ return nil
123+}
124+
125+// writeStyles writes the embedded stylesheet to a private temporary file:
126+// RendererConfig.Styles takes a path.
127+func writeStyles() (string, error) {
128+ f, err := os.CreateTemp("", "nefergui-demo-*.css")
129+ if err != nil {
130+ return "", fmt.Errorf("stylesheet: %w", err)
131+ }
132+ _, err = f.Write(stylesheet)
133+ if err = errors.Join(err, f.Close()); err != nil {
134+ os.Remove(f.Name())
135+ return "", fmt.Errorf("stylesheet: %w", err)
136+ }
137+ return f.Name(), nil
138+}
139+
140+// FeedbackDone: dmabuf feedback → nefergui.RendererConfig. Only the first
141+// complete feedback is used; this demo assumes a single GPU.
142+func (d *demo) FeedbackDone(neferclient.SurfaceID) { d.setup() }
143+
144+func (d *demo) Configure(neferclient.SurfaceID, int32, int32) {
145+ if !d.configured { // later configures must not lift the frame-callback gate
146+ d.canPresent = true
147+ }
148+ d.configured = true
149+ d.resize()
150+ d.setup()
151+}
152+
153+func (d *demo) Scale(neferclient.SurfaceID, float64) { d.resize() }
154+
155+func (d *demo) setup() {
156+ fb := d.surf.Feedback()
157+ if d.r != nil || !d.configured || fb == nil {
158+ return
159+ }
160+ cfg := nefergui.RendererConfig{MainDevice: fb.MainDevice, Styles: d.styles, Formats: make([]nefergui.Format, len(fb.Formats))}
161+ for i, f := range fb.Formats {
162+ cfg.Formats[i] = nefergui.Format{FourCC: f.FourCC, Modifier: f.Modifier}
163+ }
164+ r, err := nefergui.NewRenderer(cfg)
165+ if err != nil {
166+ d.fail(fmt.Errorf("renderer: %w", err))
167+ return
168+ }
169+ d.r, d.rwake = r, r.Wake()
170+ d.resize()
171+}
172+
173+// resize forwards the logical size and scale to the renderer.
174+func (d *demo) resize() {
175+ if d.r == nil {
176+ return
177+ }
178+ w, h, scale := d.surf.Size()
179+ d.r.Resize(int(w), int(h), scale)
180+}
181+
182+// Frame: the compositor showed the last commit; the next Present is allowed.
183+func (d *demo) Frame(neferclient.SurfaceID) {
184+ d.canPresent = true
185+ d.shown++
186+ if d.frames > 0 {
187+ if d.shown >= d.frames {
188+ d.done = true
189+ return
190+ }
191+ d.r.Invalidate()
192+ }
193+}
194+
195+func (d *demo) Closed(neferclient.SurfaceID) { d.done = true }
196+
197+func (d *demo) Error(err error) { d.fail(err) }
198+
199+func (d *demo) fail(err error) {
200+ if d.err == nil {
201+ d.err = err
202+ }
203+}
204+
205+// FDReady: a buffer's release eventfd is readable (id is the buffer).
206+func (d *demo) FDReady(id uint64) {
207+ if err := d.r.Released(id); err != nil {
208+ d.fail(fmt.Errorf("release buffer %d: %w", id, err))
209+ }
210+}
211+
212+// Pointer and Key: neferclient events → nefergui.Input, field by field.
213+func (d *demo) Pointer(ev *neferclient.PointerEvent) {
214+ if d.r == nil {
215+ return
216+ }
217+ in := nefergui.Input{X: ev.X, Y: ev.Y}
218+ switch ev.Kind {
219+ case neferclient.PointerEnter:
220+ in.Kind = nefergui.InputPointerMotion
221+ // The cursor shape is only valid after the enter: set it again.
222+ if err := d.seat.SetCursor(d.cursor); err != nil {
223+ d.fail(fmt.Errorf("cursor: %w", err))
224+ }
225+ case neferclient.PointerMotion:
226+ in.Kind = nefergui.InputPointerMotion
227+ case neferclient.PointerLeave:
228+ in.Kind = nefergui.InputPointerLeave
229+ case neferclient.PointerButton:
230+ in.Kind, in.Button = nefergui.InputPointerRelease, ev.Button
231+ if ev.Pressed {
232+ in.Kind = nefergui.InputPointerPress
233+ }
234+ case neferclient.PointerAxis:
235+ in.Kind, in.DX, in.DY = nefergui.InputPointerAxis, ev.DX, ev.DY
236+ default:
237+ return
238+ }
239+ d.r.Input(&in)
240+}
241+
242+func (d *demo) Key(ev *neferclient.KeyEvent) {
243+ if d.r == nil {
244+ return
245+ }
246+ d.r.Input(&nefergui.Input{
247+ Kind: nefergui.InputKey,
248+ Keysym: ev.Keysym,
249+ Text: ev.Text, // valid only during the call, as for Input
250+ Pressed: ev.Pressed,
251+ Repeat: ev.Repeat,
252+ Modifiers: nefergui.Modifiers(ev.Modifiers & guiMods),
253+ })
254+}
255+
256+func (d *demo) KeyboardFocus(_ neferclient.SurfaceID, focused bool) {
257+ if d.r == nil {
258+ return
259+ }
260+ kind := nefergui.InputFocusOut
261+ if focused {
262+ kind = nefergui.InputFocusIn
263+ }
264+ d.r.Input(&nefergui.Input{Kind: kind})
265+}
266+
267+// draw renders a frame when something changed and presents it.
268+func (d *demo) draw() error {
269+ if d.r == nil || !d.canPresent {
270+ return nil
271+ }
272+ ok, err := d.r.Render(&d.out, &d.model, app)
273+ if err != nil {
274+ return fmt.Errorf("render: %w", err)
275+ }
276+ if !ok {
277+ return nil
278+ }
279+ if err = d.present(); err != nil {
280+ return err
281+ }
282+ return d.setCursor()
283+}
284+
285+// present: Renderer.Render output → ImportBuffer, ImportTimeline, Present.
286+func (d *demo) present() error {
287+ out := &d.out
288+ // A resize retired buffers: stop watching and destroy them first.
289+ for _, rt := range out.Retired {
290+ if err := d.conn.UnwatchFD(rt.ReleaseFD); err != nil {
291+ return fmt.Errorf("unwatch retired buffer %d: %w", rt.Buffer, err)
292+ }
293+ if err := d.surf.DestroyBuffer(rt.Buffer); err != nil {
294+ return fmt.Errorf("destroy retired buffer %d: %w", rt.Buffer, err)
295+ }
296+ // The release timeline of a buffer is imported under the buffer's id.
297+ if err := d.surf.DestroyTimeline(rt.Buffer); err != nil {
298+ return fmt.Errorf("destroy retired timeline %d: %w", rt.Buffer, err)
299+ }
300+ }
301+ if out.NewBuffer {
302+ buf := neferclient.Buffer{
303+ Width: out.Width, Height: out.Height, FourCC: out.FourCC, Modifier: out.Modifier,
304+ PlaneCount: out.PlaneCount,
305+ }
306+ for i, p := range out.Planes {
307+ buf.Planes[i] = neferclient.Plane{FD: p.FD, Offset: p.Offset, Stride: p.Stride}
308+ }
309+ if err := d.surf.ImportBuffer(out.Buffer, &buf); err != nil {
310+ return fmt.Errorf("import buffer: %w", err)
311+ }
312+ // The release eventfd is stable per buffer: watch it once.
313+ if err := d.conn.WatchFD(out.ReleaseFD, out.Buffer); err != nil {
314+ return fmt.Errorf("watch release fd: %w", err)
315+ }
316+ }
317+ if out.NewTimelines {
318+ if !d.haveAcquire { // the acquire timeline is shared by every buffer
319+ if err := d.surf.ImportTimeline(out.Acquire.ID, out.Acquire.FD); err != nil {
320+ return fmt.Errorf("import acquire timeline: %w", err)
321+ }
322+ d.haveAcquire = true
323+ }
324+ if err := d.surf.ImportTimeline(out.Release.ID, out.Release.FD); err != nil {
325+ return fmt.Errorf("import release timeline: %w", err)
326+ }
327+ }
328+ d.damage = d.damage[:0]
329+ for _, r := range out.Damage {
330+ d.damage = append(d.damage, neferclient.Rect{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height})
331+ }
332+ err := d.surf.Present(&neferclient.Present{
333+ Buffer: out.Buffer,
334+ AcquireTimeline: out.Acquire.ID,
335+ ReleaseTimeline: out.Release.ID,
336+ AcquirePoint: out.AcquirePoint,
337+ ReleasePoint: out.ReleasePoint,
338+ Damage: d.damage,
339+ Opaque: true, // XRGB8888: RendererConfig.Transparent is false
340+ })
341+ if err != nil {
342+ return fmt.Errorf("present: %w", err)
343+ }
344+ d.canPresent = false // until Frame
345+ return nil
346+}
347+
348+// setCursor: the cursor the hovered element asked for → Seat.SetCursor.
349+func (d *demo) setCursor() error {
350+ switch d.out.Cursor {
351+ case nefergui.CursorPointer:
352+ d.cursor = neferclient.CursorPointer
353+ case nefergui.CursorText:
354+ d.cursor = neferclient.CursorText
355+ case nefergui.CursorNotAllowed:
356+ d.cursor = neferclient.CursorNotAllowed
357+ default:
358+ d.cursor = neferclient.CursorDefault
359+ }
360+ if err := d.seat.SetCursor(d.cursor); err != nil {
361+ return fmt.Errorf("cursor: %w", err)
362+ }
363+ return nil
364+}
A examples/demo/main_test.go +20 -0
@@ -0,0 +1,20 @@
1+package main
2+
3+import "testing"
4+
5+func TestNewDocumentRequiresConfirmation(t *testing.T) {
6+ m := Model{Notes: "Keep this text", Page: "home"}
7+ m.requestNew()
8+ if m.Notes != "Keep this text" || !m.NewPending {
9+ t.Fatalf("first request discarded text: %+v", m)
10+ }
11+ m.NewPending = false // Cancel
12+ m.requestNew()
13+ if m.Notes != "Keep this text" || !m.NewPending {
14+ t.Fatalf("cancel failed to reset confirmation: %+v", m)
15+ }
16+ m.requestNew()
17+ if m.Notes != "" || m.NewPending {
18+ t.Fatalf("confirmation did not clear document: %+v", m)
19+ }
20+}
@@ -0,0 +1,20 @@
1+package main
2+
3+import "testing"
4+
5+func TestNewDocumentRequiresConfirmation(t *testing.T) {
6+ m := Model{Notes: "Keep this text", Page: "home"}
7+ m.requestNew()
8+ if m.Notes != "Keep this text" || !m.NewPending {
9+ t.Fatalf("first request discarded text: %+v", m)
10+ }
11+ m.NewPending = false // Cancel
12+ m.requestNew()
13+ if m.Notes != "Keep this text" || !m.NewPending {
14+ t.Fatalf("cancel failed to reset confirmation: %+v", m)
15+ }
16+ m.requestNew()
17+ if m.Notes != "" || m.NewPending {
18+ t.Fatalf("confirmation did not clear document: %+v", m)
19+ }
20+}
A examples/demo/view.go +128 -0
@@ -0,0 +1,128 @@
1+package main
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/nefergui"
7+)
8+
9+type Model struct {
10+ Page, Name, Status string
11+ Notes string
12+ NewPending bool
13+ DarkMode bool
14+ Density string
15+ Volume float64
16+}
17+
18+func navButton(parent nefergui.Node, m *Model, label, page string) {
19+ opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")}
20+ if m.Page == page {
21+ opts = append(opts, nefergui.Class("active"))
22+ }
23+ if parent.Button(label, opts...).Activated() {
24+ m.Page = page
25+ }
26+}
27+func home(parent nefergui.Node, m *Model) {
28+ workspace := parent.Row(nefergui.Class("workspace"))
29+ editor := workspace.Column(nefergui.Class("editor-pane"))
30+ tab := editor.Row(nefergui.Class("pane-bar"))
31+ tab.Text("Untitled.txt", nefergui.Class("document-title"))
32+ tab.Spacer(nefergui.Class("stretch"))
33+ tab.Text("Plain text", nefergui.Class("hint"))
34+ if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() {
35+ m.NewPending = false
36+ m.Status = "Unsaved changes · session only"
37+ }
38+ inspector := workspace.Aside(nefergui.Class("inspector"))
39+ inspector.Text("PROPERTIES", nefergui.Class("eyebrow"))
40+ inspector.Text("Author", nefergui.Class("field-label"))
41+ inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name"))
42+ if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() {
43+ m.Status = "Author set to " + m.Name + " · session only"
44+ }
45+ inspector.Separator()
46+ inspector.Text("Interface density", nefergui.Class("field-label"))
47+ for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} {
48+ inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))
49+ }
50+ inspector.Separator()
51+ inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))
52+ inspector.Spacer(nefergui.Class("stretch"))
53+ inspector.Text("Changes stay in this session.", nefergui.Class("hint"))
54+}
55+func settings(parent nefergui.Node, m *Model) {
56+ bar := parent.Row(nefergui.Class("pane-bar"))
57+ bar.Text("Preferences", nefergui.Class("document-title"))
58+ pane := parent.Column(nefergui.Class("preferences-pane"))
59+ pane.Text("Appearance", nefergui.Class("field-label"))
60+ pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance"))
61+ pane.Text("Rendering", nefergui.Class("field-label"))
62+ pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted"))
63+ pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint"))
64+}
65+
66+// requestNew arms confirmation without changing the document. A second request
67+// discards it; editing or Cancel disarms the pending request.
68+func (m *Model) requestNew() {
69+ if m.Notes != "" && !m.NewPending {
70+ m.NewPending = true
71+ m.Status = "Click Discard & new to replace the current document"
72+ return
73+ }
74+ m.Notes = ""
75+ m.NewPending = false
76+ m.Page = "home"
77+ m.Status = "New document · session only"
78+}
79+
80+func app(f *nefergui.Frame, m *Model) {
81+ theme := "light"
82+ if m.DarkMode {
83+ theme = "dark"
84+ }
85+ root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density))
86+ header := root.Header(nefergui.Class("header"))
87+ header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo"))
88+ newLabel := "New"
89+ if m.NewPending {
90+ newLabel = "Discard & new"
91+ }
92+ if header.Button(newLabel, nefergui.Key("new-document")).Activated() {
93+ m.requestNew()
94+ }
95+ if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() {
96+ m.NewPending = false
97+ m.Status = "Ready"
98+ }
99+ header.Text("Untitled.txt", nefergui.Class("muted"))
100+ header.Spacer(nefergui.Class("stretch"))
101+ label := "Dark mode"
102+ if m.DarkMode {
103+ label = "Light mode"
104+ }
105+ if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() {
106+ m.DarkMode = !m.DarkMode
107+ }
108+ body := root.Row(nefergui.Class("body"))
109+ sidebar := body.Aside(nefergui.Class("sidebar"))
110+ sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow"))
111+ menu := sidebar.Nav(nefergui.Class("menu"))
112+ navButton(menu, m, "Untitled.txt", "home")
113+ navButton(menu, m, "Preferences", "settings")
114+ sidebar.Spacer(nefergui.Class("stretch"))
115+ sidebar.Text("Local workspace", nefergui.Class("sidebar-note"))
116+ sidebar.Text("1 document · in memory", nefergui.Class("hint"))
117+ content := body.Main(nefergui.Class("content"))
118+ switch m.Page {
119+ case "home":
120+ home(content, m)
121+ case "settings":
122+ settings(content, m)
123+ }
124+ footer := root.Footer(nefergui.Class("status-bar"))
125+ footer.Text(m.Status)
126+ footer.Spacer(nefergui.Class("stretch"))
127+ footer.Text("UTF-8 • Plain text", nefergui.Class("hint"))
128+}
@@ -0,0 +1,128 @@
1+package main
2+
3+import (
4+ "fmt"
5+
6+ "github.com/bnema/nefergui"
7+)
8+
9+type Model struct {
10+ Page, Name, Status string
11+ Notes string
12+ NewPending bool
13+ DarkMode bool
14+ Density string
15+ Volume float64
16+}
17+
18+func navButton(parent nefergui.Node, m *Model, label, page string) {
19+ opts := []nefergui.ButtonOption{nefergui.Key(page), nefergui.Class("menu-item")}
20+ if m.Page == page {
21+ opts = append(opts, nefergui.Class("active"))
22+ }
23+ if parent.Button(label, opts...).Activated() {
24+ m.Page = page
25+ }
26+}
27+func home(parent nefergui.Node, m *Model) {
28+ workspace := parent.Row(nefergui.Class("workspace"))
29+ editor := workspace.Column(nefergui.Class("editor-pane"))
30+ tab := editor.Row(nefergui.Class("pane-bar"))
31+ tab.Text("Untitled.txt", nefergui.Class("document-title"))
32+ tab.Spacer(nefergui.Class("stretch"))
33+ tab.Text("Plain text", nefergui.Class("hint"))
34+ if editor.Textarea("Document", &m.Notes, nefergui.Key("document"), nefergui.Class("document-editor"), nefergui.Placeholder("Start writing…")).Changed() {
35+ m.NewPending = false
36+ m.Status = "Unsaved changes · session only"
37+ }
38+ inspector := workspace.Aside(nefergui.Class("inspector"))
39+ inspector.Text("PROPERTIES", nefergui.Class("eyebrow"))
40+ inspector.Text("Author", nefergui.Class("field-label"))
41+ inspector.Input("Author", &m.Name, nefergui.Key("name"), nefergui.Placeholder("Your name"))
42+ if inspector.Button("Apply", nefergui.Key("continue"), nefergui.Disabled(m.Name == "")).Activated() {
43+ m.Status = "Author set to " + m.Name + " · session only"
44+ }
45+ inspector.Separator()
46+ inspector.Text("Interface density", nefergui.Class("field-label"))
47+ for _, d := range []struct{ label, value string }{{"Comfortable", "comfortable"}, {"Compact", "compact"}} {
48+ inspector.Radio(d.label, d.value, &m.Density, nefergui.Key(d.value))
49+ }
50+ inspector.Separator()
51+ inspector.Slider(fmt.Sprintf("Volume %.0f %%", m.Volume), &m.Volume, 0, 100, 5, nefergui.Key("volume"))
52+ inspector.Spacer(nefergui.Class("stretch"))
53+ inspector.Text("Changes stay in this session.", nefergui.Class("hint"))
54+}
55+func settings(parent nefergui.Node, m *Model) {
56+ bar := parent.Row(nefergui.Class("pane-bar"))
57+ bar.Text("Preferences", nefergui.Class("document-title"))
58+ pane := parent.Column(nefergui.Class("preferences-pane"))
59+ pane.Text("Appearance", nefergui.Class("field-label"))
60+ pane.Checkbox("Dark mode", &m.DarkMode, nefergui.Key("appearance"))
61+ pane.Text("Rendering", nefergui.Class("field-label"))
62+ pane.Text("Wayland · Vulkan · UTF-8", nefergui.Class("muted"))
63+ pane.Text("Document properties are available in the editor's right pane.", nefergui.Class("hint"))
64+}
65+
66+// requestNew arms confirmation without changing the document. A second request
67+// discards it; editing or Cancel disarms the pending request.
68+func (m *Model) requestNew() {
69+ if m.Notes != "" && !m.NewPending {
70+ m.NewPending = true
71+ m.Status = "Click Discard & new to replace the current document"
72+ return
73+ }
74+ m.Notes = ""
75+ m.NewPending = false
76+ m.Page = "home"
77+ m.Status = "New document · session only"
78+}
79+
80+func app(f *nefergui.Frame, m *Model) {
81+ theme := "light"
82+ if m.DarkMode {
83+ theme = "dark"
84+ }
85+ root := f.Root(nefergui.Class("app"), nefergui.Class(theme), nefergui.Class(m.Density))
86+ header := root.Header(nefergui.Class("header"))
87+ header.Heading("NeferGUI", nefergui.Level(1), nefergui.Class("logo"))
88+ newLabel := "New"
89+ if m.NewPending {
90+ newLabel = "Discard & new"
91+ }
92+ if header.Button(newLabel, nefergui.Key("new-document")).Activated() {
93+ m.requestNew()
94+ }
95+ if m.NewPending && header.Button("Cancel", nefergui.Key("cancel-new")).Activated() {
96+ m.NewPending = false
97+ m.Status = "Ready"
98+ }
99+ header.Text("Untitled.txt", nefergui.Class("muted"))
100+ header.Spacer(nefergui.Class("stretch"))
101+ label := "Dark mode"
102+ if m.DarkMode {
103+ label = "Light mode"
104+ }
105+ if header.Button(label, nefergui.Key("dark-mode"), nefergui.Class("theme-toggle")).Activated() {
106+ m.DarkMode = !m.DarkMode
107+ }
108+ body := root.Row(nefergui.Class("body"))
109+ sidebar := body.Aside(nefergui.Class("sidebar"))
110+ sidebar.Text("DOCUMENTS", nefergui.Class("eyebrow"))
111+ menu := sidebar.Nav(nefergui.Class("menu"))
112+ navButton(menu, m, "Untitled.txt", "home")
113+ navButton(menu, m, "Preferences", "settings")
114+ sidebar.Spacer(nefergui.Class("stretch"))
115+ sidebar.Text("Local workspace", nefergui.Class("sidebar-note"))
116+ sidebar.Text("1 document · in memory", nefergui.Class("hint"))
117+ content := body.Main(nefergui.Class("content"))
118+ switch m.Page {
119+ case "home":
120+ home(content, m)
121+ case "settings":
122+ settings(content, m)
123+ }
124+ footer := root.Footer(nefergui.Class("status-bar"))
125+ footer.Text(m.Status)
126+ footer.Spacer(nefergui.Class("stretch"))
127+ footer.Text("UTF-8 • Plain text", nefergui.Class("hint"))
128+}
A examples/doc.go +4 -0
@@ -0,0 +1,4 @@
1+// Package examples holds NeferGUI example programs. It is a separate module so
2+// that NeferGUI itself imports no Wayland code: the programs use neferclient
3+// for the window and copy plain fields between the two libraries.
4+package examples
@@ -0,0 +1,4 @@
1+// Package examples holds NeferGUI example programs. It is a separate module so
2+// that NeferGUI itself imports no Wayland code: the programs use neferclient
3+// for the window and copy plain fields between the two libraries.
4+package examples
A examples/go.mod +23 -0
@@ -0,0 +1,23 @@
1+module github.com/bnema/nefergui/examples
2+
3+go 1.27
4+
5+// The examples always build against the NeferGUI code next to them.
6+replace github.com/bnema/nefergui => ../
7+
8+require (
9+ github.com/bnema/neferclient v0.2.0
10+ github.com/bnema/nefergui v0.0.0-00010101000000-000000000000
11+)
12+
13+require (
14+ github.com/bnema/go-wayland-bindings v0.1.0 // indirect
15+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
16+ github.com/bnema/purego-vulkan v0.6.0 // indirect
17+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
18+ github.com/bnema/wlturbo v0.6.2 // indirect
19+ github.com/go-text/typesetting v0.3.5 // indirect
20+ golang.org/x/image v0.46.0 // indirect
21+ golang.org/x/sys v0.48.0 // indirect
22+ golang.org/x/text v0.42.0 // indirect
23+)
@@ -0,0 +1,23 @@
1+module github.com/bnema/nefergui/examples
2+
3+go 1.27
4+
5+// The examples always build against the NeferGUI code next to them.
6+replace github.com/bnema/nefergui => ../
7+
8+require (
9+ github.com/bnema/neferclient v0.2.0
10+ github.com/bnema/nefergui v0.0.0-00010101000000-000000000000
11+)
12+
13+require (
14+ github.com/bnema/go-wayland-bindings v0.1.0 // indirect
15+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
16+ github.com/bnema/purego-vulkan v0.6.0 // indirect
17+ github.com/bnema/purego-xkbcommon v0.2.0 // indirect
18+ github.com/bnema/wlturbo v0.6.2 // indirect
19+ github.com/go-text/typesetting v0.3.5 // indirect
20+ golang.org/x/image v0.46.0 // indirect
21+ golang.org/x/sys v0.48.0 // indirect
22+ golang.org/x/text v0.42.0 // indirect
23+)
A examples/go.sum +28 -0 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close