detached 1488e4a

Merge pull request #1 from bnema/refactor/deepen-gui-modules

refactor(gui): deepen frame ownership and simplify the repository root
commit
1488e4a390e93603976ae3f5e9ac3fdb1bd00c1a
author
bnema · · 2026-09-30 03:56 UTC
committer
GitHub · · 2026-09-30 03:56 UTC
merge of
5203207 + f8b8aaa · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
92 files · +1489 -418
M README.md +43 -28
@@ -1,49 +1,64 @@
11 # NeferGUI
22
3-Native immediate-mode GUI library for Go, built for Wayland with Vulkan rendering and CSS styling.
3+A native Go GUI library for Linux: Wayland windows, Vulkan rendering, and CSS styling. Build your view from the current model; controls return events during that call. No cgo required.
44
5-## Status
5+> [!WARNING]
6+> **Early development.** No release yet. Expect bugs and breaking changes. Platform accessibility is not connected.
67
7-NeferGUI is under active development and has no release yet.
8-
9-## Usage
8+## A small example
109
1110 ```go
12-type Model struct{ Name, Status string }
11+package main
1312
14-func view(f *nefergui.Frame, m *Model) {
15- root := f.Root(nefergui.Class("app"))
16- root.Input("Name", &m.Name, nefergui.Key("name"))
17- if root.Button("Greet", nefergui.Key("greet")).Activated() {
18- m.Status = "Hello " + m.Name
19- }
20- root.Text(m.Status)
21-}
13+import (
14+ "context"
15+ "fmt"
16+ "log"
17+
18+ "github.com/bnema/nefergui"
19+)
2220
2321 func main() {
24- var m Model
25- if err := nefergui.Run(context.Background(), &m, view, nefergui.Title("Hello"), nefergui.Size(640, 480), nefergui.Styles("app.css")); err != nil {
22+ count := 0
23+ view := func(f *nefergui.Frame, count *int) {
24+ root := f.Root().Column()
25+ root.Text("Hello, Wayland!")
26+ if root.Button("Count", nefergui.Key("count")).Activated() {
27+ *count++
28+ }
29+ root.Text(fmt.Sprintf("Clicked %d times", *count))
30+ }
31+ if err := nefergui.Run(context.Background(), &count, view,
32+ nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
2633 log.Fatal(err)
2734 }
2835 }
2936 ```
3037
31-Runtime requirements: a Wayland compositor with linux-dmabuf and linux-drm-syncobj, `libvulkan.so.1` and `libxkbcommon.so.0`. Builds with `CGO_ENABLED=0`. See [docs/runtime.md](docs/runtime.md) and `examples/demo`.
38+Default styles work without a stylesheet; `nefergui.Styles("app.css")` loads your own CSS.
3239
33-## Documentation
40+## Try it
3441
35-- [CSS properties and limitations](docs/css.md)
36-- [Layout](docs/layout.md) · [Controls](docs/controls.md) · [Runtime](docs/runtime.md)
37-- [Troubleshooting](docs/troubleshooting.md) · [Harness](docs/harness.md) · [Performance](docs/performance.md)
42+Requires Go 1.27, `libvulkan.so.1`, `libxkbcommon.so.0`, usable fonts, and a Wayland compositor supporting linux-dmabuf and linux-drm-syncobj. Fractional scaling also requires viewporter. No X11 or software-rendering fallback.
3843
39-## Design records
44+```sh
45+go get github.com/bnema/nefergui
46+```
47+
48+From a checkout, run the [demo](examples/demo):
49+
50+```sh
51+CGO_ENABLED=0 go run ./examples/demo
52+```
4053
41-- [0002 Wayland bindings and FD ownership](docs/adr/0002-wayland-bindings-and-fd-ownership.md)
42-- [0003 Vulkan presentation through DMA-BUF](docs/adr/0003-vulkan-dmabuf-presentation.md)
43-- [0004 Keyboard interpretation](docs/adr/0004-keyboard.md)
44-- [0005 Immediate-mode identity and events](docs/adr/0005-identity-and-events.md)
45-- [0006 CSS and text scope](docs/adr/0006-css-and-text-scope.md)
54+## Documentation
55+
56+- [Controls](docs/controls.md) and [layout](docs/layout.md)
57+- [CSS properties and limitations](docs/css.md)
58+- [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md)
59+- [Visual test harness](docs/harness.md) and [performance](docs/performance.md)
60+- [Architecture decisions](docs/adr)
4661
4762 ## License
4863
49-NeferGUI is licensed under [GNU GPL v3](LICENSE). Third-party code and test fonts retain their licenses and attribution notices in their respective directories.
64+[GNU GPL v3](LICENSE). Third-party code and test fonts retain their own licenses and attribution.
@@ -1,49 +1,64 @@
1 # NeferGUI1 # NeferGUI
2 2
3-Native immediate-mode GUI library for Go, built for Wayland with Vulkan rendering and CSS styling.3+A native Go GUI library for Linux: Wayland windows, Vulkan rendering, and CSS styling. Build your view from the current model; controls return events during that call. No cgo required.
4 4
5-## Status5+> [!WARNING]
6+> **Early development.** No release yet. Expect bugs and breaking changes. Platform accessibility is not connected.
6 7
7-NeferGUI is under active development and has no release yet.8+## A small example
8-
9-## Usage
10 9
11 ```go10 ```go
12-type Model struct{ Name, Status string }11+package main
13 12
14-func view(f *nefergui.Frame, m *Model) {13+import (
15- root := f.Root(nefergui.Class("app"))14+ "context"
16- root.Input("Name", &m.Name, nefergui.Key("name"))15+ "fmt"
17- if root.Button("Greet", nefergui.Key("greet")).Activated() {16+ "log"
18- m.Status = "Hello " + m.Name17+
19- }18+ "github.com/bnema/nefergui"
20- root.Text(m.Status)19+)
21-}
22 20
23 func main() {21 func main() {
24- var m Model22+ count := 0
25- if err := nefergui.Run(context.Background(), &m, view, nefergui.Title("Hello"), nefergui.Size(640, 480), nefergui.Styles("app.css")); err != nil {23+ view := func(f *nefergui.Frame, count *int) {
24+ root := f.Root().Column()
25+ root.Text("Hello, Wayland!")
26+ if root.Button("Count", nefergui.Key("count")).Activated() {
27+ *count++
28+ }
29+ root.Text(fmt.Sprintf("Clicked %d times", *count))
30+ }
31+ if err := nefergui.Run(context.Background(), &count, view,
32+ nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
26 log.Fatal(err)33 log.Fatal(err)
27 }34 }
28 }35 }
29 ```36 ```
30 37
31-Runtime requirements: a Wayland compositor with linux-dmabuf and linux-drm-syncobj, `libvulkan.so.1` and `libxkbcommon.so.0`. Builds with `CGO_ENABLED=0`. See [docs/runtime.md](docs/runtime.md) and `examples/demo`.38+Default styles work without a stylesheet; `nefergui.Styles("app.css")` loads your own CSS.
32 39
33-## Documentation40+## Try it
34 41
35-- [CSS properties and limitations](docs/css.md)42+Requires Go 1.27, `libvulkan.so.1`, `libxkbcommon.so.0`, usable fonts, and a Wayland compositor supporting linux-dmabuf and linux-drm-syncobj. Fractional scaling also requires viewporter. No X11 or software-rendering fallback.
36-- [Layout](docs/layout.md) · [Controls](docs/controls.md) · [Runtime](docs/runtime.md)
37-- [Troubleshooting](docs/troubleshooting.md) · [Harness](docs/harness.md) · [Performance](docs/performance.md)
38 43
39-## Design records44+```sh
45+go get github.com/bnema/nefergui
46+```
47+
48+From a checkout, run the [demo](examples/demo):
49+
50+```sh
51+CGO_ENABLED=0 go run ./examples/demo
52+```
40 53
41-- [0002 Wayland bindings and FD ownership](docs/adr/0002-wayland-bindings-and-fd-ownership.md)54+## Documentation
42-- [0003 Vulkan presentation through DMA-BUF](docs/adr/0003-vulkan-dmabuf-presentation.md)55+
43-- [0004 Keyboard interpretation](docs/adr/0004-keyboard.md)56+- [Controls](docs/controls.md) and [layout](docs/layout.md)
44-- [0005 Immediate-mode identity and events](docs/adr/0005-identity-and-events.md)57+- [CSS properties and limitations](docs/css.md)
45-- [0006 CSS and text scope](docs/adr/0006-css-and-text-scope.md)58+- [Runtime requirements and behavior](docs/runtime.md) and [troubleshooting](docs/troubleshooting.md)
59+- [Visual test harness](docs/harness.md) and [performance](docs/performance.md)
60+- [Architecture decisions](docs/adr)
46 61
47 ## License62 ## License
48 63
49-NeferGUI is licensed under [GNU GPL v3](LICENSE). Third-party code and test fonts retain their licenses and attribution notices in their respective directories.64+[GNU GPL v3](LICENSE). Third-party code and test fonts retain their own licenses and attribution.
D doc.go +0 -13
@@ -1,13 +0,0 @@
1-// Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with
2-// Vulkan and styled with a bounded CSS dialect (see docs/css.md).
3-//
4-// Run opens a window and calls the view function to build each frame. The view
5-// receives a Frame; Frame and its Node handles are valid only during that call.
6-// Key identifies a child among its siblings across frames; without a key,
7-// position and type determine identity. ID is CSS metadata, not frame identity.
8-// Options such as Title, Size, Styles and Transparent configure the window.
9-//
10-// Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1,
11-// libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and
12-// linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md.
13-package nefergui
@@ -1,13 +0,0 @@
1-// Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with
2-// Vulkan and styled with a bounded CSS dialect (see docs/css.md).
3-//
4-// Run opens a window and calls the view function to build each frame. The view
5-// receives a Frame; Frame and its Node handles are valid only during that call.
6-// Key identifies a child among its siblings across frames; without a key,
7-// position and type determine identity. ID is CSS metadata, not frame identity.
8-// Options such as Title, Size, Styles and Transparent configure the window.
9-//
10-// Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1,
11-// libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and
12-// linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md.
13-package nefergui
M docs/controls.md +6 -2
@@ -1,5 +1,9 @@
11 # Controls
22
3+Import `github.com/bnema/nefergui`. Public `Frame`, `Node` and event types alias their implementation types, so `go doc` does not list their methods at the root. Inspect signatures with `go doc github.com/bnema/nefergui/internal/ui.Node` (or `.Frame`, `.ButtonEvent`, `.EditEvent`, `.ChangeEvent`). Applications still use the public package; do not import `internal/ui`.
4+
5+`ButtonEvent.Activated()` reports activation; `EditEvent.Changed()` and `.Submitted()` report edits and submission; `ChangeEvent.Changed()` reports value changes. These snapshots can be queried repeatedly without consuming an event.
6+
37 `Text`, `Heading`, `Image`, `Icon`, `Separator`, `Spacer`, `Button`, `Checkbox`, `Radio`, `Slider`, `Input` and `Textarea` build semantic nodes in the immediate frame. `Key` identifies reorderable controls; `ID` and `Class` are CSS metadata. `Image` accepts an existing `image.Image` with bounded intrinsic dimensions. Style is computed via the existing CSS cascade; the caller owns images. CSS types and default accessibility roles are assigned even though a platform accessibility adapter is not yet connected.
48
59 `Button`, `Checkbox`, `Radio` and `Slider` paint their label as text in the control's content box, using its font, `color` and `text-align` (buttons center by default).
@@ -8,11 +12,11 @@ Checkbox and radio paint a one-em indicator, centered vertically, in their left
812
913 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
1014
11-`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries the preceding run supplies the caret edge. No undo/redo is implemented.
15+`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries and soft wraps, caret painting, scrolling and preedit positioning use the preceding character's trailing edge. Up/Down navigation treats a shared soft-wrap offset as belonging to the following line. Password geometry maps offsets by grapheme, like the displayed mask. No undo/redo is implemented.
1216
1317 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.
1418
15-Display-list JSON snapshots under `testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list.
19+Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list.
1620
1721 The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above.
1822
@@ -1,5 +1,9 @@
1 # Controls1 # Controls
2 2
3+Import `github.com/bnema/nefergui`. Public `Frame`, `Node` and event types alias their implementation types, so `go doc` does not list their methods at the root. Inspect signatures with `go doc github.com/bnema/nefergui/internal/ui.Node` (or `.Frame`, `.ButtonEvent`, `.EditEvent`, `.ChangeEvent`). Applications still use the public package; do not import `internal/ui`.
4+
5+`ButtonEvent.Activated()` reports activation; `EditEvent.Changed()` and `.Submitted()` report edits and submission; `ChangeEvent.Changed()` reports value changes. These snapshots can be queried repeatedly without consuming an event.
6+
3 `Text`, `Heading`, `Image`, `Icon`, `Separator`, `Spacer`, `Button`, `Checkbox`, `Radio`, `Slider`, `Input` and `Textarea` build semantic nodes in the immediate frame. `Key` identifies reorderable controls; `ID` and `Class` are CSS metadata. `Image` accepts an existing `image.Image` with bounded intrinsic dimensions. Style is computed via the existing CSS cascade; the caller owns images. CSS types and default accessibility roles are assigned even though a platform accessibility adapter is not yet connected.7 `Text`, `Heading`, `Image`, `Icon`, `Separator`, `Spacer`, `Button`, `Checkbox`, `Radio`, `Slider`, `Input` and `Textarea` build semantic nodes in the immediate frame. `Key` identifies reorderable controls; `ID` and `Class` are CSS metadata. `Image` accepts an existing `image.Image` with bounded intrinsic dimensions. Style is computed via the existing CSS cascade; the caller owns images. CSS types and default accessibility roles are assigned even though a platform accessibility adapter is not yet connected.
4 8
5 `Button`, `Checkbox`, `Radio` and `Slider` paint their label as text in the control's content box, using its font, `color` and `text-align` (buttons center by default).9 `Button`, `Checkbox`, `Radio` and `Slider` paint their label as text in the control's content box, using its font, `color` and `text-align` (buttons center by default).
@@ -8,11 +12,11 @@ Checkbox and radio paint a one-em indicator, centered vertically, in their left
8 12
9 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.13 The pointer cursor follows the CSS `cursor` of the hovered element (`pointer`, `text`, `not-allowed`; `auto` and `default` use the default arrow). While a primary button is held, the pressed element keeps its cursor. The UA sheet sets `pointer` on buttons, checkboxes, radios and sliders, `text` on inputs and textareas, and `not-allowed` on disabled controls. NeferGUI sets the shape through `wp_cursor_shape_manager_v1` when the compositor offers it; otherwise the compositor's cursor stays unchanged. `Placeholder` paints dimmed text (half opacity) in an empty `Input` or `Textarea` until a value or preedit exists; it never enters the bound value. The `label` argument of `Input` and `Textarea` is not painted; place a visible label with `Text`.
10 14
11-`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries the preceding run supplies the caret edge. No undo/redo is implemented.15+`Checkbox` toggles `*bool`; `Radio(label, option, *string, ...)` selects an option in a string-valued group; `Slider(label, *float64, min, max, step, ...)` changes its pointer-backed value on arrow, Home/End and PageUp/Down keys. These controls never retain the model pointer. `Disabled(true)` blocks interaction. `Input` and `Textarea` bind `*string`; `Password(true)` masks the displayed string per Unicode grapheme and blocks copy/cut, but not paste. The editor stores cursor and anchor in UTF-8 byte offsets at extended-grapheme boundaries. Left/Right and selections are **logical**, not visual. Primary pointer press places the caret at the nearest shaped grapheme boundary, Shift+press extends from the existing anchor, and captured dragging extends even outside the control. A second press on the same control within 500 ms and 4 logical pixels selects exactly the word-iterator segment under that boundary; at a boundary the segment to the right wins, including whitespace or punctuation segments. An unrelated press resets this sequence. Pointer hit testing requires a measured text layout. Textarea Up/Down traverse wrapped visual lines at a retained preferred x; horizontal movement and edits reset it. The per-identity editor viewport scrolls vertically in Textarea and horizontally in unwrapped Input to keep the caret visible, clamping at the measured extent. Focused editors emit visual cluster highlight rectangles and a one-pixel caret inside the editor clip. At LTR/RTL run boundaries and soft wraps, caret painting, scrolling and preedit positioning use the preceding character's trailing edge. Up/Down navigation treats a shared soft-wrap offset as belonging to the following line. Password geometry maps offsets by grapheme, like the displayed mask. No undo/redo is implemented.
12 16
13 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.17 `internal/edit` supplies the headless clipboard (`ReadText(limit)`, `WriteText`) and IME (`Enable`, `Disable`, `Surrounding`, `CursorRect`, `ContentType`) ports. A read is limited to 1 MiB and rejected unless UTF-8. IME batches represent preedit, commit and delete-surrounding relative to the UTF-8 cursor, processed at done. The preedit is shaped inline at the model cursor with an underline and its own cursor commands, without changing the model or its normal text run until commit. Password preedit is masked per grapheme before shaping or entering the display list. Ordinary keyboard text is inserted while an IME port is active, except while a preedit is shown; an input method that grabs the keyboard receives its keys instead of the editor. The ports do not negotiate MIME types, own FDs, or provide a Wayland adapter.
14 18
15-Display-list JSON snapshots under `testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list.19+Controls are implemented in `internal/ui`; the root package re-exports their types. Display-list JSON snapshots under `internal/ui/testdata/control-display` cover every control's normal and hover states, interactive focus-visible and disabled states, checkbox/radio checked states, and Input LTR/Arabic selections and preedit. They are deterministic command snapshots, not raster screenshots. Platform `Run` uses the same control tree and display list.
16 20
17 The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above.21 The additive UA rules for `checkbox`, `radio`, `slider`, `separator`, `spacer`, and `icon` live in `internal/css/ua.css`. Their dimensions and colors are regular computed CSS and can be overridden by author styles. The control helpers hard-code no paint values; only the indicator painter fixes the shape details listed above.
18 22
M docs/performance.md +24 -4
@@ -1,10 +1,12 @@
1-# CPU performance budgets
1+# Performance
2+
3+## CPU budgets
24
35 AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission.
46
57 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |
68 |---|---:|---:|---:|---:|---:|---:|
7-| root / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 |
9+| internal/ui / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 |
810 | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 |
911 | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 |
1012 | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 |
@@ -24,11 +26,29 @@ Frame construction stays low-allocation because:
2426 - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions);
2527 - identity path keys are computed once per element.
2628
27-`Run` builds a frame only on input or a redraw request, so an idle window allocates nothing. Profile with:
29+`Run` builds a frame only on input or a redraw request. Idle frame construction stops, but platform release-wait polling can still allocate small amounts. Profile with:
2830
2931 ```sh
30-go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 .
32+go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui
3133 go tool pprof -sample_index=alloc_objects -top -cum mem.out
3234 ```
3335
3436 A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load.
37+
38+## Memory as a dependency
39+
40+An external Go module with a minimal counter view, a 960×640 window, and a private headless NeferWL compositor was profiled on AMD/RADV. Baseline: NeferGUI `068ba85`, Go 1.27.1, Mesa/RADV 26.2.3, and NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. A minimal-consumer repeat at `84c1f24` matched retained heap within 0.02 MiB and used identical GPU allocations. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages.
41+
42+| Measurement | Minimal view | Demo view |
43+|---|---:|---:|
44+| Live Go heap after 1,000 frames | 5.3 MiB | 5.4 MiB |
45+| Process RSS / PSS after 1,000 frames | 60 / 43 MiB | 64 / 47 MiB |
46+| Estimated temporary allocations per rendered frame, measurement sampler's own allocations excluded | about 65 KiB | about 230 KiB |
47+
48+The minimal view starts at about 0.75 MiB live Go heap, reaches 5.2 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 1,000–3,000 frames. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own.
49+
50+DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.
51+
52+The largest frame-allocation sources are prepared quads, render batches, and layout painting. Release-wait polling allocated about 150 KiB over a 38-second idle interval. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work and raised observed peak RSS to about 113 MiB; leave it unset when measuring ordinary application cost.
53+
54+Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.
@@ -1,10 +1,12 @@
1-# CPU performance budgets1+# Performance
2+
3+## CPU budgets
2 4
3 AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission.5 AMD Ryzen 9 7900X3D 12-Core Processor; `CGO_ENABLED=0 GOWORK=off go test -run '^$' -bench . -benchmem -count=5` in the indicated packages. Entries are medians of five runs. Budgets are approximately twice the measured medians (zero allocations remain zero). CPU numbers vary with machine load. The demo-frame benchmark builds a headless home-view tree, computes layout and produces a display list after warmup; Prepare uses a representative warm text/box command mix and excludes GPU submission.
4 6
5 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |7 | Package / benchmark | Median ns/op | Median B/op | Median allocs/op | Budget ns/op | Budget B/op | Budget allocs/op |
6 |---|---:|---:|---:|---:|---:|---:|8 |---|---:|---:|---:|---:|---:|---:|
7-| root / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 |9+| internal/ui / DemoFrame | 21,238 | 32,816 | 220 | 45,000 | 66,000 | 500 |
8 | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 |10 | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 |
9 | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 |11 | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 |
10 | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 |12 | text / RasterDistinct1000 | 4,139,638 | 8,566,137 | 16,784 | 8,300,000 | 17,200,000 | 34,000 |
@@ -24,11 +26,29 @@ Frame construction stays low-allocation because:
24 - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions);26 - `text.Engine.Measure` caches results by text, request and width; entries unused for two frames are evicted and the cache holds at most 4,096 entries (callers treat results as immutable and use `Layout.Clone` before adjusting positions);
25 - identity path keys are computed once per element.27 - identity path keys are computed once per element.
26 28
27-`Run` builds a frame only on input or a redraw request, so an idle window allocates nothing. Profile with:29+`Run` builds a frame only on input or a redraw request. Idle frame construction stops, but platform release-wait polling can still allocate small amounts. Profile with:
28 30
29 ```sh31 ```sh
30-go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 .32+go test -run '^$' -bench BenchmarkDemoFrame -benchmem -memprofile mem.out -memprofilerate=1 ./internal/ui
31 go tool pprof -sample_index=alloc_objects -top -cum mem.out33 go tool pprof -sample_index=alloc_objects -top -cum mem.out
32 ```34 ```
33 35
34 A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load.36 A 400-frame demo harness run (960×640, scale 1, input: name Ada then continue) recorded `submit_to_commit_ns` median **184,545 ns** and nearest-rank p95 **481,550 ns** in `frame-timings.jsonl`. This is host-side submission-to-Wayland-commit latency, not GPU execution time. `atlas_pages` stayed at **1** allocated 1024×1024 grayscale page (maximum 4) for all 400 frames. These figures depend on the compositor, driver and machine load.
37+
38+## Memory as a dependency
39+
40+An external Go module with a minimal counter view, a 960×640 window, and a private headless NeferWL compositor was profiled on AMD/RADV. Baseline: NeferGUI `068ba85`, Go 1.27.1, Mesa/RADV 26.2.3, and NeferWL `1d16b83bf90f9e8189ad21b27772382c96b9f9a8`. A minimal-consumer repeat at `84c1f24` matched retained heap within 0.02 MiB and used identical GPU allocations. Debug readbacks were disabled. Live Go heap was sampled after forced GC; RSS includes shared driver libraries, while PSS apportions shared pages.
41+
42+| Measurement | Minimal view | Demo view |
43+|---|---:|---:|
44+| Live Go heap after 1,000 frames | 5.3 MiB | 5.4 MiB |
45+| Process RSS / PSS after 1,000 frames | 60 / 43 MiB | 64 / 47 MiB |
46+| Estimated temporary allocations per rendered frame, measurement sampler's own allocations excluded | about 65 KiB | about 230 KiB |
47+
48+The minimal view starts at about 0.75 MiB live Go heap, reaches 5.2 MiB after the first frame, and returns to about 0.9 MiB after closing. No sustained live-heap growth was observed over 1,000–3,000 frames. Driver mappings and Go heap capacity can remain resident after closing; RSS is not a leak measurement on its own.
49+
50+DRM counters reported about 41 MiB VRAM and 4 MiB GTT once all three presentation buffers were active. Window dimensions, scale, fonts and driver affect these figures. These are measurements on one stack, not portable limits.
51+
52+The largest frame-allocation sources are prepared quads, render batches, and layout painting. Release-wait polling allocated about 150 KiB over a 38-second idle interval. `NEFERGUI_DEBUG_DIR` enables readbacks and PNG work and raised observed peak RSS to about 113 MiB; leave it unset when measuring ordinary application cost.
53+
54+Use `pprof`'s `alloc_space` to find cumulative allocation churn and `inuse_space` after GC to inspect retained Go memory. Use `/proc/<pid>/smaps_rollup` for RSS/PSS and DRM fdinfo for GPU counters; heap profiles do not include driver or GPU memory.
D edit_geometry.go +0 -129
@@ -1,129 +0,0 @@
1-package nefergui
2-
3-import (
4- "math"
5- "sort"
6- "unicode/utf8"
7-
8- "github.com/bnema/nefergui/internal/edit"
9- "github.com/bnema/nefergui/internal/layout"
10- "github.com/go-text/typesetting/di"
11-)
12-
13-type textBoundary struct {
14- byteOffset int
15- x, y float64
16-}
17-
18-// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
19-// Runs are in visual order; glyph cluster indices are source rune offsets.
20-func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
21- if n == nil {
22- return nil
23- }
24- offsets := edit.Boundaries(value)
25- runeAt := make(map[int]int, len(offsets))
26- for _, b := range offsets {
27- runeAt[utf8.RuneCountInString(value[:b])] = b
28- }
29- var runs []layout.Run
30- for _, cmd := range display {
31- if cmd.ID == n.ID && cmd.Op == "text" {
32- runs = cmd.Runs
33- break
34- }
35- }
36- var lines [][]textBoundary
37- ri := 0
38- top := n.Content.Y
39- for _, line := range n.Lines {
40- current := []textBoundary{}
41- for _, shaped := range line.Runs {
42- if ri >= len(runs) {
43- break
44- }
45- run := runs[ri]
46- ri++
47- // Cluster start and end positions are derived from the glyph ink advances,
48- // not rune counts (ligatures and combining graphemes have one visual span).
49- type span struct{ low, high float64 }
50- spans := make(map[int]span)
51- for _, g := range run.Glyphs {
52- p, ok := spans[g.Cluster]
53- if !ok {
54- p = span{g.X, g.X + g.Advance}
55- } else {
56- p.low = math.Min(p.low, g.X)
57- p.high = math.Max(p.high, g.X+g.Advance)
58- }
59- spans[g.Cluster] = p
60- }
61- starts := make([]int, 0, len(spans))
62- for k := range spans {
63- starts = append(starts, k)
64- }
65- sort.Ints(starts)
66- for i, k := range starts {
67- end := shaped.End
68- if i+1 < len(starts) {
69- end = starts[i+1]
70- }
71- p := spans[k]
72- a, b := p.low, p.high
73- if shaped.Direction == di.DirectionRTL {
74- a, b = b, a
75- }
76- if offset, ok := runeAt[k]; ok {
77- current = append(current, textBoundary{offset, a, top})
78- }
79- if offset, ok := runeAt[end]; ok {
80- current = append(current, textBoundary{offset, b, top})
81- }
82- }
83- }
84- if len(current) == 0 { // empty paragraph, including a trailing newline
85- offset := 0
86- if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
87- for _, boundary := range lines[len(lines)-1] {
88- if boundary.byteOffset > offset {
89- offset = boundary.byteOffset
90- }
91- }
92- for offset < len(value) && value[offset] == '\n' {
93- offset++
94- }
95- }
96- current = append(current, textBoundary{offset, n.Content.X, top})
97- }
98- lines = append(lines, current)
99- top += line.Height
100- }
101- return lines
102-}
103-
104-func nearestTextBoundary(value string, n *layout.Result, display []layout.Command, x, y float64) int {
105- lines := textBoundaries(value, n, display)
106- if len(lines) == 0 {
107- return 0
108- }
109- line := 0
110- top := n.Content.Y
111- for i, shaped := range n.Lines {
112- if y >= top+shaped.Height/2 {
113- line = i
114- }
115- top += shaped.Height
116- }
117- if line >= len(lines) {
118- line = len(lines) - 1
119- }
120- best := 0
121- distance := math.Inf(1)
122- for _, b := range lines[line] {
123- if d := math.Abs(b.x - x); d < distance {
124- distance = d
125- best = b.byteOffset
126- }
127- }
128- return best
129-}
@@ -1,129 +0,0 @@
1-package nefergui
2-
3-import (
4- "math"
5- "sort"
6- "unicode/utf8"
7-
8- "github.com/bnema/nefergui/internal/edit"
9- "github.com/bnema/nefergui/internal/layout"
10- "github.com/go-text/typesetting/di"
11-)
12-
13-type textBoundary struct {
14- byteOffset int
15- x, y float64
16-}
17-
18-// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
19-// Runs are in visual order; glyph cluster indices are source rune offsets.
20-func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
21- if n == nil {
22- return nil
23- }
24- offsets := edit.Boundaries(value)
25- runeAt := make(map[int]int, len(offsets))
26- for _, b := range offsets {
27- runeAt[utf8.RuneCountInString(value[:b])] = b
28- }
29- var runs []layout.Run
30- for _, cmd := range display {
31- if cmd.ID == n.ID && cmd.Op == "text" {
32- runs = cmd.Runs
33- break
34- }
35- }
36- var lines [][]textBoundary
37- ri := 0
38- top := n.Content.Y
39- for _, line := range n.Lines {
40- current := []textBoundary{}
41- for _, shaped := range line.Runs {
42- if ri >= len(runs) {
43- break
44- }
45- run := runs[ri]
46- ri++
47- // Cluster start and end positions are derived from the glyph ink advances,
48- // not rune counts (ligatures and combining graphemes have one visual span).
49- type span struct{ low, high float64 }
50- spans := make(map[int]span)
51- for _, g := range run.Glyphs {
52- p, ok := spans[g.Cluster]
53- if !ok {
54- p = span{g.X, g.X + g.Advance}
55- } else {
56- p.low = math.Min(p.low, g.X)
57- p.high = math.Max(p.high, g.X+g.Advance)
58- }
59- spans[g.Cluster] = p
60- }
61- starts := make([]int, 0, len(spans))
62- for k := range spans {
63- starts = append(starts, k)
64- }
65- sort.Ints(starts)
66- for i, k := range starts {
67- end := shaped.End
68- if i+1 < len(starts) {
69- end = starts[i+1]
70- }
71- p := spans[k]
72- a, b := p.low, p.high
73- if shaped.Direction == di.DirectionRTL {
74- a, b = b, a
75- }
76- if offset, ok := runeAt[k]; ok {
77- current = append(current, textBoundary{offset, a, top})
78- }
79- if offset, ok := runeAt[end]; ok {
80- current = append(current, textBoundary{offset, b, top})
81- }
82- }
83- }
84- if len(current) == 0 { // empty paragraph, including a trailing newline
85- offset := 0
86- if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
87- for _, boundary := range lines[len(lines)-1] {
88- if boundary.byteOffset > offset {
89- offset = boundary.byteOffset
90- }
91- }
92- for offset < len(value) && value[offset] == '\n' {
93- offset++
94- }
95- }
96- current = append(current, textBoundary{offset, n.Content.X, top})
97- }
98- lines = append(lines, current)
99- top += line.Height
100- }
101- return lines
102-}
103-
104-func nearestTextBoundary(value string, n *layout.Result, display []layout.Command, x, y float64) int {
105- lines := textBoundaries(value, n, display)
106- if len(lines) == 0 {
107- return 0
108- }
109- line := 0
110- top := n.Content.Y
111- for i, shaped := range n.Lines {
112- if y >= top+shaped.Height/2 {
113- line = i
114- }
115- top += shaped.Height
116- }
117- if line >= len(lines) {
118- line = len(lines) - 1
119- }
120- best := 0
121- distance := math.Inf(1)
122- for _, b := range lines[line] {
123- if d := math.Abs(b.x - x); d < distance {
124- distance = d
125- best = b.byteOffset
126- }
127- }
128- return best
129-}
M example_test.go +20 -0
@@ -2,10 +2,30 @@ package nefergui_test
22
33 import (
44 "context"
5+ "fmt"
6+ "log"
57
68 "github.com/bnema/nefergui"
79 )
810
11+// The README counter. Run needs a Wayland session, so this example is
12+// compiled but not executed by go test.
13+func Example_counter() {
14+ count := 0
15+ view := func(f *nefergui.Frame, count *int) {
16+ root := f.Root().Column()
17+ root.Text("Hello, Wayland!")
18+ if root.Button("Count", nefergui.Key("count")).Activated() {
19+ *count++
20+ }
21+ root.Text(fmt.Sprintf("Clicked %d times", *count))
22+ }
23+ if err := nefergui.Run(context.Background(), &count, view,
24+ nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
25+ log.Fatal(err)
26+ }
27+}
28+
929 // The product-design example. Run needs a Wayland session, so this example is
1030 // compiled but not executed by go test; examples/demo runs it.
1131 func Example_compileOnly() {
@@ -2,10 +2,30 @@ package nefergui_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
6+ "log"
5 7
6 "github.com/bnema/nefergui"8 "github.com/bnema/nefergui"
7 )9 )
8 10
11+// The README counter. Run needs a Wayland session, so this example is
12+// compiled but not executed by go test.
13+func Example_counter() {
14+ count := 0
15+ view := func(f *nefergui.Frame, count *int) {
16+ root := f.Root().Column()
17+ root.Text("Hello, Wayland!")
18+ if root.Button("Count", nefergui.Key("count")).Activated() {
19+ *count++
20+ }
21+ root.Text(fmt.Sprintf("Clicked %d times", *count))
22+ }
23+ if err := nefergui.Run(context.Background(), &count, view,
24+ nefergui.Title("Hello"), nefergui.Size(320, 200)); err != nil {
25+ log.Fatal(err)
26+ }
27+}
28+
9 // The product-design example. Run needs a Wayland session, so this example is29 // The product-design example. Run needs a Wayland session, so this example is
10 // compiled but not executed by go test; examples/demo runs it.30 // compiled but not executed by go test; examples/demo runs it.
11 func Example_compileOnly() {31 func Example_compileOnly() {
M internal/presentation/session/app.go +22 -45
@@ -40,9 +40,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
4040 }
4141 defer func() { s.waitCancel(); s.waiters.Wait() }()
4242 wlEvents := s.Window.StartReader()
43- dirty := true
44- var prepared render.Frame
45- havePrepared := false
43+ sched := newFrameSchedule()
4644 for !s.Window.Closed {
4745 if err := ctx.Err(); err != nil {
4846 return err
@@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
5149 if err != nil {
5250 return err
5351 }
52+ // Resize or scale change (also set before RunApp, or by the event case
53+ // below): the prepared frame is stale until tick clears Window.Dirty.
5454 if s.Window.Dirty {
55- dirty = true
55+ sched.Invalidate()
5656 }
5757 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {
58- dirty = true
59- havePrepared = false
58+ sched.Invalidate()
6059 if app.Input != nil {
6160 if err := app.Input(inputs); err != nil {
6261 return err
6362 }
6463 }
6564 }
66- if dirty && s.Window.FrameReady {
67- if !havePrepared {
68- frame, changed, err := app.Draw()
69- if err != nil {
70- return err
71- }
72- if !changed {
73- dirty = false
74- } else {
75- prepared, havePrepared = frame, true
76- }
65+ committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
66+ instances, batches, stats := render.ListBatches(prepared)
67+ if len(stats.Skipped) != 0 {
68+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
7769 }
78- if havePrepared {
79- instances, batches, stats := render.ListBatches(prepared)
80- if len(stats.Skipped) != 0 {
81- s.Preparer.Atlas.MarkAllDirty()
82- return fmt.Errorf("unsupported list operations: %v", stats.Skipped)
83- }
84- ok, err := s.tick(nil, instances, prepared.Uploads, batches)
85- if err != nil {
70+ return s.tick(nil, instances, prepared.Uploads, batches)
71+ })
72+ if err != nil {
73+ return err
74+ }
75+ if committed {
76+ if app.Committed != nil {
77+ if err := app.Committed(s.frame); err != nil {
8678 return err
8779 }
88- if ok {
89- dirty = false
90- havePrepared = false
91- if app.Committed != nil {
92- if err := app.Committed(s.frame); err != nil {
93- return err
94- }
95- }
96- continue
97- }
9880 }
81+ continue
9982 }
10083 var retry, repeat <-chan time.Time
101- if pending || s.framePending || (dirty && havePrepared && s.Window.FrameReady) {
102- retry = time.After(5 * time.Millisecond)
84+ if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) {
85+ retry = time.After(retryInterval)
10386 }
10487 if adapter := s.Window.InputAdapter(); adapter != nil {
10588 if at, ok := adapter.NextRepeat(); ok {
@@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
11093 case <-ctx.Done():
11194 return ctx.Err()
11295 case <-app.Wake:
113- dirty = true
114- havePrepared = false
96+ sched.Invalidate()
11597 case <-retry:
11698 case <-repeat:
11799 if adapter := s.Window.InputAdapter(); adapter != nil {
118100 if input, ok := adapter.Tick(time.Now()); ok {
119- dirty = true
120- havePrepared = false
101+ sched.Invalidate()
121102 if app.Input != nil {
122103 if err := app.Input([]wayland.Input{input}); err != nil {
123104 return err
@@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
141122 return err
142123 }
143124 }
144- if s.Window.Dirty {
145- dirty = true
146- havePrepared = false
147- }
148125 // DrainInput is handled at the start of the next iteration.
149126 case ev := <-s.releases:
150127 if err := s.release(ev); err != nil {
@@ -40,9 +40,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
40 }40 }
41 defer func() { s.waitCancel(); s.waiters.Wait() }()41 defer func() { s.waitCancel(); s.waiters.Wait() }()
42 wlEvents := s.Window.StartReader()42 wlEvents := s.Window.StartReader()
43- dirty := true43+ sched := newFrameSchedule()
44- var prepared render.Frame
45- havePrepared := false
46 for !s.Window.Closed {44 for !s.Window.Closed {
47 if err := ctx.Err(); err != nil {45 if err := ctx.Err(); err != nil {
48 return err46 return err
@@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
51 if err != nil {49 if err != nil {
52 return err50 return err
53 }51 }
52+ // Resize or scale change (also set before RunApp, or by the event case
53+ // below): the prepared frame is stale until tick clears Window.Dirty.
54 if s.Window.Dirty {54 if s.Window.Dirty {
55- dirty = true55+ sched.Invalidate()
56 }56 }
57 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {57 if inputs := s.Window.DrainInput(nil); len(inputs) > 0 {
58- dirty = true58+ sched.Invalidate()
59- havePrepared = false
60 if app.Input != nil {59 if app.Input != nil {
61 if err := app.Input(inputs); err != nil {60 if err := app.Input(inputs); err != nil {
62 return err61 return err
63 }62 }
64 }63 }
65 }64 }
66- if dirty && s.Window.FrameReady {65+ committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) {
67- if !havePrepared {66+ instances, batches, stats := render.ListBatches(prepared)
68- frame, changed, err := app.Draw()67+ if len(stats.Skipped) != 0 {
69- if err != nil {68+ return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
70- return err
71- }
72- if !changed {
73- dirty = false
74- } else {
75- prepared, havePrepared = frame, true
76- }
77 }69 }
78- if havePrepared {70+ return s.tick(nil, instances, prepared.Uploads, batches)
79- instances, batches, stats := render.ListBatches(prepared)71+ })
80- if len(stats.Skipped) != 0 {72+ if err != nil {
81- s.Preparer.Atlas.MarkAllDirty()73+ return err
82- return fmt.Errorf("unsupported list operations: %v", stats.Skipped)74+ }
83- }75+ if committed {
84- ok, err := s.tick(nil, instances, prepared.Uploads, batches)76+ if app.Committed != nil {
85- if err != nil {77+ if err := app.Committed(s.frame); err != nil {
86 return err78 return err
87 }79 }
88- if ok {
89- dirty = false
90- havePrepared = false
91- if app.Committed != nil {
92- if err := app.Committed(s.frame); err != nil {
93- return err
94- }
95- }
96- continue
97- }
98 }80 }
81+ continue
99 }82 }
100 var retry, repeat <-chan time.Time83 var retry, repeat <-chan time.Time
101- if pending || s.framePending || (dirty && havePrepared && s.Window.FrameReady) {84+ if sched.NeedsRetry(pending || s.framePending, s.Window.FrameReady) {
102- retry = time.After(5 * time.Millisecond)85+ retry = time.After(retryInterval)
103 }86 }
104 if adapter := s.Window.InputAdapter(); adapter != nil {87 if adapter := s.Window.InputAdapter(); adapter != nil {
105 if at, ok := adapter.NextRepeat(); ok {88 if at, ok := adapter.NextRepeat(); ok {
@@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
110 case <-ctx.Done():93 case <-ctx.Done():
111 return ctx.Err()94 return ctx.Err()
112 case <-app.Wake:95 case <-app.Wake:
113- dirty = true96+ sched.Invalidate()
114- havePrepared = false
115 case <-retry:97 case <-retry:
116 case <-repeat:98 case <-repeat:
117 if adapter := s.Window.InputAdapter(); adapter != nil {99 if adapter := s.Window.InputAdapter(); adapter != nil {
118 if input, ok := adapter.Tick(time.Now()); ok {100 if input, ok := adapter.Tick(time.Now()); ok {
119- dirty = true101+ sched.Invalidate()
120- havePrepared = false
121 if app.Input != nil {102 if app.Input != nil {
122 if err := app.Input([]wayland.Input{input}); err != nil {103 if err := app.Input([]wayland.Input{input}); err != nil {
123 return err104 return err
@@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error {
141 return err122 return err
142 }123 }
143 }124 }
144- if s.Window.Dirty {
145- dirty = true
146- havePrepared = false
147- }
148 // DrainInput is handled at the start of the next iteration.125 // DrainInput is handled at the start of the next iteration.
149 case ev := <-s.releases:126 case ev := <-s.releases:
150 if err := s.release(ev); err != nil {127 if err := s.release(ev); err != nil {
A internal/presentation/session/schedule.go +78 -0
@@ -0,0 +1,78 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "time"
7+
8+ "github.com/bnema/nefergui/internal/render"
9+)
10+
11+// retryInterval bounds how long the owner loop sleeps while a frame is blocked
12+// on GPU or buffer readiness that has no event source of its own.
13+const retryInterval = 5 * time.Millisecond
14+
15+// frameSchedule is RunApp's frame state, touched only from the owner loop.
16+// A frame is wanted (dirty) until the compositor accepted a commit or the app
17+// reported no change. A prepared display list is kept across blocked submits so
18+// a retry does not call Draw again. Input, a wake, resize or scale changes ask
19+// Draw for a replacement; an unchanged draw retains the unsubmitted frame.
20+type frameSchedule struct {
21+ dirty bool
22+ prepared render.Frame
23+ have bool
24+ stale bool
25+}
26+
27+// newFrameSchedule starts dirty so the first configured frame is drawn.
28+func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
29+
30+// Invalidate asks Draw to refresh the UI before submitting. Keep any held
31+// frame until Draw actually replaces it: no-op input must not lose a frame.
32+func (f *frameSchedule) Invalidate() {
33+ f.dirty = true
34+ f.stale = true
35+}
36+
37+// Step attempts one frame. Nothing is drawn unless a frame is wanted and the
38+// compositor allows one (ready). Draw runs when no frame is held or it is
39+// stale; an unchanged draw keeps a held frame, or goes idle without one.
40+// submit reports whether the frame was
41+// committed: on false the prepared frame is kept for the next attempt, on true
42+// the schedule is clean until the next Invalidate.
43+func (f *frameSchedule) Step(
44+ ready bool,
45+ draw func() (render.Frame, bool, error),
46+ submit func(render.Frame) (bool, error),
47+) (committed bool, err error) {
48+ if !f.dirty || !ready {
49+ return false, nil
50+ }
51+ if !f.have || f.stale {
52+ frame, changed, err := draw()
53+ if err != nil {
54+ return false, err
55+ }
56+ f.stale = false
57+ if changed {
58+ f.prepared, f.have = frame, true
59+ } else if !f.have {
60+ f.dirty = false
61+ return false, nil
62+ }
63+ }
64+ committed, err = submit(f.prepared)
65+ if err != nil || !committed {
66+ return false, err
67+ }
68+ f.dirty, f.have, f.stale, f.prepared = false, false, false, render.Frame{}
69+ return true, nil
70+}
71+
72+// NeedsRetry reports whether the loop must wake itself after retryInterval.
73+// gpuPending covers work whose completion only polling can observe. A frame
74+// still to be drawn, or one waiting for a frame callback, is woken by events
75+// instead, so an idle or compositor-throttled loop never spins.
76+func (f *frameSchedule) NeedsRetry(gpuPending, ready bool) bool {
77+ return gpuPending || (f.dirty && f.have && ready)
78+}
@@ -0,0 +1,78 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "time"
7+
8+ "github.com/bnema/nefergui/internal/render"
9+)
10+
11+// retryInterval bounds how long the owner loop sleeps while a frame is blocked
12+// on GPU or buffer readiness that has no event source of its own.
13+const retryInterval = 5 * time.Millisecond
14+
15+// frameSchedule is RunApp's frame state, touched only from the owner loop.
16+// A frame is wanted (dirty) until the compositor accepted a commit or the app
17+// reported no change. A prepared display list is kept across blocked submits so
18+// a retry does not call Draw again. Input, a wake, resize or scale changes ask
19+// Draw for a replacement; an unchanged draw retains the unsubmitted frame.
20+type frameSchedule struct {
21+ dirty bool
22+ prepared render.Frame
23+ have bool
24+ stale bool
25+}
26+
27+// newFrameSchedule starts dirty so the first configured frame is drawn.
28+func newFrameSchedule() *frameSchedule { return &frameSchedule{dirty: true} }
29+
30+// Invalidate asks Draw to refresh the UI before submitting. Keep any held
31+// frame until Draw actually replaces it: no-op input must not lose a frame.
32+func (f *frameSchedule) Invalidate() {
33+ f.dirty = true
34+ f.stale = true
35+}
36+
37+// Step attempts one frame. Nothing is drawn unless a frame is wanted and the
38+// compositor allows one (ready). Draw runs when no frame is held or it is
39+// stale; an unchanged draw keeps a held frame, or goes idle without one.
40+// submit reports whether the frame was
41+// committed: on false the prepared frame is kept for the next attempt, on true
42+// the schedule is clean until the next Invalidate.
43+func (f *frameSchedule) Step(
44+ ready bool,
45+ draw func() (render.Frame, bool, error),
46+ submit func(render.Frame) (bool, error),
47+) (committed bool, err error) {
48+ if !f.dirty || !ready {
49+ return false, nil
50+ }
51+ if !f.have || f.stale {
52+ frame, changed, err := draw()
53+ if err != nil {
54+ return false, err
55+ }
56+ f.stale = false
57+ if changed {
58+ f.prepared, f.have = frame, true
59+ } else if !f.have {
60+ f.dirty = false
61+ return false, nil
62+ }
63+ }
64+ committed, err = submit(f.prepared)
65+ if err != nil || !committed {
66+ return false, err
67+ }
68+ f.dirty, f.have, f.stale, f.prepared = false, false, false, render.Frame{}
69+ return true, nil
70+}
71+
72+// NeedsRetry reports whether the loop must wake itself after retryInterval.
73+// gpuPending covers work whose completion only polling can observe. A frame
74+// still to be drawn, or one waiting for a frame callback, is woken by events
75+// instead, so an idle or compositor-throttled loop never spins.
76+func (f *frameSchedule) NeedsRetry(gpuPending, ready bool) bool {
77+ return gpuPending || (f.dirty && f.have && ready)
78+}
A internal/presentation/session/schedule_test.go +153 -0
@@ -0,0 +1,153 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/render"
10+)
11+
12+// scheduleHarness plays the app and session sides of frameSchedule.
13+type scheduleHarness struct {
14+ t *testing.T
15+ s *frameSchedule
16+ draws int
17+ submits []int // frame id (Quads length) of each submit attempt
18+ changed bool
19+ blocked bool
20+ ready bool
21+ drawErr error
22+ submitErr error
23+}
24+
25+func newHarness(t *testing.T) *scheduleHarness {
26+ return &scheduleHarness{t: t, s: newFrameSchedule(), changed: true, ready: true}
27+}
28+
29+func (h *scheduleHarness) step() bool {
30+ h.t.Helper()
31+ ok, err := h.s.Step(h.ready, func() (render.Frame, bool, error) {
32+ h.draws++
33+ return render.Frame{Quads: make([]render.Quad, h.draws)}, h.changed, h.drawErr
34+ }, func(f render.Frame) (bool, error) {
35+ h.submits = append(h.submits, len(f.Quads))
36+ return !h.blocked, h.submitErr
37+ })
38+ if err != nil && h.drawErr == nil && h.submitErr == nil {
39+ h.t.Fatal(err)
40+ }
41+ return ok
42+}
43+
44+func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
45+ h := newHarness(t)
46+ h.blocked = true
47+ if h.step() || h.draws != 1 || !h.s.NeedsRetry(false, true) {
48+ t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
49+ }
50+ // Retries resubmit the same prepared frame without redrawing.
51+ if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
52+ t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
53+ }
54+ h.blocked = false
55+ if !h.step() || h.draws != 1 || h.s.NeedsRetry(false, true) {
56+ t.Fatalf("release did not commit cleanly: draws=%d submits=%v", h.draws, h.submits)
57+ }
58+ if h.step() || len(h.submits) != 4 {
59+ t.Fatalf("clean schedule submitted again: %v", h.submits)
60+ }
61+}
62+
63+func TestScheduleInvalidateReplacesChangedFrame(t *testing.T) {
64+ h := newHarness(t)
65+ h.blocked = true
66+ h.step()
67+ h.s.Invalidate()
68+ h.blocked = false
69+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 {
70+ t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits)
71+ }
72+}
73+
74+func TestScheduleNoOpInputKeepsUnsubmittedFrame(t *testing.T) {
75+ h := newHarness(t)
76+ h.blocked = true
77+ h.step()
78+ h.s.Invalidate()
79+ h.changed = false
80+ if h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
81+ t.Fatalf("no-op lost held frame: draws=%d submits=%v", h.draws, h.submits)
82+ }
83+ h.blocked = false
84+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
85+ t.Fatalf("release lost held frame: draws=%d submits=%v", h.draws, h.submits)
86+ }
87+ if h.s.NeedsRetry(false, true) {
88+ t.Fatal("committed schedule did not go idle")
89+ }
90+}
91+
92+func TestScheduleDrawUnchangedGoesIdle(t *testing.T) {
93+ h := newHarness(t)
94+ h.changed = false
95+ if h.step() || h.draws != 1 || len(h.submits) != 0 {
96+ t.Fatalf("unchanged draw: draws=%d submits=%v", h.draws, h.submits)
97+ }
98+ if h.s.NeedsRetry(false, true) {
99+ t.Fatal("idle schedule asks for retry (busy loop)")
100+ }
101+ for i := 0; i < 3; i++ {
102+ h.step()
103+ }
104+ if h.draws != 1 {
105+ t.Fatalf("idle loop redrew %d times", h.draws)
106+ }
107+ h.s.Invalidate()
108+ h.changed = true
109+ if !h.step() || h.draws != 2 {
110+ t.Fatalf("wake after idle: draws=%d", h.draws)
111+ }
112+}
113+
114+func TestScheduleWaitsForFrameCallback(t *testing.T) {
115+ h := newHarness(t)
116+ h.ready = false
117+ if h.step() || h.draws != 0 || len(h.submits) != 0 || h.s.NeedsRetry(false, false) {
118+ t.Fatalf("drew before frame callback: draws=%d", h.draws)
119+ }
120+ h.ready = true
121+ if !h.step() || h.draws != 1 {
122+ t.Fatalf("frame callback did not release draw: draws=%d", h.draws)
123+ }
124+ // A prepared frame waiting for a callback is woken by the event, not polled.
125+ h.s.Invalidate()
126+ h.blocked = true
127+ h.step()
128+ if h.s.NeedsRetry(false, false) || !h.s.NeedsRetry(false, true) {
129+ t.Fatal("retry must follow frame readiness")
130+ }
131+}
132+
133+func TestSchedulePendingGPUForcesRetry(t *testing.T) {
134+ h := newHarness(t)
135+ h.changed = false
136+ h.step()
137+ if !h.s.NeedsRetry(true, false) {
138+ t.Fatal("pending GPU work must schedule a bounded retry")
139+ }
140+}
141+
142+func TestScheduleErrorsPropagate(t *testing.T) {
143+ h := newHarness(t)
144+ h.drawErr = errors.New("draw")
145+ if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); !errors.Is(err, h.drawErr) {
146+ t.Fatalf("draw err = %v", err)
147+ }
148+ h.drawErr = nil
149+ h.submitErr = errors.New("submit")
150+ if ok, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, true, nil }, func(render.Frame) (bool, error) { return true, h.submitErr }); ok || !errors.Is(err, h.submitErr) {
151+ t.Fatalf("submit error: ok=%v err=%v", ok, err)
152+ }
153+}
@@ -0,0 +1,153 @@
1+//go:build linux
2+
3+package session
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/bnema/nefergui/internal/render"
10+)
11+
12+// scheduleHarness plays the app and session sides of frameSchedule.
13+type scheduleHarness struct {
14+ t *testing.T
15+ s *frameSchedule
16+ draws int
17+ submits []int // frame id (Quads length) of each submit attempt
18+ changed bool
19+ blocked bool
20+ ready bool
21+ drawErr error
22+ submitErr error
23+}
24+
25+func newHarness(t *testing.T) *scheduleHarness {
26+ return &scheduleHarness{t: t, s: newFrameSchedule(), changed: true, ready: true}
27+}
28+
29+func (h *scheduleHarness) step() bool {
30+ h.t.Helper()
31+ ok, err := h.s.Step(h.ready, func() (render.Frame, bool, error) {
32+ h.draws++
33+ return render.Frame{Quads: make([]render.Quad, h.draws)}, h.changed, h.drawErr
34+ }, func(f render.Frame) (bool, error) {
35+ h.submits = append(h.submits, len(f.Quads))
36+ return !h.blocked, h.submitErr
37+ })
38+ if err != nil && h.drawErr == nil && h.submitErr == nil {
39+ h.t.Fatal(err)
40+ }
41+ return ok
42+}
43+
44+func TestScheduleBlockedFrameKeepsPreparedAndRetries(t *testing.T) {
45+ h := newHarness(t)
46+ h.blocked = true
47+ if h.step() || h.draws != 1 || !h.s.NeedsRetry(false, true) {
48+ t.Fatalf("blocked: draws=%d retry=%v", h.draws, h.s.NeedsRetry(false, true))
49+ }
50+ // Retries resubmit the same prepared frame without redrawing.
51+ if h.step() || h.step() || h.draws != 1 || len(h.submits) != 3 || h.submits[2] != 1 {
52+ t.Fatalf("retry redrew: draws=%d submits=%v", h.draws, h.submits)
53+ }
54+ h.blocked = false
55+ if !h.step() || h.draws != 1 || h.s.NeedsRetry(false, true) {
56+ t.Fatalf("release did not commit cleanly: draws=%d submits=%v", h.draws, h.submits)
57+ }
58+ if h.step() || len(h.submits) != 4 {
59+ t.Fatalf("clean schedule submitted again: %v", h.submits)
60+ }
61+}
62+
63+func TestScheduleInvalidateReplacesChangedFrame(t *testing.T) {
64+ h := newHarness(t)
65+ h.blocked = true
66+ h.step()
67+ h.s.Invalidate()
68+ h.blocked = false
69+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 2 {
70+ t.Fatalf("stale frame submitted: draws=%d submits=%v", h.draws, h.submits)
71+ }
72+}
73+
74+func TestScheduleNoOpInputKeepsUnsubmittedFrame(t *testing.T) {
75+ h := newHarness(t)
76+ h.blocked = true
77+ h.step()
78+ h.s.Invalidate()
79+ h.changed = false
80+ if h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
81+ t.Fatalf("no-op lost held frame: draws=%d submits=%v", h.draws, h.submits)
82+ }
83+ h.blocked = false
84+ if !h.step() || h.draws != 2 || h.submits[len(h.submits)-1] != 1 {
85+ t.Fatalf("release lost held frame: draws=%d submits=%v", h.draws, h.submits)
86+ }
87+ if h.s.NeedsRetry(false, true) {
88+ t.Fatal("committed schedule did not go idle")
89+ }
90+}
91+
92+func TestScheduleDrawUnchangedGoesIdle(t *testing.T) {
93+ h := newHarness(t)
94+ h.changed = false
95+ if h.step() || h.draws != 1 || len(h.submits) != 0 {
96+ t.Fatalf("unchanged draw: draws=%d submits=%v", h.draws, h.submits)
97+ }
98+ if h.s.NeedsRetry(false, true) {
99+ t.Fatal("idle schedule asks for retry (busy loop)")
100+ }
101+ for i := 0; i < 3; i++ {
102+ h.step()
103+ }
104+ if h.draws != 1 {
105+ t.Fatalf("idle loop redrew %d times", h.draws)
106+ }
107+ h.s.Invalidate()
108+ h.changed = true
109+ if !h.step() || h.draws != 2 {
110+ t.Fatalf("wake after idle: draws=%d", h.draws)
111+ }
112+}
113+
114+func TestScheduleWaitsForFrameCallback(t *testing.T) {
115+ h := newHarness(t)
116+ h.ready = false
117+ if h.step() || h.draws != 0 || len(h.submits) != 0 || h.s.NeedsRetry(false, false) {
118+ t.Fatalf("drew before frame callback: draws=%d", h.draws)
119+ }
120+ h.ready = true
121+ if !h.step() || h.draws != 1 {
122+ t.Fatalf("frame callback did not release draw: draws=%d", h.draws)
123+ }
124+ // A prepared frame waiting for a callback is woken by the event, not polled.
125+ h.s.Invalidate()
126+ h.blocked = true
127+ h.step()
128+ if h.s.NeedsRetry(false, false) || !h.s.NeedsRetry(false, true) {
129+ t.Fatal("retry must follow frame readiness")
130+ }
131+}
132+
133+func TestSchedulePendingGPUForcesRetry(t *testing.T) {
134+ h := newHarness(t)
135+ h.changed = false
136+ h.step()
137+ if !h.s.NeedsRetry(true, false) {
138+ t.Fatal("pending GPU work must schedule a bounded retry")
139+ }
140+}
141+
142+func TestScheduleErrorsPropagate(t *testing.T) {
143+ h := newHarness(t)
144+ h.drawErr = errors.New("draw")
145+ if _, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, false, h.drawErr }, nil); !errors.Is(err, h.drawErr) {
146+ t.Fatalf("draw err = %v", err)
147+ }
148+ h.drawErr = nil
149+ h.submitErr = errors.New("submit")
150+ if ok, err := h.s.Step(true, func() (render.Frame, bool, error) { return render.Frame{}, true, nil }, func(render.Frame) (bool, error) { return true, h.submitErr }); ok || !errors.Is(err, h.submitErr) {
151+ t.Fatalf("submit error: ok=%v err=%v", ok, err)
152+ }
153+}
M internal/presentation/session/session.go +4 -11
@@ -343,13 +343,6 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
343343 }
344344
345345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346- // Uploads were drained by Prepare. Restore them if this tick did not submit.
347- submitted := false
348- defer func() {
349- if !submitted && len(uploads) > 0 {
350- s.Preparer.Atlas.MarkAllDirty()
351- }
352- }()
353346 started := time.Now()
354347 if s.Window.Dirty {
355348 w, h, err := s.Window.PhysicalSize()
@@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
402395 if err = slot.Frame.Submit(slot.Wait); err != nil {
403396 return false, err
404397 }
405- submitted = true
398+ // The render module keeps uploads owed until this acknowledgement.
399+ s.Preparer.Submitted(uploads)
406400 if slot.Wait != nil {
407401 slot.WaitInFlight = true
408402 }
@@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int)
677671 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
678672 }
679673
680-// RunPreparedListWithCommit prepares uploads on every attempt. A skipped
681-// submission restores dirty pages, so the next attempt resends them.
674+// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
675+// them owed until a submission, so a skipped attempt is resent by the next one.
682676 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
683677 if draw == nil {
684678 return 0, fmt.Errorf("nil draw function")
@@ -690,7 +684,6 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
690684 }
691685 instances, batches, stats := render.ListBatches(frame)
692686 if len(stats.Skipped) != 0 {
693- s.Preparer.Atlas.MarkAllDirty()
694687 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
695688 }
696689 return s.tick(nil, instances, frame.Uploads, batches)
@@ -343,13 +343,6 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload)
343 }343 }
344 344
345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {345 func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) {
346- // Uploads were drained by Prepare. Restore them if this tick did not submit.
347- submitted := false
348- defer func() {
349- if !submitted && len(uploads) > 0 {
350- s.Preparer.Atlas.MarkAllDirty()
351- }
352- }()
353 started := time.Now()346 started := time.Now()
354 if s.Window.Dirty {347 if s.Window.Dirty {
355 w, h, err := s.Window.PhysicalSize()348 w, h, err := s.Window.PhysicalSize()
@@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa
402 if err = slot.Frame.Submit(slot.Wait); err != nil {395 if err = slot.Frame.Submit(slot.Wait); err != nil {
403 return false, err396 return false, err
404 }397 }
405- submitted = true398+ // The render module keeps uploads owed until this acknowledgement.
399+ s.Preparer.Submitted(uploads)
406 if slot.Wait != nil {400 if slot.Wait != nil {
407 slot.WaitInFlight = true401 slot.WaitInFlight = true
408 }402 }
@@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int)
677 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)671 return s.runWithCommit(ctx, frames, func(i int) (bool, error) { return s.Tick(draw(i)) }, committed)
678 }672 }
679 673
680-// RunPreparedListWithCommit prepares uploads on every attempt. A skipped674+// RunPreparedListWithCommit prepares uploads on every attempt. Preparer keeps
681-// submission restores dirty pages, so the next attempt resends them.675+// them owed until a submission, so a skipped attempt is resent by the next one.
682 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {676 func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) {
683 if draw == nil {677 if draw == nil {
684 return 0, fmt.Errorf("nil draw function")678 return 0, fmt.Errorf("nil draw function")
@@ -690,7 +684,6 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra
690 }684 }
691 instances, batches, stats := render.ListBatches(frame)685 instances, batches, stats := render.ListBatches(frame)
692 if len(stats.Skipped) != 0 {686 if len(stats.Skipped) != 0 {
693- s.Preparer.Atlas.MarkAllDirty()
694 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)687 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
695 }688 }
696 return s.tick(nil, instances, frame.Uploads, batches)689 return s.tick(nil, instances, frame.Uploads, batches)
M internal/render/benchmark_test.go +4 -1
@@ -39,9 +39,11 @@ func BenchmarkPrepare(b *testing.B) {
3939 if err != nil {
4040 b.Fatal(err)
4141 }
42- if _, err = p.Prepare(commands, 1, 960, 640); err != nil {
42+ warm, err := p.Prepare(commands, 1, 960, 640)
43+ if err != nil {
4344 b.Fatal(err)
4445 }
46+ p.Submitted(warm.Uploads)
4547 b.ReportAllocs()
4648 b.ResetTimer()
4749 for i := 0; i < b.N; i++ {
@@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) {
4951 if err != nil || len(frame.Quads) == 0 {
5052 b.Fatalf("prepare: %v", err)
5153 }
54+ p.Submitted(frame.Uploads)
5255 }
5356 }
@@ -39,9 +39,11 @@ func BenchmarkPrepare(b *testing.B) {
39 if err != nil {39 if err != nil {
40 b.Fatal(err)40 b.Fatal(err)
41 }41 }
42- if _, err = p.Prepare(commands, 1, 960, 640); err != nil {42+ warm, err := p.Prepare(commands, 1, 960, 640)
43+ if err != nil {
43 b.Fatal(err)44 b.Fatal(err)
44 }45 }
46+ p.Submitted(warm.Uploads)
45 b.ReportAllocs()47 b.ReportAllocs()
46 b.ResetTimer()48 b.ResetTimer()
47 for i := 0; i < b.N; i++ {49 for i := 0; i < b.N; i++ {
@@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) {
49 if err != nil || len(frame.Quads) == 0 {51 if err != nil || len(frame.Quads) == 0 {
50 b.Fatalf("prepare: %v", err)52 b.Fatalf("prepare: %v", err)
51 }53 }
54+ p.Submitted(frame.Uploads)
52 }55 }
53 }56 }
M internal/render/prepare.go +28 -1
@@ -38,7 +38,17 @@ type Frame struct {
3838 Uploads []text.Upload
3939 }
4040
41-type Preparer struct{ Atlas *text.Atlas }
41+// Preparer owns the glyph atlas and the obligation to deliver its changes to the
42+// GPU mirror. Prepare hands the caller every change not yet known to be
43+// submitted; Submitted is the only acknowledgement. A frame that is retried,
44+// abandoned, superseded or never reached submission therefore never loses
45+// atlas changes: the next Prepare resends whole pages (placements are kept).
46+type Preparer struct {
47+ Atlas *text.Atlas
48+ // owed is true while the latest issued uploads have not been submitted.
49+ owed bool
50+ issued []text.Upload
51+}
4252
4353 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
4454 // layout; the atlas and shaped faces are not safe for concurrent use.
@@ -172,10 +182,27 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
172182 if len(stack) > 0 {
173183 return Frame{}, fmt.Errorf("render: unbalanced clip-push")
174184 }
185+ if p.owed {
186+ // The previous frame's uploads may never have reached the GPU. Full pages
187+ // subsume them and any changes made since.
188+ p.Atlas.MarkAllDirty()
189+ }
175190 frame.Uploads = p.Atlas.Uploads()
191+ p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
176192 return frame, nil
177193 }
178194
195+// Submitted acknowledges that uploads, as returned in the most recent Prepare's
196+// Frame.Uploads, were recorded in a successfully submitted GPU submission.
197+// Any other slice (an older, superseded frame or foreign data) is ignored and
198+// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
199+func (p *Preparer) Submitted(uploads []text.Upload) {
200+ if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
201+ return
202+ }
203+ p.owed, p.issued = false, nil
204+}
205+
179206 func opacity(c css.Color, a float64) css.Color {
180207 c.A *= math.Max(0, math.Min(1, a))
181208 return c
@@ -38,7 +38,17 @@ type Frame struct {
38 Uploads []text.Upload38 Uploads []text.Upload
39 }39 }
40 40
41-type Preparer struct{ Atlas *text.Atlas }41+// Preparer owns the glyph atlas and the obligation to deliver its changes to the
42+// GPU mirror. Prepare hands the caller every change not yet known to be
43+// submitted; Submitted is the only acknowledgement. A frame that is retried,
44+// abandoned, superseded or never reached submission therefore never loses
45+// atlas changes: the next Prepare resends whole pages (placements are kept).
46+type Preparer struct {
47+ Atlas *text.Atlas
48+ // owed is true while the latest issued uploads have not been submitted.
49+ owed bool
50+ issued []text.Upload
51+}
42 52
43 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with53 // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
44 // layout; the atlas and shaped faces are not safe for concurrent use.54 // layout; the atlas and shaped faces are not safe for concurrent use.
@@ -172,10 +182,27 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig
172 if len(stack) > 0 {182 if len(stack) > 0 {
173 return Frame{}, fmt.Errorf("render: unbalanced clip-push")183 return Frame{}, fmt.Errorf("render: unbalanced clip-push")
174 }184 }
185+ if p.owed {
186+ // The previous frame's uploads may never have reached the GPU. Full pages
187+ // subsume them and any changes made since.
188+ p.Atlas.MarkAllDirty()
189+ }
175 frame.Uploads = p.Atlas.Uploads()190 frame.Uploads = p.Atlas.Uploads()
191+ p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
176 return frame, nil192 return frame, nil
177 }193 }
178 194
195+// Submitted acknowledges that uploads, as returned in the most recent Prepare's
196+// Frame.Uploads, were recorded in a successfully submitted GPU submission.
197+// Any other slice (an older, superseded frame or foreign data) is ignored and
198+// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
199+func (p *Preparer) Submitted(uploads []text.Upload) {
200+ if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
201+ return
202+ }
203+ p.owed, p.issued = false, nil
204+}
205+
179 func opacity(c css.Color, a float64) css.Color {206 func opacity(c css.Color, a float64) css.Color {
180 c.A *= math.Max(0, math.Min(1, a))207 c.A *= math.Max(0, math.Min(1, a))
181 return c208 return c
M internal/render/prepare_test.go +149 -0
@@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
9090 if len(f.Quads) < 10 || len(f.Uploads) == 0 {
9191 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))
9292 }
93+ p.Submitted(f.Uploads)
9394 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)
9495 if err != nil || len(f.Uploads) != 0 {
9596 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)
@@ -100,3 +101,151 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
100101 t.Fatalf("accepted mismatched face: %v", err)
101102 }
102103 }
104+
105+func glyphCommand(t *testing.T, str string) layout.Command {
106+ t.Helper()
107+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ measured, err := text.NewEngine(catalog).Measure(str, text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ cmd := layout.Command{Op: "text", Color: css.Color{A: 1}, Opacity: 1}
116+ for _, line := range measured.Lines {
117+ for _, shaped := range line.Runs {
118+ r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20}
119+ for _, g := range shaped.Glyphs {
120+ r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 6 + g.Y})
121+ }
122+ cmd.Runs = append(cmd.Runs, r)
123+ }
124+ }
125+ return cmd
126+}
127+
128+func uploadBytes(us []text.Upload) (n int) {
129+ for _, u := range us {
130+ n += len(u.Bytes)
131+ }
132+ return n
133+}
134+
135+func TestUploadsStayOwedUntilSubmitted(t *testing.T) {
136+ cmd := glyphCommand(t, "office")
137+ p, err := NewPreparer(256, 2)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ prep := func() Frame {
142+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ return f
147+ }
148+ first := prep()
149+ if len(first.Uploads) == 0 {
150+ t.Fatal("no uploads")
151+ }
152+ // No submit (deferred/retried): the same placements are resent, as full pages.
153+ retry := prep()
154+ if len(retry.Uploads) == 0 || len(retry.Quads) != len(first.Quads) {
155+ t.Fatalf("retry uploads=%d quads=%d/%d", len(retry.Uploads), len(retry.Quads), len(first.Quads))
156+ }
157+ for i := range first.Quads {
158+ if first.Quads[i].Glyph != retry.Quads[i].Glyph {
159+ t.Fatal("placements moved on retry")
160+ }
161+ }
162+ if got := retry.Uploads[0].Rect; got.Dx() != 256 || got.Dy() != 256 {
163+ t.Fatalf("retry rect %v not full page", got)
164+ }
165+ // Acknowledging a stale frame's uploads is ignored.
166+ p.Submitted(first.Uploads)
167+ again := prep()
168+ if len(again.Uploads) == 0 {
169+ t.Fatal("stale acknowledgement discarded owed uploads")
170+ }
171+ // Successful submit clears the debt; an unchanged frame uploads nothing.
172+ p.Submitted(again.Uploads)
173+ if f := prep(); len(f.Uploads) != 0 {
174+ t.Fatalf("uploads after ack: %d", len(f.Uploads))
175+ }
176+ // Repeated acknowledgement is harmless.
177+ p.Submitted(again.Uploads)
178+ if f := prep(); len(f.Uploads) != 0 {
179+ t.Fatalf("uploads after repeated ack: %d", len(f.Uploads))
180+ }
181+}
182+
183+func TestSupersededFrameKeepsAbandonedAtlasChanges(t *testing.T) {
184+ a, b := glyphCommand(t, "office"), glyphCommand(t, "wxyz日本語")
185+ p, err := NewPreparer(256, 2)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ // Frame 1 is prepared and acknowledged: the GPU mirror holds "office".
190+ f, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ p.Submitted(f.Uploads)
195+ // Frame 2 adds glyphs but is abandoned before submission.
196+ old, err := p.Prepare([]layout.Command{a, b}, 1, 400, 100)
197+ if err != nil || len(old.Uploads) == 0 {
198+ t.Fatalf("old frame: %v uploads=%d", err, len(old.Uploads))
199+ }
200+ // Frame 3 does not use frame 2's glyphs, yet must carry its atlas changes.
201+ latest, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if uploadBytes(latest.Uploads) < uploadBytes(old.Uploads) {
206+ t.Fatalf("latest uploads %d < abandoned %d", uploadBytes(latest.Uploads), uploadBytes(old.Uploads))
207+ }
208+ // The abandoned frame's late acknowledgement must not clear the debt.
209+ p.Submitted(old.Uploads)
210+ if next, err := p.Prepare([]layout.Command{a}, 1, 400, 100); err != nil || len(next.Uploads) == 0 {
211+ t.Fatalf("debt lost: %v uploads=%d", err, len(next.Uploads))
212+ }
213+}
214+
215+func TestFailedPrepareKeepsOwedUploads(t *testing.T) {
216+ cmd := glyphCommand(t, "office")
217+ p, err := NewPreparer(256, 2)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if _, err = p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil {
222+ t.Fatal(err)
223+ }
224+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
225+ t.Fatal("expected failure")
226+ }
227+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
228+ if err != nil || len(f.Uploads) == 0 {
229+ t.Fatalf("uploads lost after failed prepare: %v %d", err, len(f.Uploads))
230+ }
231+}
232+
233+func TestFirstPrepareFailureKeepsUndrainedAtlasChanges(t *testing.T) {
234+ cmd := glyphCommand(t, "office")
235+ p, err := NewPreparer(256, 2)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ // Glyphs are inserted, then the frame fails before uploads are drained.
240+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
241+ t.Fatal("expected failure")
242+ }
243+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
244+ if err != nil || len(f.Uploads) == 0 || len(f.Quads) == 0 {
245+ t.Fatalf("undrained changes lost: %v uploads=%d quads=%d", err, len(f.Uploads), len(f.Quads))
246+ }
247+ p.Submitted(f.Uploads)
248+ if g, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil || len(g.Uploads) != 0 {
249+ t.Fatalf("uploads after ack: %v %d", err, len(g.Uploads))
250+ }
251+}
@@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
90 if len(f.Quads) < 10 || len(f.Uploads) == 0 {90 if len(f.Quads) < 10 || len(f.Uploads) == 0 {
91 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))91 t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads))
92 }92 }
93+ p.Submitted(f.Uploads)
93 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)94 f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100)
94 if err != nil || len(f.Uploads) != 0 {95 if err != nil || len(f.Uploads) != 0 {
95 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)96 t.Fatalf("scale %g: second frame uploads=%d err=%v", scale, len(f.Uploads), err)
@@ -100,3 +101,151 @@ func TestGlyphRasterAndAtlas(t *testing.T) {
100 t.Fatalf("accepted mismatched face: %v", err)101 t.Fatalf("accepted mismatched face: %v", err)
101 }102 }
102 }103 }
104+
105+func glyphCommand(t *testing.T, str string) layout.Command {
106+ t.Helper()
107+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ measured, err := text.NewEngine(catalog).Measure(str, text.Request{Families: []string{"Noto Sans"}, Size: 20}, 0)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ cmd := layout.Command{Op: "text", Color: css.Color{A: 1}, Opacity: 1}
116+ for _, line := range measured.Lines {
117+ for _, shaped := range line.Runs {
118+ r := layout.Run{Face: shaped.Face, FaceID: shaped.Face.ID, Size: 20}
119+ for _, g := range shaped.Glyphs {
120+ r.Glyphs = append(r.Glyphs, layout.Glyph{ID: uint32(g.ID), X: 6 + g.X, Y: 6 + g.Y})
121+ }
122+ cmd.Runs = append(cmd.Runs, r)
123+ }
124+ }
125+ return cmd
126+}
127+
128+func uploadBytes(us []text.Upload) (n int) {
129+ for _, u := range us {
130+ n += len(u.Bytes)
131+ }
132+ return n
133+}
134+
135+func TestUploadsStayOwedUntilSubmitted(t *testing.T) {
136+ cmd := glyphCommand(t, "office")
137+ p, err := NewPreparer(256, 2)
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ prep := func() Frame {
142+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
143+ if err != nil {
144+ t.Fatal(err)
145+ }
146+ return f
147+ }
148+ first := prep()
149+ if len(first.Uploads) == 0 {
150+ t.Fatal("no uploads")
151+ }
152+ // No submit (deferred/retried): the same placements are resent, as full pages.
153+ retry := prep()
154+ if len(retry.Uploads) == 0 || len(retry.Quads) != len(first.Quads) {
155+ t.Fatalf("retry uploads=%d quads=%d/%d", len(retry.Uploads), len(retry.Quads), len(first.Quads))
156+ }
157+ for i := range first.Quads {
158+ if first.Quads[i].Glyph != retry.Quads[i].Glyph {
159+ t.Fatal("placements moved on retry")
160+ }
161+ }
162+ if got := retry.Uploads[0].Rect; got.Dx() != 256 || got.Dy() != 256 {
163+ t.Fatalf("retry rect %v not full page", got)
164+ }
165+ // Acknowledging a stale frame's uploads is ignored.
166+ p.Submitted(first.Uploads)
167+ again := prep()
168+ if len(again.Uploads) == 0 {
169+ t.Fatal("stale acknowledgement discarded owed uploads")
170+ }
171+ // Successful submit clears the debt; an unchanged frame uploads nothing.
172+ p.Submitted(again.Uploads)
173+ if f := prep(); len(f.Uploads) != 0 {
174+ t.Fatalf("uploads after ack: %d", len(f.Uploads))
175+ }
176+ // Repeated acknowledgement is harmless.
177+ p.Submitted(again.Uploads)
178+ if f := prep(); len(f.Uploads) != 0 {
179+ t.Fatalf("uploads after repeated ack: %d", len(f.Uploads))
180+ }
181+}
182+
183+func TestSupersededFrameKeepsAbandonedAtlasChanges(t *testing.T) {
184+ a, b := glyphCommand(t, "office"), glyphCommand(t, "wxyz日本語")
185+ p, err := NewPreparer(256, 2)
186+ if err != nil {
187+ t.Fatal(err)
188+ }
189+ // Frame 1 is prepared and acknowledged: the GPU mirror holds "office".
190+ f, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ p.Submitted(f.Uploads)
195+ // Frame 2 adds glyphs but is abandoned before submission.
196+ old, err := p.Prepare([]layout.Command{a, b}, 1, 400, 100)
197+ if err != nil || len(old.Uploads) == 0 {
198+ t.Fatalf("old frame: %v uploads=%d", err, len(old.Uploads))
199+ }
200+ // Frame 3 does not use frame 2's glyphs, yet must carry its atlas changes.
201+ latest, err := p.Prepare([]layout.Command{a}, 1, 400, 100)
202+ if err != nil {
203+ t.Fatal(err)
204+ }
205+ if uploadBytes(latest.Uploads) < uploadBytes(old.Uploads) {
206+ t.Fatalf("latest uploads %d < abandoned %d", uploadBytes(latest.Uploads), uploadBytes(old.Uploads))
207+ }
208+ // The abandoned frame's late acknowledgement must not clear the debt.
209+ p.Submitted(old.Uploads)
210+ if next, err := p.Prepare([]layout.Command{a}, 1, 400, 100); err != nil || len(next.Uploads) == 0 {
211+ t.Fatalf("debt lost: %v uploads=%d", err, len(next.Uploads))
212+ }
213+}
214+
215+func TestFailedPrepareKeepsOwedUploads(t *testing.T) {
216+ cmd := glyphCommand(t, "office")
217+ p, err := NewPreparer(256, 2)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ if _, err = p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil {
222+ t.Fatal(err)
223+ }
224+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
225+ t.Fatal("expected failure")
226+ }
227+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
228+ if err != nil || len(f.Uploads) == 0 {
229+ t.Fatalf("uploads lost after failed prepare: %v %d", err, len(f.Uploads))
230+ }
231+}
232+
233+func TestFirstPrepareFailureKeepsUndrainedAtlasChanges(t *testing.T) {
234+ cmd := glyphCommand(t, "office")
235+ p, err := NewPreparer(256, 2)
236+ if err != nil {
237+ t.Fatal(err)
238+ }
239+ // Glyphs are inserted, then the frame fails before uploads are drained.
240+ if _, err = p.Prepare([]layout.Command{cmd, {Op: "clip-pop"}}, 1, 200, 100); err == nil {
241+ t.Fatal("expected failure")
242+ }
243+ f, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100)
244+ if err != nil || len(f.Uploads) == 0 || len(f.Quads) == 0 {
245+ t.Fatalf("undrained changes lost: %v uploads=%d quads=%d", err, len(f.Uploads), len(f.Quads))
246+ }
247+ p.Submitted(f.Uploads)
248+ if g, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100); err != nil || len(g.Uploads) != 0 {
249+ t.Fatalf("uploads after ack: %v %d", err, len(g.Uploads))
250+ }
251+}
R controls.go → internal/ui/controls.go +3 -11 renamed, 93% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "fmt"
@@ -99,15 +99,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
9999 switch input.Kind {
100100 case "edit-pointer", "edit-word":
101101 editor.PreferredXValid = false
102- rendered := editor.Value
103- if child.element.password {
104- rendered = editor.Mask()
105- }
106- place := nearestTextBoundary(rendered, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.X, input.Y)
107- if child.element.password {
108- index := len(edit.Boundaries(rendered[:place])) - 1
109- place = edit.Boundaries(editor.Value)[index]
110- }
102+ place := newEditorGeometry(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password).nearest(input.X, input.Y)
111103 if input.Kind == "edit-word" {
112104 start, end := editor.WordAt(place)
113105 editor.Select(start, end)
@@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
119111 ev.changed = editor.Insert(input.Text) || ev.changed
120112 case "edit-key":
121113 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {
122- moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.Text == "down", input.Shift)
114+ moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password, input.Text == "down", input.Shift)
123115 } else {
124116 editor.PreferredXValid = false
125117 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "fmt"4 "fmt"
@@ -99,15 +99,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
99 switch input.Kind {99 switch input.Kind {
100 case "edit-pointer", "edit-word":100 case "edit-pointer", "edit-word":
101 editor.PreferredXValid = false101 editor.PreferredXValid = false
102- rendered := editor.Value102+ place := newEditorGeometry(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password).nearest(input.X, input.Y)
103- if child.element.password {
104- rendered = editor.Mask()
105- }
106- place := nearestTextBoundary(rendered, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.X, input.Y)
107- if child.element.password {
108- index := len(edit.Boundaries(rendered[:place])) - 1
109- place = edit.Boundaries(editor.Value)[index]
110- }
111 if input.Kind == "edit-word" {103 if input.Kind == "edit-word" {
112 start, end := editor.WordAt(place)104 start, end := editor.WordAt(place)
113 editor.Select(start, end)105 editor.Select(start, end)
@@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption
119 ev.changed = editor.Insert(input.Text) || ev.changed111 ev.changed = editor.Insert(input.Text) || ev.changed
120 case "edit-key":112 case "edit-key":
121 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {113 if (input.Text == "up" || input.Text == "down") && typ == "textarea" {
122- moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, input.Text == "down", input.Shift)114+ moveVisualLine(editor, resultByID(child.frame.layout.Tree, id), child.frame.layout.Display, child.element.password, input.Text == "down", input.Shift)
123 } else {115 } else {
124 editor.PreferredXValid = false116 editor.PreferredXValid = false
125 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {117 if input.Ctrl && input.Text == "v" && child.frame.owner != nil {
R controls_extra.go → internal/ui/controls_extra.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 vi "github.com/bnema/nefergui/internal/image"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 vi "github.com/bnema/nefergui/internal/image"4 vi "github.com/bnema/nefergui/internal/image"
R controls_extra_test.go → internal/ui/controls_extra_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
R controls_label_test.go → internal/ui/controls_label_test.go +3 -3 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "testing"
@@ -12,7 +12,7 @@ import (
1212 // glyphs. Snapshots alone once froze labelless controls; this invariant does not
1313 // depend on a golden file.
1414 func TestDeclaredTextIsPainted(t *testing.T) {
15- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
15+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1616 if err != nil {
1717 t.Fatal(err)
1818 }
@@ -75,7 +75,7 @@ func TestDeclaredTextIsPainted(t *testing.T) {
7575 // Placeholders follow editor text geometry: text-align and RTL position,
7676 // letter-spacing, and textarea wrapping.
7777 func TestPlaceholderGeometry(t *testing.T) {
78- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
78+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
7979 if err != nil {
8080 t.Fatal(err)
8181 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "testing"4 "testing"
@@ -12,7 +12,7 @@ import (
12 // glyphs. Snapshots alone once froze labelless controls; this invariant does not12 // glyphs. Snapshots alone once froze labelless controls; this invariant does not
13 // depend on a golden file.13 // depend on a golden file.
14 func TestDeclaredTextIsPainted(t *testing.T) {14 func TestDeclaredTextIsPainted(t *testing.T) {
15- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))15+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
16 if err != nil {16 if err != nil {
17 t.Fatal(err)17 t.Fatal(err)
18 }18 }
@@ -75,7 +75,7 @@ func TestDeclaredTextIsPainted(t *testing.T) {
75 // Placeholders follow editor text geometry: text-align and RTL position,75 // Placeholders follow editor text geometry: text-align and RTL position,
76 // letter-spacing, and textarea wrapping.76 // letter-spacing, and textarea wrapping.
77 func TestPlaceholderGeometry(t *testing.T) {77 func TestPlaceholderGeometry(t *testing.T) {
78- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))78+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
79 if err != nil {79 if err != nil {
80 t.Fatal(err)80 t.Fatal(err)
81 }81 }
R controls_snapshot_test.go → internal/ui/controls_snapshot_test.go +2 -2 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "bytes"
@@ -17,7 +17,7 @@ import (
1717 // The golden is the serialized display list, not a rendered image; each case
1818 // still checks the intended pseudo-state/event before comparing exact JSON.
1919 func TestControlDisplaySnapshots(t *testing.T) {
20- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
20+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
2121 if err != nil {
2222 t.Fatal(err)
2323 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -17,7 +17,7 @@ import (
17 // The golden is the serialized display list, not a rendered image; each case17 // The golden is the serialized display list, not a rendered image; each case
18 // still checks the intended pseudo-state/event before comparing exact JSON.18 // still checks the intended pseudo-state/event before comparing exact JSON.
19 func TestControlDisplaySnapshots(t *testing.T) {19 func TestControlDisplaySnapshots(t *testing.T) {
20- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))20+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21 if err != nil {21 if err != nil {
22 t.Fatal(err)22 t.Fatal(err)
23 }23 }
R controls_visual_test.go → internal/ui/controls_visual_test.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "encoding/json"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
R debug_off.go → internal/ui/debug_off.go +1 -1 renamed, 77% similar
@@ -1,5 +1,5 @@
11 //go:build !nefergui_debug
22
3-package nefergui
3+package ui
44
55 const debugDiagnostics = false
@@ -1,5 +1,5 @@
1 //go:build !nefergui_debug1 //go:build !nefergui_debug
2 2
3-package nefergui3+package ui
4 4
5 const debugDiagnostics = false5 const debugDiagnostics = false
R debug_on.go → internal/ui/debug_on.go +1 -1 renamed, 77% similar
@@ -1,5 +1,5 @@
11 //go:build nefergui_debug
22
3-package nefergui
3+package ui
44
55 const debugDiagnostics = true
@@ -1,5 +1,5 @@
1 //go:build nefergui_debug1 //go:build nefergui_debug
2 2
3-package nefergui3+package ui
4 4
5 const debugDiagnostics = true5 const debugDiagnostics = true
A internal/ui/edit_geometry.go +261 -0
@@ -0,0 +1,261 @@
1+package ui
2+
3+import (
4+ "math"
5+ "sort"
6+ "unicode/utf8"
7+
8+ "github.com/bnema/nefergui/internal/edit"
9+ "github.com/bnema/nefergui/internal/layout"
10+ "github.com/go-text/typesetting/di"
11+)
12+
13+// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14+// far edge of the character that precedes byteOffset; otherwise it is the
15+// leading edge of the character that follows. line indexes layout.Result.Lines.
16+type textBoundary struct {
17+ byteOffset int
18+ x, y float64
19+ line int
20+ trailing bool
21+}
22+
23+// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
24+// Runs are in visual order; glyph cluster indices are source rune offsets.
25+func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
26+ if n == nil {
27+ return nil
28+ }
29+ offsets := edit.Boundaries(value)
30+ runeAt := make(map[int]int, len(offsets))
31+ for _, b := range offsets {
32+ runeAt[utf8.RuneCountInString(value[:b])] = b
33+ }
34+ var runs []layout.Run
35+ for _, cmd := range display {
36+ if cmd.ID == n.ID && cmd.Op == "text" {
37+ runs = cmd.Runs
38+ break
39+ }
40+ }
41+ var lines [][]textBoundary
42+ ri := 0
43+ top := n.Content.Y
44+ for _, line := range n.Lines {
45+ current := []textBoundary{}
46+ for _, shaped := range line.Runs {
47+ if ri >= len(runs) {
48+ break
49+ }
50+ run := runs[ri]
51+ ri++
52+ // Cluster start and end positions are derived from the glyph ink advances,
53+ // not rune counts (ligatures and combining graphemes have one visual span).
54+ type span struct{ low, high float64 }
55+ spans := make(map[int]span)
56+ for _, g := range run.Glyphs {
57+ p, ok := spans[g.Cluster]
58+ if !ok {
59+ p = span{g.X, g.X + g.Advance}
60+ } else {
61+ p.low = math.Min(p.low, g.X)
62+ p.high = math.Max(p.high, g.X+g.Advance)
63+ }
64+ spans[g.Cluster] = p
65+ }
66+ starts := make([]int, 0, len(spans))
67+ for k := range spans {
68+ starts = append(starts, k)
69+ }
70+ sort.Ints(starts)
71+ for i, k := range starts {
72+ end := shaped.End
73+ if i+1 < len(starts) {
74+ end = starts[i+1]
75+ }
76+ p := spans[k]
77+ a, b := p.low, p.high
78+ if shaped.Direction == di.DirectionRTL {
79+ a, b = b, a
80+ }
81+ if offset, ok := runeAt[k]; ok {
82+ current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
83+ }
84+ if offset, ok := runeAt[end]; ok {
85+ current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
86+ }
87+ }
88+ }
89+ if len(current) == 0 { // empty paragraph, including a trailing newline
90+ offset := 0
91+ if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
92+ for _, boundary := range lines[len(lines)-1] {
93+ if boundary.byteOffset > offset {
94+ offset = boundary.byteOffset
95+ }
96+ }
97+ for offset < len(value) && value[offset] == '\n' {
98+ offset++
99+ }
100+ }
101+ current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
102+ }
103+ lines = append(lines, current)
104+ top += line.Height
105+ }
106+ return lines
107+}
108+
109+// displayText is the text an editor shows for a logical value, with the
110+// mapping between their byte offsets. A password shows one bullet per grapheme
111+// cluster, so logical and shown offsets differ; otherwise they are identical.
112+// It is the only place that maps password offsets.
113+type displayText struct {
114+ shown string
115+ logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116+}
117+
118+func newDisplayText(value string, password bool) displayText {
119+ if !password {
120+ return displayText{shown: value}
121+ }
122+ shown := (&edit.State{Value: value}).Mask()
123+ return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124+}
125+
126+// toShown maps a logical byte offset to the shown string. An offset inside a
127+// grapheme cluster maps to the start of that cluster.
128+func (d displayText) toShown(offset int) int {
129+ if d.logical == nil {
130+ return offset
131+ }
132+ i := sort.SearchInts(d.logical, offset+1) - 1
133+ if i < 0 {
134+ i = 0
135+ }
136+ return d.display[i]
137+}
138+
139+// toLogical maps a byte offset of the shown string back to the value.
140+func (d displayText) toLogical(offset int) int {
141+ if d.logical == nil {
142+ return offset
143+ }
144+ i := sort.SearchInts(d.display, offset+1) - 1
145+ if i < 0 {
146+ i = 0
147+ }
148+ return d.logical[i]
149+}
150+
151+// editorGeometry is the single owner of where editor byte offsets sit on
152+// screen. Every caller (pointer placement, vertical movement, selection and
153+// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154+// Boundary offsets are logical value offsets, password or not.
155+//
156+// Caret policy: a boundary can have several visual positions (bidi run
157+// changes, soft wraps). The caret sits at the trailing edge of the character
158+// before the offset; the leading edge of the following character is used only
159+// when no character precedes the offset.
160+type editorGeometry struct {
161+ node *layout.Result
162+ lines [][]textBoundary
163+}
164+
165+func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166+ text := newDisplayText(s.Value, password)
167+ lines := textBoundaries(text.shown, n, display)
168+ if text.logical != nil {
169+ for _, line := range lines {
170+ for i := range line {
171+ line[i].byteOffset = text.toLogical(line[i].byteOffset)
172+ }
173+ }
174+ }
175+ return editorGeometry{node: n, lines: lines}
176+}
177+
178+// caret returns the visual caret position for a logical offset.
179+func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180+ var leading *textBoundary
181+ for i := range g.lines {
182+ b, trailing := preferredBoundary(g.lines[i], offset)
183+ if b == nil {
184+ continue
185+ }
186+ if trailing {
187+ return *b, true
188+ }
189+ if leading == nil {
190+ leading = b
191+ }
192+ }
193+ if leading == nil {
194+ return textBoundary{}, false
195+ }
196+ return *leading, true
197+}
198+
199+// caretOnLine applies the caret policy within one visual line.
200+func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201+ b, _ := preferredBoundary(g.lines[line], offset)
202+ if b == nil {
203+ return textBoundary{}, false
204+ }
205+ return *b, true
206+}
207+
208+// preferredBoundary picks the trailing edge of the preceding character over
209+// the leading edge of the following one, else the first leading edge.
210+func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211+ var leading *textBoundary
212+ for j := range line {
213+ b := &line[j]
214+ if b.byteOffset != offset {
215+ continue
216+ }
217+ if b.trailing {
218+ return b, true
219+ }
220+ if leading == nil {
221+ leading = b
222+ }
223+ }
224+ return leading, false
225+}
226+
227+// lineHeight is the height of a visual line, falling back to the content box.
228+func (g editorGeometry) lineHeight(line int) float64 {
229+ if line >= 0 && line < len(g.node.Lines) {
230+ return g.node.Lines[line].Height
231+ }
232+ return g.node.Content.H
233+}
234+
235+// nearest returns the logical offset whose boundary is horizontally closest
236+// to (x, y) on the visual line under y.
237+func (g editorGeometry) nearest(x, y float64) int {
238+ if len(g.lines) == 0 {
239+ return 0
240+ }
241+ line := 0
242+ top := g.node.Content.Y
243+ for i, shaped := range g.node.Lines {
244+ if y >= top+shaped.Height/2 {
245+ line = i
246+ }
247+ top += shaped.Height
248+ }
249+ if line >= len(g.lines) {
250+ line = len(g.lines) - 1
251+ }
252+ best := 0
253+ distance := math.Inf(1)
254+ for _, b := range g.lines[line] {
255+ if d := math.Abs(b.x - x); d < distance {
256+ distance = d
257+ best = b.byteOffset
258+ }
259+ }
260+ return best
261+}
@@ -0,0 +1,261 @@
1+package ui
2+
3+import (
4+ "math"
5+ "sort"
6+ "unicode/utf8"
7+
8+ "github.com/bnema/nefergui/internal/edit"
9+ "github.com/bnema/nefergui/internal/layout"
10+ "github.com/go-text/typesetting/di"
11+)
12+
13+// textBoundary is one painted edge of a grapheme cluster. trailing marks the
14+// far edge of the character that precedes byteOffset; otherwise it is the
15+// leading edge of the character that follows. line indexes layout.Result.Lines.
16+type textBoundary struct {
17+ byteOffset int
18+ x, y float64
19+ line int
20+ trailing bool
21+}
22+
23+// textBoundaries projects editor byte boundaries onto laid-out glyph clusters.
24+// Runs are in visual order; glyph cluster indices are source rune offsets.
25+func textBoundaries(value string, n *layout.Result, display []layout.Command) [][]textBoundary {
26+ if n == nil {
27+ return nil
28+ }
29+ offsets := edit.Boundaries(value)
30+ runeAt := make(map[int]int, len(offsets))
31+ for _, b := range offsets {
32+ runeAt[utf8.RuneCountInString(value[:b])] = b
33+ }
34+ var runs []layout.Run
35+ for _, cmd := range display {
36+ if cmd.ID == n.ID && cmd.Op == "text" {
37+ runs = cmd.Runs
38+ break
39+ }
40+ }
41+ var lines [][]textBoundary
42+ ri := 0
43+ top := n.Content.Y
44+ for _, line := range n.Lines {
45+ current := []textBoundary{}
46+ for _, shaped := range line.Runs {
47+ if ri >= len(runs) {
48+ break
49+ }
50+ run := runs[ri]
51+ ri++
52+ // Cluster start and end positions are derived from the glyph ink advances,
53+ // not rune counts (ligatures and combining graphemes have one visual span).
54+ type span struct{ low, high float64 }
55+ spans := make(map[int]span)
56+ for _, g := range run.Glyphs {
57+ p, ok := spans[g.Cluster]
58+ if !ok {
59+ p = span{g.X, g.X + g.Advance}
60+ } else {
61+ p.low = math.Min(p.low, g.X)
62+ p.high = math.Max(p.high, g.X+g.Advance)
63+ }
64+ spans[g.Cluster] = p
65+ }
66+ starts := make([]int, 0, len(spans))
67+ for k := range spans {
68+ starts = append(starts, k)
69+ }
70+ sort.Ints(starts)
71+ for i, k := range starts {
72+ end := shaped.End
73+ if i+1 < len(starts) {
74+ end = starts[i+1]
75+ }
76+ p := spans[k]
77+ a, b := p.low, p.high
78+ if shaped.Direction == di.DirectionRTL {
79+ a, b = b, a
80+ }
81+ if offset, ok := runeAt[k]; ok {
82+ current = append(current, textBoundary{byteOffset: offset, x: a, y: top, line: len(lines)})
83+ }
84+ if offset, ok := runeAt[end]; ok {
85+ current = append(current, textBoundary{byteOffset: offset, x: b, y: top, line: len(lines), trailing: true})
86+ }
87+ }
88+ }
89+ if len(current) == 0 { // empty paragraph, including a trailing newline
90+ offset := 0
91+ if len(lines) > 0 && len(lines[len(lines)-1]) > 0 {
92+ for _, boundary := range lines[len(lines)-1] {
93+ if boundary.byteOffset > offset {
94+ offset = boundary.byteOffset
95+ }
96+ }
97+ for offset < len(value) && value[offset] == '\n' {
98+ offset++
99+ }
100+ }
101+ current = append(current, textBoundary{byteOffset: offset, x: n.Content.X, y: top, line: len(lines)})
102+ }
103+ lines = append(lines, current)
104+ top += line.Height
105+ }
106+ return lines
107+}
108+
109+// displayText is the text an editor shows for a logical value, with the
110+// mapping between their byte offsets. A password shows one bullet per grapheme
111+// cluster, so logical and shown offsets differ; otherwise they are identical.
112+// It is the only place that maps password offsets.
113+type displayText struct {
114+ shown string
115+ logical, display []int // grapheme boundaries of the value and of shown; nil when identical
116+}
117+
118+func newDisplayText(value string, password bool) displayText {
119+ if !password {
120+ return displayText{shown: value}
121+ }
122+ shown := (&edit.State{Value: value}).Mask()
123+ return displayText{shown: shown, logical: edit.Boundaries(value), display: edit.Boundaries(shown)}
124+}
125+
126+// toShown maps a logical byte offset to the shown string. An offset inside a
127+// grapheme cluster maps to the start of that cluster.
128+func (d displayText) toShown(offset int) int {
129+ if d.logical == nil {
130+ return offset
131+ }
132+ i := sort.SearchInts(d.logical, offset+1) - 1
133+ if i < 0 {
134+ i = 0
135+ }
136+ return d.display[i]
137+}
138+
139+// toLogical maps a byte offset of the shown string back to the value.
140+func (d displayText) toLogical(offset int) int {
141+ if d.logical == nil {
142+ return offset
143+ }
144+ i := sort.SearchInts(d.display, offset+1) - 1
145+ if i < 0 {
146+ i = 0
147+ }
148+ return d.logical[i]
149+}
150+
151+// editorGeometry is the single owner of where editor byte offsets sit on
152+// screen. Every caller (pointer placement, vertical movement, selection and
153+// caret painting, caret scrolling, preedit origin) reads it, so they agree.
154+// Boundary offsets are logical value offsets, password or not.
155+//
156+// Caret policy: a boundary can have several visual positions (bidi run
157+// changes, soft wraps). The caret sits at the trailing edge of the character
158+// before the offset; the leading edge of the following character is used only
159+// when no character precedes the offset.
160+type editorGeometry struct {
161+ node *layout.Result
162+ lines [][]textBoundary
163+}
164+
165+func newEditorGeometry(s *edit.State, n *layout.Result, display []layout.Command, password bool) editorGeometry {
166+ text := newDisplayText(s.Value, password)
167+ lines := textBoundaries(text.shown, n, display)
168+ if text.logical != nil {
169+ for _, line := range lines {
170+ for i := range line {
171+ line[i].byteOffset = text.toLogical(line[i].byteOffset)
172+ }
173+ }
174+ }
175+ return editorGeometry{node: n, lines: lines}
176+}
177+
178+// caret returns the visual caret position for a logical offset.
179+func (g editorGeometry) caret(offset int) (textBoundary, bool) {
180+ var leading *textBoundary
181+ for i := range g.lines {
182+ b, trailing := preferredBoundary(g.lines[i], offset)
183+ if b == nil {
184+ continue
185+ }
186+ if trailing {
187+ return *b, true
188+ }
189+ if leading == nil {
190+ leading = b
191+ }
192+ }
193+ if leading == nil {
194+ return textBoundary{}, false
195+ }
196+ return *leading, true
197+}
198+
199+// caretOnLine applies the caret policy within one visual line.
200+func (g editorGeometry) caretOnLine(line, offset int) (textBoundary, bool) {
201+ b, _ := preferredBoundary(g.lines[line], offset)
202+ if b == nil {
203+ return textBoundary{}, false
204+ }
205+ return *b, true
206+}
207+
208+// preferredBoundary picks the trailing edge of the preceding character over
209+// the leading edge of the following one, else the first leading edge.
210+func preferredBoundary(line []textBoundary, offset int) (*textBoundary, bool) {
211+ var leading *textBoundary
212+ for j := range line {
213+ b := &line[j]
214+ if b.byteOffset != offset {
215+ continue
216+ }
217+ if b.trailing {
218+ return b, true
219+ }
220+ if leading == nil {
221+ leading = b
222+ }
223+ }
224+ return leading, false
225+}
226+
227+// lineHeight is the height of a visual line, falling back to the content box.
228+func (g editorGeometry) lineHeight(line int) float64 {
229+ if line >= 0 && line < len(g.node.Lines) {
230+ return g.node.Lines[line].Height
231+ }
232+ return g.node.Content.H
233+}
234+
235+// nearest returns the logical offset whose boundary is horizontally closest
236+// to (x, y) on the visual line under y.
237+func (g editorGeometry) nearest(x, y float64) int {
238+ if len(g.lines) == 0 {
239+ return 0
240+ }
241+ line := 0
242+ top := g.node.Content.Y
243+ for i, shaped := range g.node.Lines {
244+ if y >= top+shaped.Height/2 {
245+ line = i
246+ }
247+ top += shaped.Height
248+ }
249+ if line >= len(g.lines) {
250+ line = len(g.lines) - 1
251+ }
252+ best := 0
253+ distance := math.Inf(1)
254+ for _, b := range g.lines[line] {
255+ if d := math.Abs(b.x - x); d < distance {
256+ distance = d
257+ best = b.byteOffset
258+ }
259+ }
260+ return best
261+}
A internal/ui/edit_geometry_review_test.go +88 -0
@@ -0,0 +1,88 @@
1+package ui
2+
3+import (
4+ "math"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/nefergui/internal/edit"
9+ "github.com/bnema/nefergui/internal/layout"
10+)
11+
12+func TestRTLParagraphCaretUsesPrecedingTrailingEdge(t *testing.T) {
13+ value := "مرحبا abc"
14+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
15+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
16+ r.Build(view)
17+ r.route(key("Tab"))
18+ r.Build(view)
19+ n := r.output.Tree.Children[0]
20+ offset := len("مرحبا ")
21+ s := r.edits[n.ID]
22+ s.Move(offset, false)
23+ s.Preedit = "x"
24+ r.Redraw()
25+ r.Build(view)
26+ n = r.output.Tree.Children[0]
27+ want := rawTrailingEdge(t, r, n, 5)
28+ g := newEditorGeometry(s, n, r.output.Display, false)
29+ var first *textBoundary
30+ for i := range g.lines[0] {
31+ if g.lines[0][i].byteOffset == offset {
32+ first = &g.lines[0][i]
33+ break
34+ }
35+ }
36+ if first == nil || first.trailing || math.Abs(first.x-want) < 1 {
37+ t.Fatalf("fixture must encounter a distinct leading edge first: first=%+v trailing=%g", first, want)
38+ }
39+ for _, op := range []string{"caret", "preedit-text"} {
40+ cmd, ok := displayCommand(r, n.ID, op)
41+ if !ok || math.Abs(cmd.Rect.X-want) > .01 {
42+ t.Fatalf("%s x=%g want %g", op, cmd.Rect.X, want)
43+ }
44+ }
45+ // Place the right viewport edge between the two positions. Only the correct
46+ // trailing edge should require a horizontal scroll.
47+ if want <= first.x {
48+ t.Fatalf("fixture must place trailing edge right of leading edge")
49+ }
50+ n.Content.W = (first.x+want)/2 - n.Content.X
51+ n.ContentSize.W = 500
52+ r.state.scroll = map[string]layout.Size{n.ID: {}}
53+ expected := want - (n.Content.X + n.Content.W - 1)
54+ if !r.scrollCaret(r.committed, &r.output) || math.Abs(r.state.scroll[n.ID].W-expected) > .01 {
55+ t.Fatalf("scroll=%+v want %g", r.state.scroll[n.ID], expected)
56+ }
57+}
58+
59+func TestPasswordWrappedNavigationUsesLogicalGraphemeOffsets(t *testing.T) {
60+ grapheme := "e\u0301"
61+ value := strings.Repeat(grapheme, 24)
62+ r := geometryRuntime(t, `textarea {width:45px;height:100px;font-size:16px}`)
63+ view := func(f *Frame) { f.Root().Textarea("secret", &value, Password(true)) }
64+ r.Build(view)
65+ r.route(key("Tab"))
66+ r.Build(view)
67+ n := r.output.Tree.Children[0]
68+ if len(n.Lines) < 3 {
69+ t.Fatalf("need wrapped password: %d lines", len(n.Lines))
70+ }
71+ s := r.edits[n.ID]
72+ s.Move(0, false)
73+ s.PreferredXValid = false
74+ // Each shaped source rune is one bullet, hence one logical grapheme. The
75+ // starts of the masked runs independently determine the expected offsets.
76+ logical := edit.Boundaries(value)
77+ want := logical[n.Lines[1].Runs[0].Start]
78+ r.route(key("Down"))
79+ r.Build(view)
80+ if s.Cursor != want {
81+ t.Fatalf("down cursor=%d want logical offset %d", s.Cursor, want)
82+ }
83+ r.route(key("Up"))
84+ r.Build(view)
85+ if s.Cursor != 0 {
86+ t.Fatalf("up cursor=%d want 0", s.Cursor)
87+ }
88+}
@@ -0,0 +1,88 @@
1+package ui
2+
3+import (
4+ "math"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/nefergui/internal/edit"
9+ "github.com/bnema/nefergui/internal/layout"
10+)
11+
12+func TestRTLParagraphCaretUsesPrecedingTrailingEdge(t *testing.T) {
13+ value := "مرحبا abc"
14+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
15+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
16+ r.Build(view)
17+ r.route(key("Tab"))
18+ r.Build(view)
19+ n := r.output.Tree.Children[0]
20+ offset := len("مرحبا ")
21+ s := r.edits[n.ID]
22+ s.Move(offset, false)
23+ s.Preedit = "x"
24+ r.Redraw()
25+ r.Build(view)
26+ n = r.output.Tree.Children[0]
27+ want := rawTrailingEdge(t, r, n, 5)
28+ g := newEditorGeometry(s, n, r.output.Display, false)
29+ var first *textBoundary
30+ for i := range g.lines[0] {
31+ if g.lines[0][i].byteOffset == offset {
32+ first = &g.lines[0][i]
33+ break
34+ }
35+ }
36+ if first == nil || first.trailing || math.Abs(first.x-want) < 1 {
37+ t.Fatalf("fixture must encounter a distinct leading edge first: first=%+v trailing=%g", first, want)
38+ }
39+ for _, op := range []string{"caret", "preedit-text"} {
40+ cmd, ok := displayCommand(r, n.ID, op)
41+ if !ok || math.Abs(cmd.Rect.X-want) > .01 {
42+ t.Fatalf("%s x=%g want %g", op, cmd.Rect.X, want)
43+ }
44+ }
45+ // Place the right viewport edge between the two positions. Only the correct
46+ // trailing edge should require a horizontal scroll.
47+ if want <= first.x {
48+ t.Fatalf("fixture must place trailing edge right of leading edge")
49+ }
50+ n.Content.W = (first.x+want)/2 - n.Content.X
51+ n.ContentSize.W = 500
52+ r.state.scroll = map[string]layout.Size{n.ID: {}}
53+ expected := want - (n.Content.X + n.Content.W - 1)
54+ if !r.scrollCaret(r.committed, &r.output) || math.Abs(r.state.scroll[n.ID].W-expected) > .01 {
55+ t.Fatalf("scroll=%+v want %g", r.state.scroll[n.ID], expected)
56+ }
57+}
58+
59+func TestPasswordWrappedNavigationUsesLogicalGraphemeOffsets(t *testing.T) {
60+ grapheme := "e\u0301"
61+ value := strings.Repeat(grapheme, 24)
62+ r := geometryRuntime(t, `textarea {width:45px;height:100px;font-size:16px}`)
63+ view := func(f *Frame) { f.Root().Textarea("secret", &value, Password(true)) }
64+ r.Build(view)
65+ r.route(key("Tab"))
66+ r.Build(view)
67+ n := r.output.Tree.Children[0]
68+ if len(n.Lines) < 3 {
69+ t.Fatalf("need wrapped password: %d lines", len(n.Lines))
70+ }
71+ s := r.edits[n.ID]
72+ s.Move(0, false)
73+ s.PreferredXValid = false
74+ // Each shaped source rune is one bullet, hence one logical grapheme. The
75+ // starts of the masked runs independently determine the expected offsets.
76+ logical := edit.Boundaries(value)
77+ want := logical[n.Lines[1].Runs[0].Start]
78+ r.route(key("Down"))
79+ r.Build(view)
80+ if s.Cursor != want {
81+ t.Fatalf("down cursor=%d want logical offset %d", s.Cursor, want)
82+ }
83+ r.route(key("Up"))
84+ r.Build(view)
85+ if s.Cursor != 0 {
86+ t.Fatalf("up cursor=%d want 0", s.Cursor)
87+ }
88+}
A internal/ui/edit_geometry_test.go +391 -0
@@ -0,0 +1,391 @@
1+package ui
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "math"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/nefergui/internal/css"
12+ "github.com/bnema/nefergui/internal/edit"
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/text"
15+ "github.com/go-text/typesetting/di"
16+)
17+
18+func geometryRuntime(t *testing.T, sheet string) *runtime {
19+ t.Helper()
20+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ r := newRuntime()
25+ r.setTextEngine(text.NewEngine(catalog))
26+ r.styles = css.Compile(css.UA(), css.Parse(sheet))
27+ return r
28+}
29+
30+func displayCommand(r *runtime, id, op string) (layout.Command, bool) {
31+ for _, c := range r.output.Display {
32+ if c.ID == id && c.Op == op {
33+ return c, true
34+ }
35+ }
36+ return layout.Command{}, false
37+}
38+
39+// rawTrailingEdge computes, independently of editorGeometry, the x where the
40+// character at source rune index `rune` ends in reading order: its right ink
41+// edge in an LTR run and its left edge in an RTL run. It reads the shaped
42+// glyphs of the painted text command and the line's run directions only.
43+func rawTrailingEdge(t *testing.T, r *runtime, n *layout.Result, rune int) float64 {
44+ t.Helper()
45+ cmd, ok := displayCommand(r, n.ID, "text")
46+ if !ok {
47+ t.Fatal("missing text command")
48+ }
49+ ri := 0
50+ for _, line := range n.Lines {
51+ for _, shaped := range line.Runs {
52+ run := cmd.Runs[ri]
53+ ri++
54+ low, high, seen := math.Inf(1), math.Inf(-1), false
55+ for _, g := range run.Glyphs {
56+ if g.Cluster == rune {
57+ seen = true
58+ low = math.Min(low, g.X)
59+ high = math.Max(high, g.X+g.Advance)
60+ }
61+ }
62+ if !seen {
63+ continue
64+ }
65+ if shaped.Direction == di.DirectionRTL {
66+ return low
67+ }
68+ return high
69+ }
70+ }
71+ t.Fatalf("no glyph for rune %d", rune)
72+ return 0
73+}
74+
75+func TestDisplayTextMapsPasswordGraphemes(t *testing.T) {
76+ value := "e\u0301猫a"
77+ d := newDisplayText(value, true)
78+ if d.shown != "•••" {
79+ t.Fatalf("shown %q", d.shown)
80+ }
81+ bullet := len("•")
82+ for _, c := range []struct{ logical, shown int }{
83+ {0, 0}, {len("e\u0301"), bullet}, {len("e\u0301猫"), 2 * bullet}, {len(value), 3 * bullet},
84+ {1, 0}, // inside the first grapheme: start of its cluster
85+ } {
86+ if got := d.toShown(c.logical); got != c.shown {
87+ t.Errorf("toShown(%d) = %d, want %d", c.logical, got, c.shown)
88+ }
89+ }
90+ for _, c := range []struct{ shown, logical int }{{0, 0}, {bullet, len("e\u0301")}, {3 * bullet, len(value)}} {
91+ if got := d.toLogical(c.shown); got != c.logical {
92+ t.Errorf("toLogical(%d) = %d, want %d", c.shown, got, c.logical)
93+ }
94+ }
95+ plain := newDisplayText(value, false)
96+ if plain.shown != value || plain.toShown(3) != 3 || plain.toLogical(3) != 3 {
97+ t.Fatalf("plain text must map identically: %+v", plain)
98+ }
99+}
100+
101+// At a bidi run boundary one offset has two visual positions. Painting,
102+// scrolling and preedit must all use the preceding character's trailing edge.
103+func TestMixedDirectionCaretPolicyAgreesAcrossCallers(t *testing.T) {
104+ value := "abc مرحبا def"
105+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
106+ // offset -> source rune index of the character before it.
107+ for _, c := range []struct{ offset, before int }{{len("abc "), 3}, {len("abc مرحبا"), 8}} {
108+ offset := c.offset
109+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
110+ r.Build(view)
111+ r.route(key("Tab"))
112+ r.Build(view)
113+ n := r.output.Tree.Children[0]
114+ r.edits[n.ID].Move(offset, false)
115+ r.edits[n.ID].Preedit = "x"
116+ r.Redraw()
117+ r.Build(view)
118+ n = r.output.Tree.Children[0]
119+
120+ g := newEditorGeometry(r.edits[n.ID], n, r.output.Display, false)
121+ var edges []textBoundary
122+ for _, b := range g.lines[0] {
123+ if b.byteOffset == offset {
124+ edges = append(edges, b)
125+ }
126+ }
127+ var leading textBoundary
128+ for _, b := range edges {
129+ if !b.trailing {
130+ leading = b
131+ }
132+ }
133+ want := rawTrailingEdge(t, r, n, c.before)
134+ if len(edges) < 2 || math.Abs(want-leading.x) < 1 {
135+ t.Fatalf("offset %d is not an ambiguous bidi boundary: %+v (preceding edge %g)", offset, edges, want)
136+ }
137+ caret, ok := displayCommand(r, n.ID, "caret")
138+ if !ok {
139+ t.Fatal("missing caret")
140+ }
141+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
142+ if !ok {
143+ t.Fatal("missing preedit")
144+ }
145+ if math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
146+ t.Fatalf("offset %d: caret %g preedit %g, want preceding trailing edge %g (leading %g)", offset, caret.Rect.X, preedit.Rect.X, want, leading.x)
147+ }
148+ }
149+}
150+
151+// The scroll target must be the painted caret, not another duplicate offset.
152+func TestMixedDirectionScrollFollowsPaintedCaret(t *testing.T) {
153+ value := "abc مرحبا def"
154+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
155+ // The trailing edge of "abc " is left of the leading edge of the RTL run.
156+ // A content box between the two edges scrolls only if the wrong one is used.
157+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
158+ r.Build(view)
159+ r.route(key("Tab"))
160+ r.Build(view)
161+ n := r.output.Tree.Children[0]
162+ // Expected edges come from raw glyphs. The space before the RTL run ends at
163+ // trailing; the RTL run's reading-order start (its right ink edge) is the
164+ // other, leading duplicate of the same offset.
165+ trailing := rawTrailingEdge(t, r, n, 3)
166+ leading := math.Inf(-1)
167+ cmd, _ := displayCommand(r, n.ID, "text")
168+ for i, run := range cmd.Runs {
169+ if n.Lines[0].Runs[i].Direction == di.DirectionRTL {
170+ for _, g := range run.Glyphs {
171+ leading = math.Max(leading, g.X+g.Advance)
172+ }
173+ }
174+ }
175+ if trailing >= leading {
176+ t.Fatalf("fixture changed: trailing %g leading %g", trailing, leading)
177+ }
178+ // Content wide enough for the preceding character's trailing edge but not
179+ // for the following run's leading edge.
180+ padding := 300 - n.Content.W
181+ contentW := math.Floor((trailing + leading) / 2)
182+ r = geometryRuntime(t, fmt.Sprintf("input {height:28px;font-size:16px;width:%gpx}", contentW+padding))
183+ r.Build(view)
184+ n = r.output.Tree.Children[0]
185+ r.edits[n.ID].Move(len("abc "), false) // before focus, so no earlier scroll persists
186+ r.route(key("Tab"))
187+ r.Build(view)
188+ n = r.output.Tree.Children[0]
189+ caret, _ := displayCommand(r, n.ID, "caret")
190+ if n.ScrollX != 0 || math.Abs(caret.Rect.X-trailing) > 0.01 || caret.Rect.X > n.Content.X+n.Content.W-1 {
191+ t.Fatalf("scroll %g caret %g want unscrolled at %g; content %+v", n.ScrollX, caret.Rect.X, trailing, n.Content)
192+ }
193+}
194+
195+func TestPasswordCombiningGraphemesScrollAndPreedit(t *testing.T) {
196+ value := strings.Repeat("e\u0301", 12)
197+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
198+ r := geometryRuntime(t, `input {width:60px;height:28px;font-size:16px}`)
199+ r.Build(view)
200+ r.route(key("Tab"))
201+ r.Build(view)
202+ n := r.output.Tree.Children[0]
203+ s := r.edits[n.ID]
204+ if s.Cursor != len(value) {
205+ t.Fatalf("cursor %d", s.Cursor)
206+ }
207+ // 12 graphemes are 24 runes; counting runes used to miss the mask boundary.
208+ r.Redraw()
209+ r.Build(view)
210+ n = r.output.Tree.Children[0]
211+ if n.ScrollX <= 0 {
212+ t.Fatalf("caret at end of a long password did not scroll: %+v", n)
213+ }
214+ caret, ok := displayCommand(r, n.ID, "caret")
215+ if !ok || caret.Rect.X < n.Content.X-0.01 || caret.Rect.X > n.Content.X+n.Content.W {
216+ t.Fatalf("caret %+v outside content %+v", caret, n.Content)
217+ }
218+
219+ // Move inside the value, then compose: origin is the painted caret.
220+ mid := len(strings.Repeat("e\u0301", 4))
221+ s.Move(mid, false)
222+ s.Preedit = "e\u0301猫"
223+ s.PreeditBegin = len("e\u0301")
224+ r.Redraw()
225+ r.Build(view)
226+ n = r.output.Tree.Children[0]
227+ caret, _ = displayCommand(r, n.ID, "caret")
228+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
229+ // Independent expectation: the trailing edge of the 4th bullet (4 graphemes,
230+ // 8 runes, precede the caret) in the shaped masked text.
231+ want := rawTrailingEdge(t, r, n, 3)
232+ if !ok || math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
233+ t.Fatalf("caret %g preedit %g, want after 4th bullet at %g", caret.Rect.X, preedit.Rect.X, want)
234+ }
235+ if preedit.Text != "••" {
236+ t.Fatalf("preedit text %q", preedit.Text)
237+ }
238+ data, err := json.Marshal(r.output.Display)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ if bytes.Contains(data, []byte("e\u0301")) || bytes.Contains(data, []byte("猫")) {
243+ t.Fatalf("plaintext leaked: %s", data)
244+ }
245+ if value != strings.Repeat("e\u0301", 12) {
246+ t.Fatalf("model changed: %q", value)
247+ }
248+}
249+
250+func TestPasswordPointerPlacementUsesGraphemeOffsets(t *testing.T) {
251+ value := "e\u0301a\u0300b"
252+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
253+ r := geometryRuntime(t, `input {width:200px;height:28px;font-size:16px}`)
254+ r.Build(view)
255+ n := r.output.Tree.Children[0]
256+ g := newEditorGeometry(&edit.State{Value: value}, n, r.output.Display, true)
257+ // Bullet k (0-based) ends the k+1'th grapheme; its raw trailing edge must
258+ // place the caret after that grapheme in the logical value.
259+ for k, want := range []int{len("e\u0301"), len("e\u0301a\u0300"), len(value)} {
260+ x := rawTrailingEdge(t, r, n, k)
261+ if got := g.nearest(x, n.Content.Y+1); got != want {
262+ t.Fatalf("nearest(%g) = %d, want %d", x, got, want)
263+ }
264+ }
265+ if got := g.nearest(n.Content.X, n.Content.Y+1); got != 0 {
266+ t.Fatalf("nearest at start = %d", got)
267+ }
268+}
269+
270+const wrapValue = "one two three four five six"
271+
272+// wrapFixture builds a focused, narrow textarea and returns the source offset
273+// shared by the end of visual line 0 and the start of line 1 ("one |two").
274+// The offset comes from the shaped runs, not from editorGeometry.
275+func wrapFixture(t *testing.T, height string) (*runtime, func(*Frame), *layout.Result, int) {
276+ t.Helper()
277+ r := geometryRuntime(t, "textarea {width:45px;height:"+height+";font-size:16px}")
278+ value := wrapValue
279+ view := func(f *Frame) { f.Root().Textarea("wrapped", &value) }
280+ r.Build(view)
281+ n := r.output.Tree.Children[0]
282+ if len(n.Lines) < 3 {
283+ t.Fatalf("expected wrapped lines, got %d", len(n.Lines))
284+ }
285+ first, second := n.Lines[0].Runs, n.Lines[1].Runs
286+ shared := first[len(first)-1].End
287+ if shared == 0 || shared != second[0].Start || shared >= len(value) {
288+ t.Fatalf("lines do not share a wrap offset: %+v %+v", first, second)
289+ }
290+ r.edits[n.ID].Move(shared, false) // ASCII: rune index == byte offset
291+ r.route(key("Tab"))
292+ r.Build(view)
293+ return r, view, r.output.Tree.Children[0], shared
294+}
295+
296+// At a soft wrap the offset is the end of line 0 and the start of line 1.
297+// Painting and preedit use the end of line 0 (preceding character), and the
298+// scroll follows that same caret, while vertical navigation still treats the
299+// offset as being on the following line.
300+func TestSoftWrapCaretPolicyAgreesAcrossCallers(t *testing.T) {
301+ r, view, n, shared := wrapFixture(t, "100px")
302+ s := r.edits[n.ID]
303+ if s.Cursor != shared {
304+ t.Fatalf("cursor %d want %d", s.Cursor, shared)
305+ }
306+ wantX := rawTrailingEdge(t, r, n, shared-1)
307+ wantY := n.Content.Y
308+ nextLineY := n.Content.Y + n.Lines[0].Height
309+ s.Preedit = "x"
310+ r.Redraw()
311+ r.Build(view)
312+ n = r.output.Tree.Children[0]
313+ caret, ok := displayCommand(r, n.ID, "caret")
314+ if !ok {
315+ t.Fatal("missing caret")
316+ }
317+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
318+ if !ok {
319+ t.Fatal("missing preedit")
320+ }
321+ for name, rect := range map[string]layout.Rect{"caret": caret.Rect, "preedit": preedit.Rect} {
322+ if math.Abs(rect.X-wantX) > 0.01 || math.Abs(rect.Y-wantY) > 0.01 || rect.Y == nextLineY {
323+ t.Errorf("%s at (%g,%g), want end of preceding line (%g,%g)", name, rect.X, rect.Y, wantX, wantY)
324+ }
325+ }
326+ if caret.Rect.H != n.Lines[0].Height {
327+ t.Errorf("caret height %g want line height %g", caret.Rect.H, n.Lines[0].Height)
328+ }
329+}
330+
331+func TestSoftWrapScrollFollowsPrecedingLineCaret(t *testing.T) {
332+ // Content holds exactly one line. The caret at the wrap offset is on line
333+ // 0, so nothing scrolls; following line 1 instead would scroll.
334+ r, _, n, _ := wrapFixture(t, "22px")
335+ if n.Content.H >= 2*n.Lines[0].Height {
336+ t.Fatalf("fixture shows more than one line: %+v", n.Content)
337+ }
338+ if n.ScrollY != 0 {
339+ t.Fatalf("scrolled to the following line: %g", n.ScrollY)
340+ }
341+ if caret, ok := displayCommand(r, n.ID, "caret"); !ok || caret.Rect.Y != n.Content.Y {
342+ t.Fatalf("caret %+v content %+v", caret, n.Content)
343+ }
344+}
345+
346+func TestSoftWrapVerticalNavigationKeepsFollowingLinePolicy(t *testing.T) {
347+ // Down from the shared offset moves off line 1 (to the start of line 2),
348+ // not off line 0; up moves off line 1 to line 0, whichever the caret paints.
349+ r, view, n, shared := wrapFixture(t, "100px")
350+ s := r.edits[n.ID]
351+ lineEnd := n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End
352+ r.route(key("Down"))
353+ r.Build(view)
354+ if s.Cursor != lineEnd {
355+ t.Fatalf("down from %d = %d, want start of line 2 at %d", shared, s.Cursor, lineEnd)
356+ }
357+ if !s.PreferredXValid || s.PreferredX != n.Content.X {
358+ t.Fatalf("preferred x %v %g, want line-1 start %g", s.PreferredXValid, s.PreferredX, n.Content.X)
359+ }
360+ s.Move(shared, false)
361+ s.PreferredXValid = false
362+ r.route(key("Up"))
363+ r.Build(view)
364+ if s.Cursor != 0 {
365+ t.Fatalf("up from %d = %d, want start of line 0", shared, s.Cursor)
366+ }
367+}
368+
369+// Scrolling uses the height of the caret's own line, not line 0's.
370+func TestScrollCaretUsesCaretLineHeight(t *testing.T) {
371+ r, _, n, _ := wrapFixture(t, "100px")
372+ id := n.ID
373+ // Line 0 is short and line 1 is tall. The caret is at the end of line 1.
374+ n.Lines[0].Height = 10
375+ n.Lines[1].Height = 30
376+ n.Content.H = 30
377+ n.ContentSize.H = 200
378+ s := r.edits[id]
379+ s.Move(n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End-1, false) // before "two"'s trailing space edge: on line 1
380+ if r.state.scroll != nil {
381+ delete(r.state.scroll, id)
382+ }
383+ if !r.scrollCaret(r.committed, &r.output) {
384+ t.Fatal("caret below the viewport did not scroll")
385+ }
386+ // Caret line 1 spans y 10..40 in a 30px viewport: scroll by 10. Using line 0's
387+ // height (10) would end at 20 and not scroll at all.
388+ if got := r.state.scroll[id].H; math.Abs(got-10) > 0.01 {
389+ t.Fatalf("scroll %g, want 10 (caret line bottom 40 - viewport 30)", got)
390+ }
391+}
@@ -0,0 +1,391 @@
1+package ui
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "math"
8+ "strings"
9+ "testing"
10+
11+ "github.com/bnema/nefergui/internal/css"
12+ "github.com/bnema/nefergui/internal/edit"
13+ "github.com/bnema/nefergui/internal/layout"
14+ "github.com/bnema/nefergui/internal/text"
15+ "github.com/go-text/typesetting/di"
16+)
17+
18+func geometryRuntime(t *testing.T, sheet string) *runtime {
19+ t.Helper()
20+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ r := newRuntime()
25+ r.setTextEngine(text.NewEngine(catalog))
26+ r.styles = css.Compile(css.UA(), css.Parse(sheet))
27+ return r
28+}
29+
30+func displayCommand(r *runtime, id, op string) (layout.Command, bool) {
31+ for _, c := range r.output.Display {
32+ if c.ID == id && c.Op == op {
33+ return c, true
34+ }
35+ }
36+ return layout.Command{}, false
37+}
38+
39+// rawTrailingEdge computes, independently of editorGeometry, the x where the
40+// character at source rune index `rune` ends in reading order: its right ink
41+// edge in an LTR run and its left edge in an RTL run. It reads the shaped
42+// glyphs of the painted text command and the line's run directions only.
43+func rawTrailingEdge(t *testing.T, r *runtime, n *layout.Result, rune int) float64 {
44+ t.Helper()
45+ cmd, ok := displayCommand(r, n.ID, "text")
46+ if !ok {
47+ t.Fatal("missing text command")
48+ }
49+ ri := 0
50+ for _, line := range n.Lines {
51+ for _, shaped := range line.Runs {
52+ run := cmd.Runs[ri]
53+ ri++
54+ low, high, seen := math.Inf(1), math.Inf(-1), false
55+ for _, g := range run.Glyphs {
56+ if g.Cluster == rune {
57+ seen = true
58+ low = math.Min(low, g.X)
59+ high = math.Max(high, g.X+g.Advance)
60+ }
61+ }
62+ if !seen {
63+ continue
64+ }
65+ if shaped.Direction == di.DirectionRTL {
66+ return low
67+ }
68+ return high
69+ }
70+ }
71+ t.Fatalf("no glyph for rune %d", rune)
72+ return 0
73+}
74+
75+func TestDisplayTextMapsPasswordGraphemes(t *testing.T) {
76+ value := "e\u0301猫a"
77+ d := newDisplayText(value, true)
78+ if d.shown != "•••" {
79+ t.Fatalf("shown %q", d.shown)
80+ }
81+ bullet := len("•")
82+ for _, c := range []struct{ logical, shown int }{
83+ {0, 0}, {len("e\u0301"), bullet}, {len("e\u0301猫"), 2 * bullet}, {len(value), 3 * bullet},
84+ {1, 0}, // inside the first grapheme: start of its cluster
85+ } {
86+ if got := d.toShown(c.logical); got != c.shown {
87+ t.Errorf("toShown(%d) = %d, want %d", c.logical, got, c.shown)
88+ }
89+ }
90+ for _, c := range []struct{ shown, logical int }{{0, 0}, {bullet, len("e\u0301")}, {3 * bullet, len(value)}} {
91+ if got := d.toLogical(c.shown); got != c.logical {
92+ t.Errorf("toLogical(%d) = %d, want %d", c.shown, got, c.logical)
93+ }
94+ }
95+ plain := newDisplayText(value, false)
96+ if plain.shown != value || plain.toShown(3) != 3 || plain.toLogical(3) != 3 {
97+ t.Fatalf("plain text must map identically: %+v", plain)
98+ }
99+}
100+
101+// At a bidi run boundary one offset has two visual positions. Painting,
102+// scrolling and preedit must all use the preceding character's trailing edge.
103+func TestMixedDirectionCaretPolicyAgreesAcrossCallers(t *testing.T) {
104+ value := "abc مرحبا def"
105+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
106+ // offset -> source rune index of the character before it.
107+ for _, c := range []struct{ offset, before int }{{len("abc "), 3}, {len("abc مرحبا"), 8}} {
108+ offset := c.offset
109+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
110+ r.Build(view)
111+ r.route(key("Tab"))
112+ r.Build(view)
113+ n := r.output.Tree.Children[0]
114+ r.edits[n.ID].Move(offset, false)
115+ r.edits[n.ID].Preedit = "x"
116+ r.Redraw()
117+ r.Build(view)
118+ n = r.output.Tree.Children[0]
119+
120+ g := newEditorGeometry(r.edits[n.ID], n, r.output.Display, false)
121+ var edges []textBoundary
122+ for _, b := range g.lines[0] {
123+ if b.byteOffset == offset {
124+ edges = append(edges, b)
125+ }
126+ }
127+ var leading textBoundary
128+ for _, b := range edges {
129+ if !b.trailing {
130+ leading = b
131+ }
132+ }
133+ want := rawTrailingEdge(t, r, n, c.before)
134+ if len(edges) < 2 || math.Abs(want-leading.x) < 1 {
135+ t.Fatalf("offset %d is not an ambiguous bidi boundary: %+v (preceding edge %g)", offset, edges, want)
136+ }
137+ caret, ok := displayCommand(r, n.ID, "caret")
138+ if !ok {
139+ t.Fatal("missing caret")
140+ }
141+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
142+ if !ok {
143+ t.Fatal("missing preedit")
144+ }
145+ if math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
146+ t.Fatalf("offset %d: caret %g preedit %g, want preceding trailing edge %g (leading %g)", offset, caret.Rect.X, preedit.Rect.X, want, leading.x)
147+ }
148+ }
149+}
150+
151+// The scroll target must be the painted caret, not another duplicate offset.
152+func TestMixedDirectionScrollFollowsPaintedCaret(t *testing.T) {
153+ value := "abc مرحبا def"
154+ view := func(f *Frame) { f.Root().Input("mixed", &value) }
155+ // The trailing edge of "abc " is left of the leading edge of the RTL run.
156+ // A content box between the two edges scrolls only if the wrong one is used.
157+ r := geometryRuntime(t, `input {width:300px;height:28px;font-size:16px}`)
158+ r.Build(view)
159+ r.route(key("Tab"))
160+ r.Build(view)
161+ n := r.output.Tree.Children[0]
162+ // Expected edges come from raw glyphs. The space before the RTL run ends at
163+ // trailing; the RTL run's reading-order start (its right ink edge) is the
164+ // other, leading duplicate of the same offset.
165+ trailing := rawTrailingEdge(t, r, n, 3)
166+ leading := math.Inf(-1)
167+ cmd, _ := displayCommand(r, n.ID, "text")
168+ for i, run := range cmd.Runs {
169+ if n.Lines[0].Runs[i].Direction == di.DirectionRTL {
170+ for _, g := range run.Glyphs {
171+ leading = math.Max(leading, g.X+g.Advance)
172+ }
173+ }
174+ }
175+ if trailing >= leading {
176+ t.Fatalf("fixture changed: trailing %g leading %g", trailing, leading)
177+ }
178+ // Content wide enough for the preceding character's trailing edge but not
179+ // for the following run's leading edge.
180+ padding := 300 - n.Content.W
181+ contentW := math.Floor((trailing + leading) / 2)
182+ r = geometryRuntime(t, fmt.Sprintf("input {height:28px;font-size:16px;width:%gpx}", contentW+padding))
183+ r.Build(view)
184+ n = r.output.Tree.Children[0]
185+ r.edits[n.ID].Move(len("abc "), false) // before focus, so no earlier scroll persists
186+ r.route(key("Tab"))
187+ r.Build(view)
188+ n = r.output.Tree.Children[0]
189+ caret, _ := displayCommand(r, n.ID, "caret")
190+ if n.ScrollX != 0 || math.Abs(caret.Rect.X-trailing) > 0.01 || caret.Rect.X > n.Content.X+n.Content.W-1 {
191+ t.Fatalf("scroll %g caret %g want unscrolled at %g; content %+v", n.ScrollX, caret.Rect.X, trailing, n.Content)
192+ }
193+}
194+
195+func TestPasswordCombiningGraphemesScrollAndPreedit(t *testing.T) {
196+ value := strings.Repeat("e\u0301", 12)
197+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
198+ r := geometryRuntime(t, `input {width:60px;height:28px;font-size:16px}`)
199+ r.Build(view)
200+ r.route(key("Tab"))
201+ r.Build(view)
202+ n := r.output.Tree.Children[0]
203+ s := r.edits[n.ID]
204+ if s.Cursor != len(value) {
205+ t.Fatalf("cursor %d", s.Cursor)
206+ }
207+ // 12 graphemes are 24 runes; counting runes used to miss the mask boundary.
208+ r.Redraw()
209+ r.Build(view)
210+ n = r.output.Tree.Children[0]
211+ if n.ScrollX <= 0 {
212+ t.Fatalf("caret at end of a long password did not scroll: %+v", n)
213+ }
214+ caret, ok := displayCommand(r, n.ID, "caret")
215+ if !ok || caret.Rect.X < n.Content.X-0.01 || caret.Rect.X > n.Content.X+n.Content.W {
216+ t.Fatalf("caret %+v outside content %+v", caret, n.Content)
217+ }
218+
219+ // Move inside the value, then compose: origin is the painted caret.
220+ mid := len(strings.Repeat("e\u0301", 4))
221+ s.Move(mid, false)
222+ s.Preedit = "e\u0301猫"
223+ s.PreeditBegin = len("e\u0301")
224+ r.Redraw()
225+ r.Build(view)
226+ n = r.output.Tree.Children[0]
227+ caret, _ = displayCommand(r, n.ID, "caret")
228+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
229+ // Independent expectation: the trailing edge of the 4th bullet (4 graphemes,
230+ // 8 runes, precede the caret) in the shaped masked text.
231+ want := rawTrailingEdge(t, r, n, 3)
232+ if !ok || math.Abs(caret.Rect.X-want) > 0.01 || math.Abs(preedit.Rect.X-want) > 0.01 {
233+ t.Fatalf("caret %g preedit %g, want after 4th bullet at %g", caret.Rect.X, preedit.Rect.X, want)
234+ }
235+ if preedit.Text != "••" {
236+ t.Fatalf("preedit text %q", preedit.Text)
237+ }
238+ data, err := json.Marshal(r.output.Display)
239+ if err != nil {
240+ t.Fatal(err)
241+ }
242+ if bytes.Contains(data, []byte("e\u0301")) || bytes.Contains(data, []byte("猫")) {
243+ t.Fatalf("plaintext leaked: %s", data)
244+ }
245+ if value != strings.Repeat("e\u0301", 12) {
246+ t.Fatalf("model changed: %q", value)
247+ }
248+}
249+
250+func TestPasswordPointerPlacementUsesGraphemeOffsets(t *testing.T) {
251+ value := "e\u0301a\u0300b"
252+ view := func(f *Frame) { f.Root().Input("secret", &value, Password(true)) }
253+ r := geometryRuntime(t, `input {width:200px;height:28px;font-size:16px}`)
254+ r.Build(view)
255+ n := r.output.Tree.Children[0]
256+ g := newEditorGeometry(&edit.State{Value: value}, n, r.output.Display, true)
257+ // Bullet k (0-based) ends the k+1'th grapheme; its raw trailing edge must
258+ // place the caret after that grapheme in the logical value.
259+ for k, want := range []int{len("e\u0301"), len("e\u0301a\u0300"), len(value)} {
260+ x := rawTrailingEdge(t, r, n, k)
261+ if got := g.nearest(x, n.Content.Y+1); got != want {
262+ t.Fatalf("nearest(%g) = %d, want %d", x, got, want)
263+ }
264+ }
265+ if got := g.nearest(n.Content.X, n.Content.Y+1); got != 0 {
266+ t.Fatalf("nearest at start = %d", got)
267+ }
268+}
269+
270+const wrapValue = "one two three four five six"
271+
272+// wrapFixture builds a focused, narrow textarea and returns the source offset
273+// shared by the end of visual line 0 and the start of line 1 ("one |two").
274+// The offset comes from the shaped runs, not from editorGeometry.
275+func wrapFixture(t *testing.T, height string) (*runtime, func(*Frame), *layout.Result, int) {
276+ t.Helper()
277+ r := geometryRuntime(t, "textarea {width:45px;height:"+height+";font-size:16px}")
278+ value := wrapValue
279+ view := func(f *Frame) { f.Root().Textarea("wrapped", &value) }
280+ r.Build(view)
281+ n := r.output.Tree.Children[0]
282+ if len(n.Lines) < 3 {
283+ t.Fatalf("expected wrapped lines, got %d", len(n.Lines))
284+ }
285+ first, second := n.Lines[0].Runs, n.Lines[1].Runs
286+ shared := first[len(first)-1].End
287+ if shared == 0 || shared != second[0].Start || shared >= len(value) {
288+ t.Fatalf("lines do not share a wrap offset: %+v %+v", first, second)
289+ }
290+ r.edits[n.ID].Move(shared, false) // ASCII: rune index == byte offset
291+ r.route(key("Tab"))
292+ r.Build(view)
293+ return r, view, r.output.Tree.Children[0], shared
294+}
295+
296+// At a soft wrap the offset is the end of line 0 and the start of line 1.
297+// Painting and preedit use the end of line 0 (preceding character), and the
298+// scroll follows that same caret, while vertical navigation still treats the
299+// offset as being on the following line.
300+func TestSoftWrapCaretPolicyAgreesAcrossCallers(t *testing.T) {
301+ r, view, n, shared := wrapFixture(t, "100px")
302+ s := r.edits[n.ID]
303+ if s.Cursor != shared {
304+ t.Fatalf("cursor %d want %d", s.Cursor, shared)
305+ }
306+ wantX := rawTrailingEdge(t, r, n, shared-1)
307+ wantY := n.Content.Y
308+ nextLineY := n.Content.Y + n.Lines[0].Height
309+ s.Preedit = "x"
310+ r.Redraw()
311+ r.Build(view)
312+ n = r.output.Tree.Children[0]
313+ caret, ok := displayCommand(r, n.ID, "caret")
314+ if !ok {
315+ t.Fatal("missing caret")
316+ }
317+ preedit, ok := displayCommand(r, n.ID, "preedit-text")
318+ if !ok {
319+ t.Fatal("missing preedit")
320+ }
321+ for name, rect := range map[string]layout.Rect{"caret": caret.Rect, "preedit": preedit.Rect} {
322+ if math.Abs(rect.X-wantX) > 0.01 || math.Abs(rect.Y-wantY) > 0.01 || rect.Y == nextLineY {
323+ t.Errorf("%s at (%g,%g), want end of preceding line (%g,%g)", name, rect.X, rect.Y, wantX, wantY)
324+ }
325+ }
326+ if caret.Rect.H != n.Lines[0].Height {
327+ t.Errorf("caret height %g want line height %g", caret.Rect.H, n.Lines[0].Height)
328+ }
329+}
330+
331+func TestSoftWrapScrollFollowsPrecedingLineCaret(t *testing.T) {
332+ // Content holds exactly one line. The caret at the wrap offset is on line
333+ // 0, so nothing scrolls; following line 1 instead would scroll.
334+ r, _, n, _ := wrapFixture(t, "22px")
335+ if n.Content.H >= 2*n.Lines[0].Height {
336+ t.Fatalf("fixture shows more than one line: %+v", n.Content)
337+ }
338+ if n.ScrollY != 0 {
339+ t.Fatalf("scrolled to the following line: %g", n.ScrollY)
340+ }
341+ if caret, ok := displayCommand(r, n.ID, "caret"); !ok || caret.Rect.Y != n.Content.Y {
342+ t.Fatalf("caret %+v content %+v", caret, n.Content)
343+ }
344+}
345+
346+func TestSoftWrapVerticalNavigationKeepsFollowingLinePolicy(t *testing.T) {
347+ // Down from the shared offset moves off line 1 (to the start of line 2),
348+ // not off line 0; up moves off line 1 to line 0, whichever the caret paints.
349+ r, view, n, shared := wrapFixture(t, "100px")
350+ s := r.edits[n.ID]
351+ lineEnd := n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End
352+ r.route(key("Down"))
353+ r.Build(view)
354+ if s.Cursor != lineEnd {
355+ t.Fatalf("down from %d = %d, want start of line 2 at %d", shared, s.Cursor, lineEnd)
356+ }
357+ if !s.PreferredXValid || s.PreferredX != n.Content.X {
358+ t.Fatalf("preferred x %v %g, want line-1 start %g", s.PreferredXValid, s.PreferredX, n.Content.X)
359+ }
360+ s.Move(shared, false)
361+ s.PreferredXValid = false
362+ r.route(key("Up"))
363+ r.Build(view)
364+ if s.Cursor != 0 {
365+ t.Fatalf("up from %d = %d, want start of line 0", shared, s.Cursor)
366+ }
367+}
368+
369+// Scrolling uses the height of the caret's own line, not line 0's.
370+func TestScrollCaretUsesCaretLineHeight(t *testing.T) {
371+ r, _, n, _ := wrapFixture(t, "100px")
372+ id := n.ID
373+ // Line 0 is short and line 1 is tall. The caret is at the end of line 1.
374+ n.Lines[0].Height = 10
375+ n.Lines[1].Height = 30
376+ n.Content.H = 30
377+ n.ContentSize.H = 200
378+ s := r.edits[id]
379+ s.Move(n.Lines[1].Runs[len(n.Lines[1].Runs)-1].End-1, false) // before "two"'s trailing space edge: on line 1
380+ if r.state.scroll != nil {
381+ delete(r.state.scroll, id)
382+ }
383+ if !r.scrollCaret(r.committed, &r.output) {
384+ t.Fatal("caret below the viewport did not scroll")
385+ }
386+ // Caret line 1 spans y 10..40 in a 30px viewport: scroll by 10. Using line 0's
387+ // height (10) would end at 20 and not scroll at all.
388+ if got := r.state.scroll[id].H; math.Abs(got-10) > 0.01 {
389+ t.Fatalf("scroll %g, want 10 (caret line bottom 40 - viewport 30)", got)
390+ }
391+}
R edit_mixed_direction_test.go → internal/ui/edit_mixed_direction_test.go +2 -2 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 func TestMixedDirectionSelectionVisualRects(t *testing.T) {
14- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
14+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1515 if err != nil {
1616 t.Fatal(err)
1717 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 func TestMixedDirectionSelectionVisualRects(t *testing.T) {13 func TestMixedDirectionSelectionVisualRects(t *testing.T) {
14- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))14+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
15 if err != nil {15 if err != nil {
16 t.Fatal(err)16 t.Fatal(err)
17 }17 }
R edit_navigation.go → internal/ui/edit_navigation.go +8 -9 renamed, 73% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -9,8 +9,9 @@ import (
99
1010 // moveVisualLine uses the last committed wrap geometry; preferred X survives
1111 // consecutive vertical moves, but is reset by horizontal editing and pointer input.
12-func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, down, shift bool) {
13- lines := textBoundaries(s.Value, n, display)
12+func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, password, down, shift bool) {
13+ geometry := newEditorGeometry(s, n, display, password)
14+ lines := geometry.lines
1415 if len(lines) == 0 {
1516 return
1617 }
@@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d
2223 break
2324 }
2425 }
25- // Prefer the following line at a shared wrap boundary.
2626 }
27+ // Navigation prefers the following line at a shared wrap boundary, unlike
28+ // the painted caret, which stays at the end of the preceding line.
2729 if !s.PreferredXValid {
28- for _, b := range lines[current] {
29- if b.byteOffset == s.Cursor {
30- s.PreferredX = b.x
31- break
32- }
30+ if b, ok := geometry.caretOnLine(current, s.Cursor); ok {
31+ s.PreferredX = b.x
3332 }
3433 s.PreferredXValid = true
3534 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
@@ -9,8 +9,9 @@ import (
9 9
10 // moveVisualLine uses the last committed wrap geometry; preferred X survives10 // moveVisualLine uses the last committed wrap geometry; preferred X survives
11 // consecutive vertical moves, but is reset by horizontal editing and pointer input.11 // consecutive vertical moves, but is reset by horizontal editing and pointer input.
12-func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, down, shift bool) {12+func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, password, down, shift bool) {
13- lines := textBoundaries(s.Value, n, display)13+ geometry := newEditorGeometry(s, n, display, password)
14+ lines := geometry.lines
14 if len(lines) == 0 {15 if len(lines) == 0 {
15 return16 return
16 }17 }
@@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d
22 break23 break
23 }24 }
24 }25 }
25- // Prefer the following line at a shared wrap boundary.
26 }26 }
27+ // Navigation prefers the following line at a shared wrap boundary, unlike
28+ // the painted caret, which stays at the end of the preceding line.
27 if !s.PreferredXValid {29 if !s.PreferredXValid {
28- for _, b := range lines[current] {30+ if b, ok := geometry.caretOnLine(current, s.Cursor); ok {
29- if b.byteOffset == s.Cursor {31+ s.PreferredX = b.x
30- s.PreferredX = b.x
31- break
32- }
33 }32 }
34 s.PreferredXValid = true33 s.PreferredXValid = true
35 }34 }
R edit_navigation_test.go → internal/ui/edit_navigation_test.go +2 -2 renamed, 95% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -7,7 +7,7 @@ import (
77 )
88
99 func TestWrappedNavigationAndScroll(t *testing.T) {
10- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
10+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1111 if err != nil {
1212 t.Fatal(err)
1313 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
@@ -7,7 +7,7 @@ import (
7 )7 )
8 8
9 func TestWrappedNavigationAndScroll(t *testing.T) {9 func TestWrappedNavigationAndScroll(t *testing.T) {
10- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))10+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
11 if err != nil {11 if err != nil {
12 t.Fatal(err)12 t.Fatal(err)
13 }13 }
R edit_paint.go → internal/ui/edit_paint.go +12 -47 renamed, 65% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -43,38 +43,18 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) {
4343 }
4444
4545 // editorDecoration maps logical grapheme selection to visual cluster spans.
46-// A bidi boundary uses the run containing the character before the cursor:
47-// later duplicate offsets must not override the first span's trailing edge.
46+// The caret follows editorGeometry's policy: at an ambiguous boundary, such as
47+// a bidi run change, the preceding character's trailing edge is used.
4848 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {
4949 if n == nil {
5050 return nil
5151 }
52- value := s.Value
5352 start, end := s.Range()
54- cursor := s.Cursor
55- if password {
56- boundaries := edit.Boundaries(value)
57- value = s.Mask()
58- render := edit.Boundaries(value)
59- for i, b := range boundaries {
60- if b == start {
61- start = render[i]
62- }
63- if b == end {
64- end = render[i]
65- }
66- if b == cursor {
67- cursor = render[i]
68- }
69- }
70- }
71- lines := textBoundaries(value, n, display)
53+ geometry := newEditorGeometry(s, n, display, password)
54+ lines := geometry.lines
7255 var result []layout.Command
7356 for i, line := range lines {
74- h := 0.0
75- if i < len(n.Lines) {
76- h = n.Lines[i].Height
77- }
57+ h := geometry.lineHeight(i)
7858 for j := 0; j+1 < len(line); j += 2 {
7959 a, b := line[j], line[j+1]
8060 if a.byteOffset == b.byteOffset {
@@ -94,29 +74,14 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command,
9474 }
9575 }
9676 if focused {
97- var point *textBoundary
98- // The trailing edge of the preceding character takes priority over the
99- // leading edge of the following run when both map to the same offset.
100- for i := range lines {
101- for j := range lines[i] {
102- b := &lines[i][j]
103- if b.byteOffset == cursor && point == nil {
104- point = b
105- }
106- }
107- }
108- if point == nil && len(lines) == 0 {
77+ // The caret uses the preceding character's trailing edge when an
78+ // offset has several visual positions.
79+ point, found := geometry.caret(s.Cursor)
80+ if !found && len(lines) == 0 {
10981 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})
11082 }
111- if point != nil {
112- h := n.Content.H
113- for i, line := range lines {
114- if len(line) > 0 && line[0].y == point.y && i < len(n.Lines) {
115- h = n.Lines[i].Height
116- break
117- }
118- }
119- result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: h}, Color: caretColor, Opacity: 1})
83+ if found {
84+ result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: geometry.lineHeight(point.line)}, Color: caretColor, Opacity: 1})
12085 }
12186 }
12287 return result
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
@@ -43,38 +43,18 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) {
43 }43 }
44 44
45 // editorDecoration maps logical grapheme selection to visual cluster spans.45 // editorDecoration maps logical grapheme selection to visual cluster spans.
46-// A bidi boundary uses the run containing the character before the cursor:46+// The caret follows editorGeometry's policy: at an ambiguous boundary, such as
47-// later duplicate offsets must not override the first span's trailing edge.47+// a bidi run change, the preceding character's trailing edge is used.
48 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {48 func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command {
49 if n == nil {49 if n == nil {
50 return nil50 return nil
51 }51 }
52- value := s.Value
53 start, end := s.Range()52 start, end := s.Range()
54- cursor := s.Cursor53+ geometry := newEditorGeometry(s, n, display, password)
55- if password {54+ lines := geometry.lines
56- boundaries := edit.Boundaries(value)
57- value = s.Mask()
58- render := edit.Boundaries(value)
59- for i, b := range boundaries {
60- if b == start {
61- start = render[i]
62- }
63- if b == end {
64- end = render[i]
65- }
66- if b == cursor {
67- cursor = render[i]
68- }
69- }
70- }
71- lines := textBoundaries(value, n, display)
72 var result []layout.Command55 var result []layout.Command
73 for i, line := range lines {56 for i, line := range lines {
74- h := 0.057+ h := geometry.lineHeight(i)
75- if i < len(n.Lines) {
76- h = n.Lines[i].Height
77- }
78 for j := 0; j+1 < len(line); j += 2 {58 for j := 0; j+1 < len(line); j += 2 {
79 a, b := line[j], line[j+1]59 a, b := line[j], line[j+1]
80 if a.byteOffset == b.byteOffset {60 if a.byteOffset == b.byteOffset {
@@ -94,29 +74,14 @@ func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command,
94 }74 }
95 }75 }
96 if focused {76 if focused {
97- var point *textBoundary77+ // The caret uses the preceding character's trailing edge when an
98- // The trailing edge of the preceding character takes priority over the78+ // offset has several visual positions.
99- // leading edge of the following run when both map to the same offset.79+ point, found := geometry.caret(s.Cursor)
100- for i := range lines {80+ if !found && len(lines) == 0 {
101- for j := range lines[i] {
102- b := &lines[i][j]
103- if b.byteOffset == cursor && point == nil {
104- point = b
105- }
106- }
107- }
108- if point == nil && len(lines) == 0 {
109 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})81 result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: n.Content.X, Y: n.Content.Y, W: 1, H: n.Content.H}, Color: caretColor, Opacity: 1})
110 }82 }
111- if point != nil {83+ if found {
112- h := n.Content.H84+ result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: geometry.lineHeight(point.line)}, Color: caretColor, Opacity: 1})
113- for i, line := range lines {
114- if len(line) > 0 && line[0].y == point.y && i < len(n.Lines) {
115- h = n.Lines[i].Height
116- break
117- }
118- }
119- result = append(result, layout.Command{Op: "caret", ID: n.ID, Rect: layout.Rect{X: point.x, Y: point.y, W: 1, H: h}, Color: caretColor, Opacity: 1})
120 }85 }
121 }86 }
122 return result87 return result
R edit_paint_test.go → internal/ui/edit_paint_test.go +2 -2 renamed, 95% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestEditorVisualSelectionAndCaret(t *testing.T) {
11- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
11+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1212 if err != nil {
1313 t.Fatal(err)
1414 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestEditorVisualSelectionAndCaret(t *testing.T) {10 func TestEditorVisualSelectionAndCaret(t *testing.T) {
11- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))11+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
12 if err != nil {12 if err != nil {
13 t.Fatal(err)13 t.Fatal(err)
14 }14 }
R edit_pointer_test.go → internal/ui/edit_pointer_test.go +2 -2 renamed, 96% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "testing"
@@ -9,7 +9,7 @@ import (
99 )
1010
1111 func TestPointerTextSelection(t *testing.T) {
12- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
12+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1313 if err != nil {
1414 t.Fatal(err)
1515 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "testing"4 "testing"
@@ -9,7 +9,7 @@ import (
9 )9 )
10 10
11 func TestPointerTextSelection(t *testing.T) {11 func TestPointerTextSelection(t *testing.T) {
12- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))12+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
13 if err != nil {13 if err != nil {
14 t.Fatal(err)14 t.Fatal(err)
15 }15 }
R edit_preedit.go → internal/ui/edit_preedit.go +8 -36 renamed, 84% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -71,30 +71,11 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
7171 if s.Preedit == "" || n == nil || e.computed == nil {
7272 return nil
7373 }
74- value := s.Value
75- cursor := s.Cursor
76- if e.password {
77- value = s.Mask()
78- i := 0
79- for k, b := range edit.Boundaries(s.Value) {
80- if b == cursor {
81- i = k
82- break
83- }
84- }
85- cursor = edit.Boundaries(value)[i]
86- }
87- var origin *textBoundary
88- lines := textBoundaries(value, n, display)
89- for i := range lines {
90- for j := range lines[i] {
91- if lines[i][j].byteOffset == cursor {
92- origin = &lines[i][j]
93- }
94- }
95- }
74+ // The composition starts at the same caret position that is painted.
75+ geometry := newEditorGeometry(s, n, display, e.password)
76+ origin, found := geometry.caret(s.Cursor)
9677 x, y := n.Content.X, n.Content.Y
97- if origin != nil {
78+ if found {
9879 x, y = origin.x, origin.y
9980 }
10081 st := e.computed.Style
@@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
128109 clamped = b
129110 }
130111 pos = clamped
131- if e.password {
132- // Preedit offsets are bytes in the original text; the display uses
133- // one bullet per original grapheme, including combined clusters.
134- index := 0
135- for _, b := range edit.Boundaries(s.Preedit) {
136- if b >= pos {
137- break
138- }
139- index++
140- }
141- pos = edit.Boundaries(preedit)[index]
142- }
112+ // Preedit offsets are bytes in the original text; a password shows one
113+ // bullet per original grapheme, including combined clusters.
114+ pos = newDisplayText(s.Preedit, e.password).toShown(pos)
143115 // The preedit cursor uses shaped cluster advances, not raw rune count.
144116 px := x
145117 pr := utf8.RuneCountInString(preedit[:pos])
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
@@ -71,30 +71,11 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
71 if s.Preedit == "" || n == nil || e.computed == nil {71 if s.Preedit == "" || n == nil || e.computed == nil {
72 return nil72 return nil
73 }73 }
74- value := s.Value74+ // The composition starts at the same caret position that is painted.
75- cursor := s.Cursor75+ geometry := newEditorGeometry(s, n, display, e.password)
76- if e.password {76+ origin, found := geometry.caret(s.Cursor)
77- value = s.Mask()
78- i := 0
79- for k, b := range edit.Boundaries(s.Value) {
80- if b == cursor {
81- i = k
82- break
83- }
84- }
85- cursor = edit.Boundaries(value)[i]
86- }
87- var origin *textBoundary
88- lines := textBoundaries(value, n, display)
89- for i := range lines {
90- for j := range lines[i] {
91- if lines[i][j].byteOffset == cursor {
92- origin = &lines[i][j]
93- }
94- }
95- }
96 x, y := n.Content.X, n.Content.Y77 x, y := n.Content.X, n.Content.Y
97- if origin != nil {78+ if found {
98 x, y = origin.x, origin.y79 x, y = origin.x, origin.y
99 }80 }
100 st := e.computed.Style81 st := e.computed.Style
@@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E
128 clamped = b109 clamped = b
129 }110 }
130 pos = clamped111 pos = clamped
131- if e.password {112+ // Preedit offsets are bytes in the original text; a password shows one
132- // Preedit offsets are bytes in the original text; the display uses113+ // bullet per original grapheme, including combined clusters.
133- // one bullet per original grapheme, including combined clusters.114+ pos = newDisplayText(s.Preedit, e.password).toShown(pos)
134- index := 0
135- for _, b := range edit.Boundaries(s.Preedit) {
136- if b >= pos {
137- break
138- }
139- index++
140- }
141- pos = edit.Boundaries(preedit)[index]
142- }
143 // The preedit cursor uses shaped cluster advances, not raw rune count.115 // The preedit cursor uses shaped cluster advances, not raw rune count.
144 px := x116 px := x
145 pr := utf8.RuneCountInString(preedit[:pos])117 pr := utf8.RuneCountInString(preedit[:pos])
R edit_preedit_password_test.go → internal/ui/edit_preedit_password_test.go +2 -2 renamed, 93% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "bytes"
@@ -11,7 +11,7 @@ import (
1111 )
1212
1313 func TestPasswordPreeditNeverLeaksIntoDisplay(t *testing.T) {
14- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
14+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1515 if err != nil {
1616 t.Fatal(err)
1717 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "bytes"4 "bytes"
@@ -11,7 +11,7 @@ import (
11 )11 )
12 12
13 func TestPasswordPreeditNeverLeaksIntoDisplay(t *testing.T) {13 func TestPasswordPreeditNeverLeaksIntoDisplay(t *testing.T) {
14- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))14+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
15 if err != nil {15 if err != nil {
16 t.Fatal(err)16 t.Fatal(err)
17 }17 }
R edit_preedit_test.go → internal/ui/edit_preedit_test.go +2 -2 renamed, 94% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestPreeditInlineWithoutModelMutation(t *testing.T) {
11- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
11+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
1212 if err != nil {
1313 t.Fatal(err)
1414 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
@@ -8,7 +8,7 @@ import (
8 )8 )
9 9
10 func TestPreeditInlineWithoutModelMutation(t *testing.T) {10 func TestPreeditInlineWithoutModelMutation(t *testing.T) {
11- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))11+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
12 if err != nil {12 if err != nil {
13 t.Fatal(err)13 t.Fatal(err)
14 }14 }
R edit_scroll.go → internal/ui/edit_scroll.go +5 -20 renamed, 77% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -35,21 +35,9 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
3535 if s == nil || n == nil {
3636 return false
3737 }
38- rendered := e.text
39- cursor := s.Cursor
40- if e.password {
41- cursor = len([]rune(s.Value[:s.Cursor])) * len("•")
42- }
43- lines := textBoundaries(rendered, n, out.Display)
44- var caret *textBoundary
45- for i := range lines {
46- for j := range lines[i] {
47- if lines[i][j].byteOffset == cursor {
48- caret = &lines[i][j]
49- }
50- }
51- }
52- if caret == nil {
38+ geometry := newEditorGeometry(s, n, out.Display, e.password)
39+ caret, found := geometry.caret(s.Cursor)
40+ if !found {
5341 return false
5442 }
5543 old := r.state.scroll[id]
@@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
6250 }
6351 next.H = 0
6452 } else {
65- height := 0.0
66- if len(n.Lines) > 0 {
67- height = n.Lines[0].Height
68- }
53+ height := geometry.lineHeight(caret.line)
6954 if caret.y < n.Content.Y {
7055 next.H += caret.y - n.Content.Y
7156 } else if caret.y+height > n.Content.Y+n.Content.H {
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
@@ -35,21 +35,9 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
35 if s == nil || n == nil {35 if s == nil || n == nil {
36 return false36 return false
37 }37 }
38- rendered := e.text38+ geometry := newEditorGeometry(s, n, out.Display, e.password)
39- cursor := s.Cursor39+ caret, found := geometry.caret(s.Cursor)
40- if e.password {40+ if !found {
41- cursor = len([]rune(s.Value[:s.Cursor])) * len("•")
42- }
43- lines := textBoundaries(rendered, n, out.Display)
44- var caret *textBoundary
45- for i := range lines {
46- for j := range lines[i] {
47- if lines[i][j].byteOffset == cursor {
48- caret = &lines[i][j]
49- }
50- }
51- }
52- if caret == nil {
53 return false41 return false
54 }42 }
55 old := r.state.scroll[id]43 old := r.state.scroll[id]
@@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool {
62 }50 }
63 next.H = 051 next.H = 0
64 } else {52 } else {
65- height := 0.053+ height := geometry.lineHeight(caret.line)
66- if len(n.Lines) > 0 {
67- height = n.Lines[0].Height
68- }
69 if caret.y < n.Content.Y {54 if caret.y < n.Content.Y {
70 next.H += caret.y - n.Content.Y55 next.H += caret.y - n.Content.Y
71 } else if caret.y+height > n.Content.Y+n.Content.H {56 } else if caret.y+height > n.Content.Y+n.Content.H {
R ime_focus_test.go → internal/ui/ime_focus_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "reflect"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "reflect"4 "reflect"
R ime_return_test.go → internal/ui/ime_return_test.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "testing"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "testing"4 "testing"
R indicator_paint.go → internal/ui/indicator_paint.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
R indicator_paint_test.go → internal/ui/indicator_paint_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
R interaction.go → internal/ui/interaction.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
R interaction_test.go → internal/ui/interaction_test.go +2 -2 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "encoding/json"
@@ -27,7 +27,7 @@ func shell(r *runtime, activated *int) func(*Frame) {
2727 }
2828 func TestShellStyleAndFocus(t *testing.T) {
2929 r := newRuntime()
30- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
30+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
3131 if err != nil {
3232 t.Fatal(err)
3333 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
@@ -27,7 +27,7 @@ func shell(r *runtime, activated *int) func(*Frame) {
27 }27 }
28 func TestShellStyleAndFocus(t *testing.T) {28 func TestShellStyleAndFocus(t *testing.T) {
29 r := newRuntime()29 r := newRuntime()
30- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))30+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
31 if err != nil {31 if err != nil {
32 t.Fatal(err)32 t.Fatal(err)
33 }33 }
R options.go → internal/ui/options.go +26 -22 renamed, 51% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import "github.com/bnema/nefergui/internal/css"
44
@@ -9,21 +9,24 @@ type EditOption interface{ edit(*element) }
99
1010 // ValueOption accepts CSS options and Disabled for pointer-backed controls.
1111 type ValueOption interface{ valueOption(*element) }
12-type commonOption struct{ apply func(*element) }
13-
14-func (o commonOption) container(e *element) { o.apply(e) }
15-func (o commonOption) button(e *element) { o.apply(e) }
16-func (o commonOption) edit(e *element) { o.apply(e) }
17-func (o commonOption) valueOption(e *element) { o.apply(e) }
18-func Key(key string) commonOption {
19- return commonOption{func(e *element) { e.identity = &identity{key: key} }}
12+
13+// CommonOption is the concrete type of Key, ID, Class and Inline; it belongs
14+// to every option family. It is exported only so the root facade can return it.
15+type CommonOption struct{ apply func(*element) }
16+
17+func (o CommonOption) container(e *element) { o.apply(e) }
18+func (o CommonOption) button(e *element) { o.apply(e) }
19+func (o CommonOption) edit(e *element) { o.apply(e) }
20+func (o CommonOption) valueOption(e *element) { o.apply(e) }
21+func Key(key string) CommonOption {
22+ return CommonOption{func(e *element) { e.identity = &identity{key: key} }}
2023 }
21-func ID(id string) commonOption { return commonOption{func(e *element) { e.id = id }} }
22-func Class(class string) commonOption {
23- return commonOption{func(e *element) { e.classes = append(e.classes, class) }}
24+func ID(id string) CommonOption { return CommonOption{func(e *element) { e.id = id }} }
25+func Class(class string) CommonOption {
26+ return CommonOption{func(e *element) { e.classes = append(e.classes, class) }}
2427 }
25-func Inline(src string) commonOption {
26- return commonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}
28+func Inline(src string) CommonOption {
29+ return CommonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}
2730 }
2831 func applyContainer(e *element, opts []ContainerOption) {
2932 for _, o := range opts {
@@ -31,21 +34,22 @@ func applyContainer(e *element, opts []ContainerOption) {
3134 }
3235 }
3336
34-// disabledOption applies to every interactive control (buttons, value controls
35-// and editors) but not to containers or headings.
36-type disabledOption struct{ disabled bool }
37+// DisabledOption is the concrete type of Disabled. It applies to every
38+// interactive control (buttons, value controls and editors) but not to
39+// containers or headings. Exported only so the root facade can return it.
40+type DisabledOption struct{ disabled bool }
3741
38-func (o disabledOption) button(e *element) { e.disabled = o.disabled }
39-func (o disabledOption) edit(e *element) { e.disabled = o.disabled }
40-func (o disabledOption) valueOption(e *element) { e.disabled = o.disabled }
42+func (o DisabledOption) button(e *element) { e.disabled = o.disabled }
43+func (o DisabledOption) edit(e *element) { e.disabled = o.disabled }
44+func (o DisabledOption) valueOption(e *element) { e.disabled = o.disabled }
4145
4246 // Disabled blocks focus and interaction on an interactive control.
43-func Disabled(v bool) disabledOption { return disabledOption{v} }
47+func Disabled(v bool) DisabledOption { return DisabledOption{v} }
4448
4549 // HeadingOption accepts common CSS options and Level, but not control options.
4650 type HeadingOption interface{ heading(*element) }
4751
48-func (o commonOption) heading(e *element) { o.apply(e) }
52+func (o CommonOption) heading(e *element) { o.apply(e) }
4953
5054 type headingLevel int
5155
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import "github.com/bnema/nefergui/internal/css"3 import "github.com/bnema/nefergui/internal/css"
4 4
@@ -9,21 +9,24 @@ type EditOption interface{ edit(*element) }
9 9
10 // ValueOption accepts CSS options and Disabled for pointer-backed controls.10 // ValueOption accepts CSS options and Disabled for pointer-backed controls.
11 type ValueOption interface{ valueOption(*element) }11 type ValueOption interface{ valueOption(*element) }
12-type commonOption struct{ apply func(*element) }12+
13-13+// CommonOption is the concrete type of Key, ID, Class and Inline; it belongs
14-func (o commonOption) container(e *element) { o.apply(e) }14+// to every option family. It is exported only so the root facade can return it.
15-func (o commonOption) button(e *element) { o.apply(e) }15+type CommonOption struct{ apply func(*element) }
16-func (o commonOption) edit(e *element) { o.apply(e) }16+
17-func (o commonOption) valueOption(e *element) { o.apply(e) }17+func (o CommonOption) container(e *element) { o.apply(e) }
18-func Key(key string) commonOption {18+func (o CommonOption) button(e *element) { o.apply(e) }
19- return commonOption{func(e *element) { e.identity = &identity{key: key} }}19+func (o CommonOption) edit(e *element) { o.apply(e) }
20+func (o CommonOption) valueOption(e *element) { o.apply(e) }
21+func Key(key string) CommonOption {
22+ return CommonOption{func(e *element) { e.identity = &identity{key: key} }}
20 }23 }
21-func ID(id string) commonOption { return commonOption{func(e *element) { e.id = id }} }24+func ID(id string) CommonOption { return CommonOption{func(e *element) { e.id = id }} }
22-func Class(class string) commonOption {25+func Class(class string) CommonOption {
23- return commonOption{func(e *element) { e.classes = append(e.classes, class) }}26+ return CommonOption{func(e *element) { e.classes = append(e.classes, class) }}
24 }27 }
25-func Inline(src string) commonOption {28+func Inline(src string) CommonOption {
26- return commonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}29+ return CommonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}
27 }30 }
28 func applyContainer(e *element, opts []ContainerOption) {31 func applyContainer(e *element, opts []ContainerOption) {
29 for _, o := range opts {32 for _, o := range opts {
@@ -31,21 +34,22 @@ func applyContainer(e *element, opts []ContainerOption) {
31 }34 }
32 }35 }
33 36
34-// disabledOption applies to every interactive control (buttons, value controls37+// DisabledOption is the concrete type of Disabled. It applies to every
35-// and editors) but not to containers or headings.38+// interactive control (buttons, value controls and editors) but not to
36-type disabledOption struct{ disabled bool }39+// containers or headings. Exported only so the root facade can return it.
40+type DisabledOption struct{ disabled bool }
37 41
38-func (o disabledOption) button(e *element) { e.disabled = o.disabled }42+func (o DisabledOption) button(e *element) { e.disabled = o.disabled }
39-func (o disabledOption) edit(e *element) { e.disabled = o.disabled }43+func (o DisabledOption) edit(e *element) { e.disabled = o.disabled }
40-func (o disabledOption) valueOption(e *element) { e.disabled = o.disabled }44+func (o DisabledOption) valueOption(e *element) { e.disabled = o.disabled }
41 45
42 // Disabled blocks focus and interaction on an interactive control.46 // Disabled blocks focus and interaction on an interactive control.
43-func Disabled(v bool) disabledOption { return disabledOption{v} }47+func Disabled(v bool) DisabledOption { return DisabledOption{v} }
44 48
45 // HeadingOption accepts common CSS options and Level, but not control options.49 // HeadingOption accepts common CSS options and Level, but not control options.
46 type HeadingOption interface{ heading(*element) }50 type HeadingOption interface{ heading(*element) }
47 51
48-func (o commonOption) heading(e *element) { o.apply(e) }52+func (o CommonOption) heading(e *element) { o.apply(e) }
49 53
50 type headingLevel int54 type headingLevel int
51 55
R paste.go → internal/ui/paste.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "context"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "context"4 "context"
R paste_test.go → internal/ui/paste_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "context"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "context"4 "context"
R performance_test.go → internal/ui/performance_test.go +2 -2 renamed, 95% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "testing"
@@ -28,7 +28,7 @@ func demoFrame(f *Frame) {
2828
2929 func demoRuntime(tb testing.TB) *runtime {
3030 tb.Helper()
31- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))
31+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
3232 if err != nil {
3333 tb.Fatal(err)
3434 }
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "testing"4 "testing"
@@ -28,7 +28,7 @@ func demoFrame(f *Frame) {
28 28
29 func demoRuntime(tb testing.TB) *runtime {29 func demoRuntime(tb testing.TB) *runtime {
30 tb.Helper()30 tb.Helper()
31- catalog, err := text.Load(text.DirectorySource("testdata/fonts"))31+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
32 if err != nil {32 if err != nil {
33 tb.Fatal(err)33 tb.Fatal(err)
34 }34 }
R platform.go → internal/ui/platform.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
R runtime.go → internal/ui/runtime.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "context"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "context"4 "context"
R runtime_test.go → internal/ui/runtime_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "context"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "context"4 "context"
R slider_grid_test.go → internal/ui/slider_grid_test.go +1 -1 renamed, 99% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "math"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "math"4 "math"
R slider_pointer_test.go → internal/ui/slider_pointer_test.go +1 -1 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package nefergui
1+package ui
22
33 import (
44 "github.com/bnema/nefergui/internal/css"
@@ -1,4 +1,4 @@
1-package nefergui1+package ui
2 2
3 import (3 import (
4 "github.com/bnema/nefergui/internal/css"4 "github.com/bnema/nefergui/internal/css"
R testdata/control-display/button_disabled.json → internal/ui/testdata/control-display/button_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/button_focus-visible.json → internal/ui/testdata/control-display/button_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/button_hover.json → internal/ui/testdata/control-display/button_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/button_normal.json → internal/ui/testdata/control-display/button_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/checkbox_checked.json → internal/ui/testdata/control-display/checkbox_checked.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/checkbox_disabled.json → internal/ui/testdata/control-display/checkbox_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/checkbox_focus-visible.json → internal/ui/testdata/control-display/checkbox_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/checkbox_hover.json → internal/ui/testdata/control-display/checkbox_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/checkbox_normal.json → internal/ui/testdata/control-display/checkbox_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/heading_hover.json → internal/ui/testdata/control-display/heading_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/heading_normal.json → internal/ui/testdata/control-display/heading_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/icon_hover.json → internal/ui/testdata/control-display/icon_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/icon_normal.json → internal/ui/testdata/control-display/icon_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/image_hover.json → internal/ui/testdata/control-display/image_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/image_normal.json → internal/ui/testdata/control-display/image_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_disabled.json → internal/ui/testdata/control-display/input_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_focus-visible.json → internal/ui/testdata/control-display/input_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_hover.json → internal/ui/testdata/control-display/input_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_normal.json → internal/ui/testdata/control-display/input_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_preedit.json → internal/ui/testdata/control-display/input_preedit.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_selection-arabic.json → internal/ui/testdata/control-display/input_selection-arabic.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/input_selection-ltr.json → internal/ui/testdata/control-display/input_selection-ltr.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/radio_checked.json → internal/ui/testdata/control-display/radio_checked.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/radio_disabled.json → internal/ui/testdata/control-display/radio_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/radio_focus-visible.json → internal/ui/testdata/control-display/radio_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/radio_hover.json → internal/ui/testdata/control-display/radio_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/radio_normal.json → internal/ui/testdata/control-display/radio_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/separator_hover.json → internal/ui/testdata/control-display/separator_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/separator_normal.json → internal/ui/testdata/control-display/separator_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/slider_disabled.json → internal/ui/testdata/control-display/slider_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/slider_focus-visible.json → internal/ui/testdata/control-display/slider_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/slider_hover.json → internal/ui/testdata/control-display/slider_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/slider_normal.json → internal/ui/testdata/control-display/slider_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/spacer_hover.json → internal/ui/testdata/control-display/spacer_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/spacer_normal.json → internal/ui/testdata/control-display/spacer_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/text_hover.json → internal/ui/testdata/control-display/text_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/text_normal.json → internal/ui/testdata/control-display/text_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/textarea_disabled.json → internal/ui/testdata/control-display/textarea_disabled.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/textarea_focus-visible.json → internal/ui/testdata/control-display/textarea_focus-visible.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/textarea_hover.json → internal/ui/testdata/control-display/textarea_hover.json +0 -0 renamed, 100% similar

renamed, 100% similar

R testdata/control-display/textarea_normal.json → internal/ui/testdata/control-display/textarea_normal.json +0 -0 renamed, 100% similar

renamed, 100% similar

R tree.go → internal/ui/tree.go +3 -1 renamed, 97% similar
@@ -1,4 +1,6 @@
1-package nefergui
1+// Package ui owns the immediate-mode frame tree, controls, editors, input
2+// routing and the Wayland run loop behind the public nefergui facade.
3+package ui
24
35 import (
46 "fmt"
@@ -1,4 +1,6 @@
1-package nefergui1+// Package ui owns the immediate-mode frame tree, controls, editors, input
2+// routing and the Wayland run loop behind the public nefergui facade.
3+package ui
2 4
3 import (5 import (
4 "fmt"6 "fmt"
A nefergui.go +115 -0
@@ -0,0 +1,115 @@
1+// Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with
2+// Vulkan and styled with a bounded CSS dialect (see docs/css.md).
3+//
4+// Run opens a window and calls the view function to build each frame. The view
5+// receives a Frame; Frame and its Node handles are valid only during that call.
6+// Key identifies a child among its siblings across frames; without a key,
7+// position and type determine identity. ID is CSS metadata, not frame identity.
8+// Options such as Title, Size, Styles and Transparent configure the window.
9+//
10+// The frame tree, controls, editors and input routing are implemented in
11+// internal/ui; this package is the public facade and re-exports those types.
12+//
13+// Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1,
14+// libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and
15+// linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md.
16+package nefergui
17+
18+import (
19+ "context"
20+
21+ "github.com/bnema/nefergui/internal/ui"
22+)
23+
24+// Frame is valid only during one call to the view function. Root builds the
25+// application element; Diagnostics reports identity problems in debug builds.
26+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.Frame.
27+type Frame = ui.Frame
28+
29+// Node is an ephemeral handle into the current frame. Do not retain it between
30+// frames. Container methods (Box, Row, Column, Stack, Scroll, Header, Nav,
31+// Main, Section, Aside, Footer, Element) return child nodes; control methods
32+// (Button, Checkbox, Radio, Slider, Input, Textarea, Text, Heading, Image,
33+// Icon, Separator, Spacer) declare controls. Interactive controls return event
34+// snapshots.
35+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.Node.
36+type Node = ui.Node
37+
38+// ButtonEvent is an immutable snapshot queried with Activated.
39+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.ButtonEvent.
40+type ButtonEvent = ui.ButtonEvent
41+
42+// EditEvent is an immutable snapshot queried with Changed and Submitted.
43+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.EditEvent.
44+type EditEvent = ui.EditEvent
45+
46+// ChangeEvent is an immutable snapshot queried with Changed.
47+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.ChangeEvent.
48+type ChangeEvent = ui.ChangeEvent
49+
50+// Option families are sealed: ContainerOption, ButtonOption, EditOption,
51+// ValueOption and HeadingOption accept the common CSS options (Key, ID,
52+// Class, Inline) plus the control-specific options listed on each function.
53+type (
54+ ContainerOption = ui.ContainerOption
55+ ButtonOption = ui.ButtonOption
56+ EditOption = ui.EditOption
57+ ValueOption = ui.ValueOption
58+ HeadingOption = ui.HeadingOption
59+ WindowOption = ui.WindowOption
60+)
61+
62+// commonOption belongs to every option family; disabledOption to every
63+// interactive control family. Both are concrete internal types, aliased so
64+// the constructors below can return them without a second implementation.
65+type (
66+ commonOption = ui.CommonOption
67+ disabledOption = ui.DisabledOption
68+)
69+
70+// Key identifies an element among its siblings across frames.
71+func Key(key string) commonOption { return ui.Key(key) }
72+
73+// ID sets the CSS ID; it does not affect frame identity.
74+func ID(id string) commonOption { return ui.ID(id) }
75+
76+// Class adds a CSS class.
77+func Class(class string) commonOption { return ui.Class(class) }
78+
79+// Inline applies inline CSS declarations.
80+func Inline(src string) commonOption { return ui.Inline(src) }
81+
82+// Disabled blocks focus and interaction on an interactive control.
83+func Disabled(v bool) disabledOption { return ui.Disabled(v) }
84+
85+// Level sets the accessibility heading level (1 through 6).
86+func Level(level int) HeadingOption { return ui.Level(level) }
87+
88+// Placeholder sets the text shown in an empty editor.
89+func Placeholder(v string) EditOption { return ui.Placeholder(v) }
90+
91+// Password masks displayed editor text and blocks copy and cut.
92+func Password(v bool) EditOption { return ui.Password(v) }
93+
94+// Title sets the window title.
95+func Title(s string) WindowOption { return ui.Title(s) }
96+
97+// Size sets the initial logical window size.
98+func Size(w, h int) WindowOption { return ui.Size(w, h) }
99+
100+// Styles loads an author stylesheet from a file path.
101+func Styles(s string) WindowOption { return ui.Styles(s) }
102+
103+// Transparent requests an alpha-capable Wayland surface (opaque by default).
104+func Transparent() WindowOption { return ui.Transparent() }
105+
106+// Run builds and presents an immediate view on the Wayland session owner loop.
107+func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
108+ return ui.Run(ctx, model, view, options...)
109+}
110+
111+// RunFrames is a deterministic harness entry: each commit requests a redraw
112+// and the loop stops after the given number of committed frames.
113+func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
114+ return ui.RunFrames(ctx, frames, model, view, committed, options...)
115+}
@@ -0,0 +1,115 @@
1+// Package nefergui provides a pure-Go immediate-mode GUI for Wayland, rendered with
2+// Vulkan and styled with a bounded CSS dialect (see docs/css.md).
3+//
4+// Run opens a window and calls the view function to build each frame. The view
5+// receives a Frame; Frame and its Node handles are valid only during that call.
6+// Key identifies a child among its siblings across frames; without a key,
7+// position and type determine identity. ID is CSS metadata, not frame identity.
8+// Options such as Title, Size, Styles and Transparent configure the window.
9+//
10+// The frame tree, controls, editors and input routing are implemented in
11+// internal/ui; this package is the public facade and re-exports those types.
12+//
13+// Builds support CGO_ENABLED=0. At runtime, NeferGUI requires libvulkan.so.1,
14+// libxkbcommon.so.0 and a Wayland compositor supporting linux-dmabuf and
15+// linux-drm-syncobj. See docs/runtime.md and docs/troubleshooting.md.
16+package nefergui
17+
18+import (
19+ "context"
20+
21+ "github.com/bnema/nefergui/internal/ui"
22+)
23+
24+// Frame is valid only during one call to the view function. Root builds the
25+// application element; Diagnostics reports identity problems in debug builds.
26+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.Frame.
27+type Frame = ui.Frame
28+
29+// Node is an ephemeral handle into the current frame. Do not retain it between
30+// frames. Container methods (Box, Row, Column, Stack, Scroll, Header, Nav,
31+// Main, Section, Aside, Footer, Element) return child nodes; control methods
32+// (Button, Checkbox, Radio, Slider, Input, Textarea, Text, Heading, Image,
33+// Icon, Separator, Spacer) declare controls. Interactive controls return event
34+// snapshots.
35+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.Node.
36+type Node = ui.Node
37+
38+// ButtonEvent is an immutable snapshot queried with Activated.
39+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.ButtonEvent.
40+type ButtonEvent = ui.ButtonEvent
41+
42+// EditEvent is an immutable snapshot queried with Changed and Submitted.
43+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.EditEvent.
44+type EditEvent = ui.EditEvent
45+
46+// ChangeEvent is an immutable snapshot queried with Changed.
47+// Alias methods are listed by go doc github.com/bnema/nefergui/internal/ui.ChangeEvent.
48+type ChangeEvent = ui.ChangeEvent
49+
50+// Option families are sealed: ContainerOption, ButtonOption, EditOption,
51+// ValueOption and HeadingOption accept the common CSS options (Key, ID,
52+// Class, Inline) plus the control-specific options listed on each function.
53+type (
54+ ContainerOption = ui.ContainerOption
55+ ButtonOption = ui.ButtonOption
56+ EditOption = ui.EditOption
57+ ValueOption = ui.ValueOption
58+ HeadingOption = ui.HeadingOption
59+ WindowOption = ui.WindowOption
60+)
61+
62+// commonOption belongs to every option family; disabledOption to every
63+// interactive control family. Both are concrete internal types, aliased so
64+// the constructors below can return them without a second implementation.
65+type (
66+ commonOption = ui.CommonOption
67+ disabledOption = ui.DisabledOption
68+)
69+
70+// Key identifies an element among its siblings across frames.
71+func Key(key string) commonOption { return ui.Key(key) }
72+
73+// ID sets the CSS ID; it does not affect frame identity.
74+func ID(id string) commonOption { return ui.ID(id) }
75+
76+// Class adds a CSS class.
77+func Class(class string) commonOption { return ui.Class(class) }
78+
79+// Inline applies inline CSS declarations.
80+func Inline(src string) commonOption { return ui.Inline(src) }
81+
82+// Disabled blocks focus and interaction on an interactive control.
83+func Disabled(v bool) disabledOption { return ui.Disabled(v) }
84+
85+// Level sets the accessibility heading level (1 through 6).
86+func Level(level int) HeadingOption { return ui.Level(level) }
87+
88+// Placeholder sets the text shown in an empty editor.
89+func Placeholder(v string) EditOption { return ui.Placeholder(v) }
90+
91+// Password masks displayed editor text and blocks copy and cut.
92+func Password(v bool) EditOption { return ui.Password(v) }
93+
94+// Title sets the window title.
95+func Title(s string) WindowOption { return ui.Title(s) }
96+
97+// Size sets the initial logical window size.
98+func Size(w, h int) WindowOption { return ui.Size(w, h) }
99+
100+// Styles loads an author stylesheet from a file path.
101+func Styles(s string) WindowOption { return ui.Styles(s) }
102+
103+// Transparent requests an alpha-capable Wayland surface (opaque by default).
104+func Transparent() WindowOption { return ui.Transparent() }
105+
106+// Run builds and presents an immediate view on the Wayland session owner loop.
107+func Run[T any](ctx context.Context, model *T, view func(*Frame, *T), options ...WindowOption) error {
108+ return ui.Run(ctx, model, view, options...)
109+}
110+
111+// RunFrames is a deterministic harness entry: each commit requests a redraw
112+// and the loop stops after the given number of committed frames.
113+func RunFrames[T any](ctx context.Context, frames int, model *T, view func(*Frame, *T), committed func(uint64) error, options ...WindowOption) error {
114+ return ui.RunFrames(ctx, frames, model, view, committed, options...)
115+}

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