Merge pull request #1 from bnema/refactor/deepen-gui-modules
refactor(gui): deepen frame ownership and simplify the repository root
M README.md +43 -28
| @@ -1,49 +1,64 @@ | |||
| 1 | 1 | # 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 | - | ## Status | |
| 5 | + | > [!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 | - | ||
| 9 | - | ## Usage | |
| 8 | + | ## A small example | |
| 10 | 9 | ||
| 11 | 10 | ```go | |
| 12 | - | type Model struct{ Name, Status string } | |
| 11 | + | package main | |
| 13 | 12 | ||
| 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 | + | ) | |
| 22 | 20 | ||
| 23 | 21 | 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 { | |
| 26 | 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 | - | ## Documentation | |
| 40 | + | ## Try it | |
| 34 | 41 | ||
| 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. | |
| 38 | 43 | ||
| 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 | + | ``` | |
| 40 | 53 | ||
| 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) | |
| 46 | 61 | ||
| 47 | 62 | ## 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. | |
| @@ -1,49 +1,64 @@ | |||||
| 1 | # NeferGUI | 1 | # 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 | - | ## Status | 5 | + | > [!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 | ```go | 10 | ```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.Name | 17 | + | |
| 19 | - | } | 18 | + | "github.com/bnema/nefergui" |
| 20 | - | root.Text(m.Status) | 19 | + | ) |
| 21 | - | } | |||
| 22 | 20 | ||||
| 23 | func main() { | 21 | func main() { | ||
| 24 | - | var m Model | 22 | + | 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 | - | ## Documentation | 40 | + | ## 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 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 | + | ``` | |||
| 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 | ## License | 62 | ## 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 @@ | |||
| 1 | 1 | # 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 | 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 | 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 | 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 | 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 | 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 | ||
| @@ -1,5 +1,9 @@ | |||||
| 1 | # Controls | 1 | # 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 | |
| 2 | 4 | ||
| 3 | 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 | 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 | 10 | | render / Prepare | 59,024 | 158,720 | 386 | 125,000 | 320,000 | 800 | | |
| 9 | 11 | | text / AtlasWarmLookup | 29.07 | 0 | 0 | 60 | 0 | 0 | | |
| 10 | 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 | 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 | 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 | 31 | ```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 | 33 | go tool pprof -sample_index=alloc_objects -top -cum mem.out | |
| 32 | 34 | ``` | |
| 33 | 35 | ||
| 34 | 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. | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | - | # CPU performance budgets | 1 | + | # 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 | ```sh | 31 | ```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.out | 33 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "log" | |
| 5 | 7 | ||
| 6 | 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 | 29 | // The product-design example. Run needs a Wayland session, so this example is | |
| 10 | 30 | // compiled but not executed by go test; examples/demo runs it. | |
| 11 | 31 | 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 is | 29 | // 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 { | |||
| 40 | 40 | } | |
| 41 | 41 | defer func() { s.waitCancel(); s.waiters.Wait() }() | |
| 42 | 42 | wlEvents := s.Window.StartReader() | |
| 43 | - | dirty := true | |
| 44 | - | var prepared render.Frame | |
| 45 | - | havePrepared := false | |
| 43 | + | sched := newFrameSchedule() | |
| 46 | 44 | for !s.Window.Closed { | |
| 47 | 45 | if err := ctx.Err(); err != nil { | |
| 48 | 46 | return err | |
| @@ -51,55 +49,40 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 51 | 49 | if err != nil { | |
| 52 | 50 | 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 | 54 | if s.Window.Dirty { | |
| 55 | - | dirty = true | |
| 55 | + | sched.Invalidate() | |
| 56 | 56 | } | |
| 57 | 57 | if inputs := s.Window.DrainInput(nil); len(inputs) > 0 { | |
| 58 | - | dirty = true | |
| 59 | - | havePrepared = false | |
| 58 | + | sched.Invalidate() | |
| 60 | 59 | if app.Input != nil { | |
| 61 | 60 | if err := app.Input(inputs); err != nil { | |
| 62 | 61 | return err | |
| 63 | 62 | } | |
| 64 | 63 | } | |
| 65 | 64 | } | |
| 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) | |
| 77 | 69 | } | |
| 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 { | |
| 86 | 78 | 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 | 83 | 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) | |
| 103 | 86 | } | |
| 104 | 87 | if adapter := s.Window.InputAdapter(); adapter != nil { | |
| 105 | 88 | if at, ok := adapter.NextRepeat(); ok { | |
| @@ -110,14 +93,12 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 110 | 93 | case <-ctx.Done(): | |
| 111 | 94 | return ctx.Err() | |
| 112 | 95 | case <-app.Wake: | |
| 113 | - | dirty = true | |
| 114 | - | havePrepared = false | |
| 96 | + | sched.Invalidate() | |
| 115 | 97 | case <-retry: | |
| 116 | 98 | case <-repeat: | |
| 117 | 99 | if adapter := s.Window.InputAdapter(); adapter != nil { | |
| 118 | 100 | if input, ok := adapter.Tick(time.Now()); ok { | |
| 119 | - | dirty = true | |
| 120 | - | havePrepared = false | |
| 101 | + | sched.Invalidate() | |
| 121 | 102 | if app.Input != nil { | |
| 122 | 103 | if err := app.Input([]wayland.Input{input}); err != nil { | |
| 123 | 104 | return err | |
| @@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 141 | 122 | return err | |
| 142 | 123 | } | |
| 143 | 124 | } | |
| 144 | - | if s.Window.Dirty { | |
| 145 | - | dirty = true | |
| 146 | - | havePrepared = false | |
| 147 | - | } | |
| 148 | 125 | // DrainInput is handled at the start of the next iteration. | |
| 149 | 126 | case ev := <-s.releases: | |
| 150 | 127 | 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 := true | 43 | + | 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 err | 46 | 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 err | 50 | 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 = true | 55 | + | 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 = true | 58 | + | 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 err | 61 | 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 err | 78 | 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.Time | 83 | 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 = true | 96 | + | 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 = true | 101 | + | 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 err | 104 | return err | ||
| @@ -141,10 +122,6 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 141 | return err | 122 | 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) | |||
| 343 | 343 | } | |
| 344 | 344 | ||
| 345 | 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 | 346 | started := time.Now() | |
| 354 | 347 | if s.Window.Dirty { | |
| 355 | 348 | w, h, err := s.Window.PhysicalSize() | |
| @@ -402,7 +395,8 @@ func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploa | |||
| 402 | 395 | if err = slot.Frame.Submit(slot.Wait); err != nil { | |
| 403 | 396 | return false, err | |
| 404 | 397 | } | |
| 405 | - | submitted = true | |
| 398 | + | // The render module keeps uploads owed until this acknowledgement. | |
| 399 | + | s.Preparer.Submitted(uploads) | |
| 406 | 400 | if slot.Wait != nil { | |
| 407 | 401 | slot.WaitInFlight = true | |
| 408 | 402 | } | |
| @@ -677,8 +671,8 @@ func (s *Session) RunWithCommit(ctx context.Context, frames int, draw func(int) | |||
| 677 | 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 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. | |
| 682 | 676 | func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, draw func(int) (render.Frame, error), committed func(int) error) (int, error) { | |
| 683 | 677 | if draw == nil { | |
| 684 | 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 | 685 | instances, batches, stats := render.ListBatches(frame) | |
| 692 | 686 | if len(stats.Skipped) != 0 { | |
| 693 | - | s.Preparer.Atlas.MarkAllDirty() | |
| 694 | 687 | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 695 | 688 | } | |
| 696 | 689 | 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, err | 396 | return false, err | ||
| 404 | } | 397 | } | ||
| 405 | - | submitted = true | 398 | + | // 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 = true | 401 | 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 skipped | 674 | + | // 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) { | |||
| 39 | 39 | if err != nil { | |
| 40 | 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 | 44 | b.Fatal(err) | |
| 44 | 45 | } | |
| 46 | + | p.Submitted(warm.Uploads) | |
| 45 | 47 | b.ReportAllocs() | |
| 46 | 48 | b.ResetTimer() | |
| 47 | 49 | for i := 0; i < b.N; i++ { | |
| @@ -49,5 +51,6 @@ func BenchmarkPrepare(b *testing.B) { | |||
| 49 | 51 | if err != nil || len(frame.Quads) == 0 { | |
| 50 | 52 | b.Fatalf("prepare: %v", err) | |
| 51 | 53 | } | |
| 54 | + | p.Submitted(frame.Uploads) | |
| 52 | 55 | } | |
| 53 | 56 | } | |
| @@ -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 { | |||
| 38 | 38 | 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 | 53 | // NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with | |
| 44 | 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 | 182 | if len(stack) > 0 { | |
| 173 | 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 | 190 | frame.Uploads = p.Atlas.Uploads() | |
| 191 | + | p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0 | |
| 176 | 192 | 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 | 206 | func opacity(c css.Color, a float64) css.Color { | |
| 180 | 207 | c.A *= math.Max(0, math.Min(1, a)) | |
| 181 | 208 | return c | |
| @@ -38,7 +38,17 @@ type Frame struct { | |||||
| 38 | Uploads []text.Upload | 38 | 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 with | 53 | // 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, nil | 192 | 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 c | 208 | return c | ||
M internal/render/prepare_test.go +149 -0
| @@ -90,6 +90,7 @@ func TestGlyphRasterAndAtlas(t *testing.T) { | |||
| 90 | 90 | if len(f.Quads) < 10 || len(f.Uploads) == 0 { | |
| 91 | 91 | t.Fatalf("scale %g: %d glyphs %d uploads", scale, len(f.Quads), len(f.Uploads)) | |
| 92 | 92 | } | |
| 93 | + | p.Submitted(f.Uploads) | |
| 93 | 94 | f, err = p.Prepare([]layout.Command{cmd}, scale, int(math.Ceil((measured.Width+12)*scale)), 100) | |
| 94 | 95 | if err != nil || len(f.Uploads) != 0 { | |
| 95 | 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 | 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 | + | } | |
| @@ -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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| @@ -99,15 +99,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption | |||
| 99 | 99 | switch input.Kind { | |
| 100 | 100 | case "edit-pointer", "edit-word": | |
| 101 | 101 | 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) | |
| 111 | 103 | if input.Kind == "edit-word" { | |
| 112 | 104 | start, end := editor.WordAt(place) | |
| 113 | 105 | editor.Select(start, end) | |
| @@ -119,7 +111,7 @@ func (n Node) editControl(typ, label string, value *string, options []EditOption | |||
| 119 | 111 | ev.changed = editor.Insert(input.Text) || ev.changed | |
| 120 | 112 | case "edit-key": | |
| 121 | 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 | 115 | } else { | |
| 124 | 116 | editor.PreferredXValid = false | |
| 125 | 117 | if input.Ctrl && input.Text == "v" && child.frame.owner != nil { | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 = false | 101 | editor.PreferredXValid = false | ||
| 102 | - | rendered := editor.Value | 102 | + | 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.changed | 111 | 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 = false | 116 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | vi "github.com/bnema/nefergui/internal/image" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| @@ -12,7 +12,7 @@ import ( | |||
| 12 | 12 | // glyphs. Snapshots alone once froze labelless controls; this invariant does not | |
| 13 | 13 | // depend on a golden file. | |
| 14 | 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 | 16 | if err != nil { | |
| 17 | 17 | t.Fatal(err) | |
| 18 | 18 | } | |
| @@ -75,7 +75,7 @@ func TestDeclaredTextIsPainted(t *testing.T) { | |||
| 75 | 75 | // Placeholders follow editor text geometry: text-align and RTL position, | |
| 76 | 76 | // letter-spacing, and textarea wrapping. | |
| 77 | 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 | 79 | if err != nil { | |
| 80 | 80 | t.Fatal(err) | |
| 81 | 81 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 not | 12 | // 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | // The golden is the serialized display list, not a rendered image; each case | |
| 18 | 18 | // still checks the intended pseudo-state/event before comparing exact JSON. | |
| 19 | 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 | 21 | if err != nil { | |
| 22 | 22 | t.Fatal(err) | |
| 23 | 23 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 case | 17 | // 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 @@ | |||
| 1 | 1 | //go:build !nefergui_debug | |
| 2 | 2 | ||
| 3 | - | package nefergui | |
| 3 | + | package ui | |
| 4 | 4 | ||
| 5 | 5 | const debugDiagnostics = false | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | //go:build !nefergui_debug | 1 | //go:build !nefergui_debug | ||
| 2 | 2 | ||||
| 3 | - | package nefergui | 3 | + | package ui |
| 4 | 4 | ||||
| 5 | const debugDiagnostics = false | 5 | const debugDiagnostics = false | ||
R debug_on.go → internal/ui/debug_on.go +1 -1 renamed, 77% similar
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | //go:build nefergui_debug | |
| 2 | 2 | ||
| 3 | - | package nefergui | |
| 3 | + | package ui | |
| 4 | 4 | ||
| 5 | 5 | const debugDiagnostics = true | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | //go:build nefergui_debug | 1 | //go:build nefergui_debug | ||
| 2 | 2 | ||||
| 3 | - | package nefergui | 3 | + | package ui |
| 4 | 4 | ||||
| 5 | const debugDiagnostics = true | 5 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 15 | if err != nil { | |
| 16 | 16 | t.Fatal(err) | |
| 17 | 17 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -9,8 +9,9 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | // moveVisualLine uses the last committed wrap geometry; preferred X survives | |
| 11 | 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) { | |
| 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 | |
| 14 | 15 | if len(lines) == 0 { | |
| 15 | 16 | return | |
| 16 | 17 | } | |
| @@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d | |||
| 22 | 23 | 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 | 29 | 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 | |
| 33 | 32 | } | |
| 34 | 33 | s.PreferredXValid = true | |
| 35 | 34 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 survives | 10 | // 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 | return | 16 | return | ||
| 16 | } | 17 | } | ||
| @@ -22,14 +23,12 @@ func moveVisualLine(s *edit.State, n *layout.Result, display []layout.Command, d | |||||
| 22 | break | 23 | 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 = true | 33 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 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 | 11 | if err != nil { | |
| 12 | 12 | t.Fatal(err) | |
| 13 | 13 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -43,38 +43,18 @@ func (r *runtime) paintEditors(root *element, out *layout.Output) { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | // 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. | |
| 48 | 48 | func editorDecoration(s *edit.State, n *layout.Result, display []layout.Command, password, focused bool) []layout.Command { | |
| 49 | 49 | if n == nil { | |
| 50 | 50 | return nil | |
| 51 | 51 | } | |
| 52 | - | value := s.Value | |
| 53 | 52 | 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 | |
| 72 | 55 | var result []layout.Command | |
| 73 | 56 | 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) | |
| 78 | 58 | for j := 0; j+1 < len(line); j += 2 { | |
| 79 | 59 | a, b := line[j], line[j+1] | |
| 80 | 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 | 76 | 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 { | |
| 109 | 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 { | |
| 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}) | |
| 120 | 85 | } | |
| 121 | 86 | } | |
| 122 | 87 | return result | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 nil | 50 | return nil | ||
| 51 | } | 51 | } | ||
| 52 | - | value := s.Value | |||
| 53 | start, end := s.Range() | 52 | start, end := s.Range() | ||
| 54 | - | cursor := s.Cursor | 53 | + | 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.Command | 55 | var result []layout.Command | ||
| 73 | for i, line := range lines { | 56 | for i, line := range lines { | ||
| 74 | - | h := 0.0 | 57 | + | 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 *textBoundary | 77 | + | // The caret uses the preceding character's trailing edge when an |
| 98 | - | // The trailing edge of the preceding character takes priority over the | 78 | + | // 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.H | 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}) |
| 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 result | 87 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 12 | if err != nil { | |
| 13 | 13 | t.Fatal(err) | |
| 14 | 14 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 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 | 13 | if err != nil { | |
| 14 | 14 | t.Fatal(err) | |
| 15 | 15 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -71,30 +71,11 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E | |||
| 71 | 71 | if s.Preedit == "" || n == nil || e.computed == nil { | |
| 72 | 72 | return nil | |
| 73 | 73 | } | |
| 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) | |
| 96 | 77 | x, y := n.Content.X, n.Content.Y | |
| 97 | - | if origin != nil { | |
| 78 | + | if found { | |
| 98 | 79 | x, y = origin.x, origin.y | |
| 99 | 80 | } | |
| 100 | 81 | st := e.computed.Style | |
| @@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E | |||
| 128 | 109 | clamped = b | |
| 129 | 110 | } | |
| 130 | 111 | 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) | |
| 143 | 115 | // The preedit cursor uses shaped cluster advances, not raw rune count. | |
| 144 | 116 | px := x | |
| 145 | 117 | pr := utf8.RuneCountInString(preedit[:pos]) | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 nil | 72 | return nil | ||
| 73 | } | 73 | } | ||
| 74 | - | value := s.Value | 74 | + | // The composition starts at the same caret position that is painted. |
| 75 | - | cursor := s.Cursor | 75 | + | 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.Y | 77 | x, y := n.Content.X, n.Content.Y | ||
| 97 | - | if origin != nil { | 78 | + | if found { |
| 98 | x, y = origin.x, origin.y | 79 | x, y = origin.x, origin.y | ||
| 99 | } | 80 | } | ||
| 100 | st := e.computed.Style | 81 | st := e.computed.Style | ||
| @@ -128,18 +109,9 @@ func preeditCommands(s *edit.State, n *layout.Result, e *element, engine *text.E | |||||
| 128 | clamped = b | 109 | clamped = b | ||
| 129 | } | 110 | } | ||
| 130 | pos = clamped | 111 | 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 uses | 113 | + | // 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 := x | 116 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| @@ -11,7 +11,7 @@ import ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 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 | 15 | if err != nil { | |
| 16 | 16 | t.Fatal(err) | |
| 17 | 17 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 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 | 12 | if err != nil { | |
| 13 | 13 | t.Fatal(err) | |
| 14 | 14 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -35,21 +35,9 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool { | |||
| 35 | 35 | if s == nil || n == nil { | |
| 36 | 36 | return false | |
| 37 | 37 | } | |
| 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 { | |
| 53 | 41 | return false | |
| 54 | 42 | } | |
| 55 | 43 | old := r.state.scroll[id] | |
| @@ -62,10 +50,7 @@ func (r *runtime) scrollCaret(root *element, out *layout.Output) bool { | |||
| 62 | 50 | } | |
| 63 | 51 | next.H = 0 | |
| 64 | 52 | } else { | |
| 65 | - | height := 0.0 | |
| 66 | - | if len(n.Lines) > 0 { | |
| 67 | - | height = n.Lines[0].Height | |
| 68 | - | } | |
| 53 | + | height := geometry.lineHeight(caret.line) | |
| 69 | 54 | if caret.y < n.Content.Y { | |
| 70 | 55 | next.H += caret.y - n.Content.Y | |
| 71 | 56 | } else if caret.y+height > n.Content.Y+n.Content.H { | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 false | 36 | return false | ||
| 37 | } | 37 | } | ||
| 38 | - | rendered := e.text | 38 | + | geometry := newEditorGeometry(s, n, out.Display, e.password) |
| 39 | - | cursor := s.Cursor | 39 | + | 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 false | 41 | 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 = 0 | 51 | next.H = 0 | ||
| 64 | } else { | 52 | } else { | ||
| 65 | - | height := 0.0 | 53 | + | 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.Y | 55 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "reflect" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/json" | |
| @@ -27,7 +27,7 @@ func shell(r *runtime, activated *int) func(*Frame) { | |||
| 27 | 27 | } | |
| 28 | 28 | func TestShellStyleAndFocus(t *testing.T) { | |
| 29 | 29 | r := newRuntime() | |
| 30 | - | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 30 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 31 | 31 | if err != nil { | |
| 32 | 32 | t.Fatal(err) | |
| 33 | 33 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import "github.com/bnema/nefergui/internal/css" | |
| 4 | 4 | ||
| @@ -9,21 +9,24 @@ type EditOption interface{ edit(*element) } | |||
| 9 | 9 | ||
| 10 | 10 | // ValueOption accepts CSS options and Disabled for pointer-backed controls. | |
| 11 | 11 | 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} }} | |
| 20 | 23 | } | |
| 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) }} | |
| 24 | 27 | } | |
| 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) }} | |
| 27 | 30 | } | |
| 28 | 31 | func applyContainer(e *element, opts []ContainerOption) { | |
| 29 | 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 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 } | |
| 37 | 41 | ||
| 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 } | |
| 41 | 45 | ||
| 42 | 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 | 49 | // HeadingOption accepts common CSS options and Level, but not control options. | |
| 46 | 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 | 54 | type headingLevel int | |
| 51 | 55 | ||
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 controls | 37 | + | // 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 int | 54 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| @@ -28,7 +28,7 @@ func demoFrame(f *Frame) { | |||
| 28 | 28 | ||
| 29 | 29 | func demoRuntime(tb testing.TB) *runtime { | |
| 30 | 30 | tb.Helper() | |
| 31 | - | catalog, err := text.Load(text.DirectorySource("testdata/fonts")) | |
| 31 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 32 | 32 | if err != nil { | |
| 33 | 33 | tb.Fatal(err) | |
| 34 | 34 | } | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "github.com/bnema/nefergui/internal/css" | |
| @@ -1,4 +1,4 @@ | |||||
| 1 | - | package nefergui | 1 | + | 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 | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| 4 | 6 | "fmt" | |
| @@ -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 | |||
| 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 | + | } | |||