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 Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.6.1
10 files · +652 -1
M .github/workflows/ci.yml +1 -0
| @@ -31,3 +31,4 @@ jobs: | |||
| 31 | 31 | - run: make check | |
| 32 | 32 | - run: make race | |
| 33 | 33 | - run: make mocks-check | |
| 34 | + | - run: make examples-check | |
| @@ -31,3 +31,4 @@ jobs: | |||||
| 31 | - run: make check | 31 | - run: make check | ||
| 32 | - run: make race | 32 | - run: make race | ||
| 33 | - run: make mocks-check | 33 | - 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 | |
| 2 | 2 | ||
| 3 | 3 | build: | |
| 4 | 4 | CGO_ENABLED=0 go build ./... | |
| @@ -21,3 +21,6 @@ perf-check: | |||
| 21 | 21 | CGO_ENABLED=0 go test -run 'TestAlloc' -count=1 ./... | |
| 22 | 22 | check: build vet test fakes-check perf-check | |
| 23 | 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 ./... | |
| @@ -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 |
| 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-check | 22 | 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 | |||
| 41 | 41 | ||
| 42 | 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 | 52 | ## Requirements | |
| 45 | 53 | ||
| 46 | 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. | |
| @@ -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 | ## Requirements | 52 | ## 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 | + | ) | |||