zaniah 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/docs/adr/005-instance-layout.md +4 -4
  4. data/docs/adr/016-semantic-vector-recording.md +33 -0
  5. data/docs/adr/017-analytic-shadows.md +31 -0
  6. data/docs/adr/018-gradient-ramps.md +30 -0
  7. data/docs/adr/019-bidirectional-caret-affinity.md +35 -0
  8. data/docs/adr/020-lightweight-code-editor-providers.md +36 -0
  9. data/docs/adr/022-vertical-writing-and-ruby.md +37 -0
  10. data/docs/components.md +26 -5
  11. data/docs/text.md +81 -8
  12. data/docs/theme.md +7 -1
  13. data/docs/tui.md +2 -1
  14. data/docs/vector.md +60 -0
  15. data/docs/vector_and_list.md +13 -4
  16. data/examples/metal_effect_check.rb +30 -0
  17. data/examples/native_font.rb +22 -7
  18. data/lib/zaniah/gpu/instance_packing.rb +2 -0
  19. data/lib/zaniah/gpu/metal.rb +27 -5
  20. data/lib/zaniah/gpu/open_gl.rb +29 -6
  21. data/lib/zaniah/gpu/software.rb +58 -6
  22. data/lib/zaniah/gpu/vulkan/scene.frag +26 -4
  23. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  24. data/lib/zaniah/gpu/vulkan/scene.vert +2 -0
  25. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  26. data/lib/zaniah/image.rb +2 -2
  27. data/lib/zaniah/input/keymap.rb +1 -0
  28. data/lib/zaniah/platform/headless/window.rb +1 -0
  29. data/lib/zaniah/platform/tui/window.rb +27 -0
  30. data/lib/zaniah/platform/windows/direct_write.rb +147 -0
  31. data/lib/zaniah/scene.rb +167 -27
  32. data/lib/zaniah/style/gradient.rb +6 -1
  33. data/lib/zaniah/svg/rasterizable.rb +54 -0
  34. data/lib/zaniah/svg.rb +157 -14
  35. data/lib/zaniah/text.rb +150 -45
  36. data/lib/zaniah/text_system/font_db.rb +6 -1
  37. data/lib/zaniah/text_system/line_breaker.rb +23 -1
  38. data/lib/zaniah/text_system/line_layout.rb +66 -1
  39. data/lib/zaniah/text_system/paragraph.rb +172 -25
  40. data/lib/zaniah/text_system/renderer.rb +73 -12
  41. data/lib/zaniah/text_system/shaper.rb +77 -13
  42. data/lib/zaniah/text_system/typesetter.rb +125 -15
  43. data/lib/zaniah/theme.rb +35 -1
  44. data/lib/zaniah/ui/code_editor_surface.rb +185 -0
  45. data/lib/zaniah/ui/editors.rb +289 -34
  46. data/lib/zaniah/ui/rich_text_surface.rb +413 -51
  47. data/lib/zaniah/ui.rb +1 -0
  48. data/lib/zaniah/unicode/arabic_joining.rb +38 -0
  49. data/lib/zaniah/unicode/arabic_joining_data.rb +7 -0
  50. data/lib/zaniah/unicode/bidi.rb +343 -0
  51. data/lib/zaniah/unicode/bidi_data.rb +9 -0
  52. data/lib/zaniah/vector.rb +63 -0
  53. data/lib/zaniah/version.rb +1 -1
  54. data/lib/zaniah.rb +1 -0
  55. data/sig/arabic_joining.rbs +8 -0
  56. data/sig/bidi.rbs +18 -0
  57. data/sig/elements.rbs +7 -3
  58. data/sig/platform.rbs +8 -0
  59. data/sig/scene.rbs +9 -1
  60. data/sig/shaper.rbs +1 -1
  61. data/sig/text.rbs +23 -11
  62. data/sig/theme.rbs +18 -0
  63. data/sig/ui.rbs +67 -4
  64. data/sig/vector.rbs +116 -0
  65. data/tools/generate_arabic_joining.rb +40 -0
  66. data/tools/generate_unicode_tables.rb +67 -0
  67. metadata +21 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e4185eb60f475f07fb3deee4bf07f0e7e16240771ee9199f9783d3fe13130cb
4
- data.tar.gz: 05cafb090e57d8552cda0826d8db0ff1fa980107eab05ab9841824d6a18187c6
3
+ metadata.gz: bad32e7fbe4f1abe64589fbc41d73f242c4ed3aeee9f760c67a4d205d0c8d8d7
4
+ data.tar.gz: b57324bacd30eeea83d04407f689e9ca47d79178d1f0228acf5396fa2ea27881
5
5
  SHA512:
6
- metadata.gz: e5da9a69b48d186dcb04cf71cf48922d9e72fd019ff54919451f028cc211262fd8c49ecf85eb6dbbfd8b48213ad324ab98aefc9079473ace7d8a289401079ae4
7
- data.tar.gz: f2b7ada32c16d932143db6cf68a9c2073e085ef0cda762aa7ed2f07ae7fdee5b9d5b1f5c3a5289b5fc54930fab9dbef1c0720e8035656c8ac700a2904012f431
6
+ metadata.gz: 5bbf0bebdad77e095cb6faba2a614cf5129558dd10629c94f2e6ad9966f9cb50dfe32a37b551569cdcfb1ba74085d4c64a3667740ff1699fa7b8ce03c553ad4c
7
+ data.tar.gz: 9d2491ddb19ce68065497f484b9bba0aa4e026fa998a31e3bca3d7bd41f0ed6edd805f0096aefd28ce2b1acf9309f0b96785cbbce88d67ebea10526094060a2f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0 — 2026-09-24
4
+
5
+ - Add Unicode bidirectional text layout, caret affinity, and Arabic joining.
6
+ - Add vertical writing, ruby annotations, inline embeds, and richer text decoration.
7
+ - Add a viewport-based code editor with optional buffer and syntax-highlighter adapters.
8
+ - Use DirectWrite shaping on Windows while retaining portable text fallback.
9
+
10
+ ## 0.9.0 — 2026-09-24
11
+
12
+ - Add semantic vector recording for UI scenes, including text, images, and raster fallbacks for unsupported drawing.
13
+ - Render shadows analytically across backends; their edge appearance changes from earlier releases.
14
+ - Add shared multi-stop and conic gradient ramps across Software, OpenGL, Metal, and Vulkan rendering.
15
+ - Support SVG gradients, dashed strokes, and alpha and luminance masks.
16
+
3
17
  ## 0.8.0 — 2026-09-24
4
18
 
5
19
  - Add native macOS and Windows menus, declarative context menus, and platform-aware shortcut labels.
@@ -26,10 +26,10 @@ fields for source UVs. Triangle vertices reuse rect and radii fields.
26
26
  | 32–37 | affine transform (`a,b,c,d,tx,ty`) | affine transform |
27
27
  | 38–39 | reserved shadow spread / inset or border flags | unused |
28
28
 
29
- Opacity is multiplied into both fill and border alpha before packing. Linear
30
- and radial gradients use exactly two stops; a future multi-stop implementation
31
- may use a 1D texture without another stride change. Shadows are expanded into
32
- ordinary quad instances, so the reserved shadow fields remain available.
29
+ Opacity is multiplied into both fill and border alpha before packing. The
30
+ original two-stop and expanded-quad behavior has since been extended without
31
+ changing this layout: see [ADR 017](017-analytic-shadows.md) for the shadow
32
+ fields and [ADR 018](018-gradient-ramps.md) for multistop ramps and conic fills.
33
33
 
34
34
  Metal, OpenGL, and Vulkan consume the layout directly, Software consumes the
35
35
  equivalent 40-float quad layout, and cached text sprite batches use the same
@@ -0,0 +1,33 @@
1
+ # ADR 016: Record semantic drawing alongside the GPU scene
2
+
3
+ - Status: Proposed
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The GPU scene contains packed quads and atlas sprites. Paths have already been
9
+ rasterized and text has lost its font, glyph IDs, and source clusters by the
10
+ time a renderer consumes those commands. Exporters need this information to
11
+ produce searchable text and scalable artwork. Reconstructing it from packed
12
+ GPU instances is impossible; maintaining a second application-specific layout
13
+ for export makes screen and export drift.
14
+
15
+ Two viable boundaries were considered: make the GPU scene itself semantic, or
16
+ keep its fast packed representation and optionally observe the drawing calls.
17
+
18
+ ## Decision
19
+
20
+ Keep the GPU scene unchanged and attach an optional vector sink to `Scene`.
21
+ Emit semantic commands before paths and text are flattened. When a source has
22
+ no supported vector form, preserve its pixels as a raster command instead.
23
+ The sink is absent by default, so normal frames do not allocate vector data.
24
+ Page assembly and document formats remain outside Zaniah.
25
+
26
+ ## Consequences
27
+
28
+ Exporters can reuse the same element tree and retain font and outline data
29
+ without adding a dependency on a PDF library. The optional path incurs a
30
+ nil check when unused and stores additional frame data when enabled. Raster
31
+ fallbacks keep output complete but cannot scale as cleanly as true vectors.
32
+ This choice should be revisited if packed GPU commands become a lossless
33
+ semantic representation or a common display list can serve both consumers.
@@ -0,0 +1,31 @@
1
+ # ADR 017: Draw each shadow as one analytic instance
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The previous shadow approximation layered eight rounded quads. It produced
9
+ visible bands and made a scene with 100 shadows submit 800 instances.
10
+
11
+ ## Decision
12
+
13
+ Keep the 40-float instance layout from ADR 005. Primitive kind `4` in field 31
14
+ is a shadow; field 30 is blur sigma, field 38 is spread, and field 39 is the
15
+ inset flag. Bounds 0–3 are expanded by `spread + 3 * blur + 1` for outer
16
+ shadows. Inset shadows retain the original bounds. Corner radii occupy 12–15.
17
+
18
+ Software and all three GPU fragment shaders evaluate the signed distance to
19
+ the original rounded rectangle. A Gaussian edge is approximated by
20
+ `0.5 - 0.5 * erf((distance - spread) / (sqrt(2) * blur))`, using the same bounded
21
+ exponential approximation of `erf` in every backend. Zero blur uses direct
22
+ coverage. Inset shadows multiply interior coverage by the inverted transition.
23
+ The existing clipping, transforms, and alpha blending remain unchanged.
24
+
25
+ ## Consequences
26
+
27
+ Shadow appearance intentionally changes; the golden images and changelog must
28
+ record this. `bench/shadow.rb` compares 100 shadows against the old eight-quad
29
+ approximation and requires the analytic Software path to be faster. GPU native
30
+ capture tests remain platform-dependent; `script/compile_shaders` regenerates
31
+ the bundled Vulkan SPIR-V from the checked-in GLSL.
@@ -0,0 +1,30 @@
1
+ # ADR 018: Use bounded ramps for multistop gradients
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The shared instance format carries only two inline stop colors. Extending its
9
+ stride would affect every renderer and text/sprite packing.
10
+
11
+ ## Decision
12
+
13
+ Two-stop linear and radial gradients retain their inline color path. Two-stop
14
+ conic gradients use kind `3` and the same interpolation path. Three or more
15
+ stops use kinds `4` (linear), `5` (radial), or `6` (conic), and one row of a
16
+ 256-by-256 RGBA8 atlas baked from the ordered stops. Field 8 stores the row
17
+ index; all renderers sample that row at the calculated gradient position. A
18
+ `Scene` caches rows by immutable `Gradient` value in an LRU capped at 256
19
+ entries. Rows used in the current frame cannot be evicted; if a frame exceeds
20
+ 256 distinct ramps, it uses reusable overflow atlases for the remainder of
21
+ that frame. Normal quads continue to use packed-byte fast paths; only ramp
22
+ quads need texture-aware batching.
23
+
24
+ ## Consequences
25
+
26
+ No instance layout migration is required. Sampling precision is limited to
27
+ 256 positions. Adjacent ramps on the shared atlas batch together, while
28
+ overflows can add batches. SVG gradient fills use the same ordered-stop
29
+ interpolation semantics and fall back to a raster snapshot in vector recording
30
+ because a `Vector::Path` currently stores solid fills only.
@@ -0,0 +1,35 @@
1
+ # ADR 019: Bidirectional caret affinity
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ UTF-8 byte offsets identify logical text positions, but a boundary between
9
+ left-to-right and right-to-left runs can appear at two visual positions. A
10
+ single `offset → x` mapping therefore loses information needed for pointer
11
+ selection and arrow-key movement. Keeping logical movement only would avoid
12
+ this ambiguity but would make arrow keys travel opposite to their visual
13
+ direction inside right-to-left text. Storing display-order text instead would
14
+ break editing, copying, and external byte-offset contracts.
15
+
16
+ ## Decision
17
+
18
+ Keep text and selection offsets in logical UTF-8 byte order. A rendered line
19
+ may associate an offset with upstream and downstream visual caret positions.
20
+ Pointer hit testing returns both offset and affinity; existing offset-only
21
+ methods remain as compatibility shortcuts. Arrow keys move through visual
22
+ carets by default, while `caret_movement: :logical` preserves storage-order
23
+ movement for applications such as code editors.
24
+
25
+ Selection is a logical byte range rendered as a set of visual rectangles.
26
+ The Unicode Bidirectional Algorithm resolves the paragraph before line-local
27
+ reordering; neither glyph painting nor hit testing changes stored text order.
28
+
29
+ ## Consequences
30
+
31
+ The editing path must retain affinity separately from the selection offset,
32
+ and callers that want an exact caret position must use the affinity-aware
33
+ methods. Existing LTR offset and caret APIs keep their values. This decision
34
+ can be revisited if a platform-specific native text editor is adopted as the
35
+ storage and interaction model instead of the toolkit's own text buffer.
@@ -0,0 +1,36 @@
1
+ # ADR 020: Lightweight code editor provider boundaries
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ The original CodeEditor passed an entire document through one Text element and
9
+ constructed a second Text element containing every line number. Both scale with
10
+ the whole file, and the two elements disagree about row heights when code
11
+ wraps. Applications may already have a rope buffer or incremental syntax
12
+ highlighter; bundling either implementation here would duplicate their work.
13
+
14
+ ## Decision
15
+
16
+ CodeEditor accepts a line-addressable buffer with `line_count`, `line(index)`,
17
+ `line_start(index)`, `line_of(offset)`, `replace(range, text)`, `undo`, and
18
+ `redo`. The default adapter wraps TextBuffer and indexes UTF-8 byte starts.
19
+ `to_s` is optional for external providers. A highlighter supplies
20
+ `tokens(line_index, text)` as byte ranges and scopes, and receives
21
+ `edited(range, new_text)` after edits. Token scopes resolve through
22
+ `theme.syntax`; the editor owns no parser or language grammar.
23
+
24
+ The dedicated Surface uses List::HeightIndex to shape and paint only visible
25
+ logical lines. A wrapped logical line owns all its display rows, so its line
26
+ number is painted once at the first display row. The Surface owns one caret,
27
+ one selection, IME candidate placement, standard text actions, and Tab
28
+ indentation. The existing positional constructor remains accepted.
29
+
30
+ ## Consequences
31
+
32
+ Buffer adapters must keep byte offsets stable and return UTF-8 lines. A
33
+ highlighter can cache tokens incrementally, but may only paint within the
34
+ current viewport. Multi-cursor editing, folding, LSP, and a minimap remain
35
+ application concerns. Unknown row heights use an estimate until those rows
36
+ are visited, so scrollbar position can refine as wrapped lines are measured.
@@ -0,0 +1,37 @@
1
+ # ADR 022: Vertical writing and ruby clusters
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ Japanese documents need top-to-bottom lines, columns that progress right to
9
+ left, and annotations adjacent to their parent text. Rotating an entire
10
+ horizontal line would give incorrect caret, selection, wrapping, and ruby
11
+ geometry. Inserting ruby readings into the text buffer would also corrupt
12
+ logical UTF-8 offsets and copied text.
13
+
14
+ ## Decision
15
+
16
+ `Paragraph`, `Text`, and `UI::RichText` accept `writing_mode: :vertical_rl`.
17
+ The inline limit is measured top to bottom, and layout converts inline and
18
+ cross axes to physical coordinates for painting and interaction. The built-in
19
+ shaper uses OpenType `vmtx` advances and GSUB `vert`/`vrt2` when a font supplies
20
+ them. Mixed orientation keeps CJK upright and rotates ordinary Latin glyphs;
21
+ `:upright` suppresses that rotation. A RichText run can request
22
+ `combine_upright: true` for a compact horizontal run inside a vertical line.
23
+
24
+ Ruby is a style on a parent run, not buffer content. The run is an unbreakable
25
+ editing cluster. Its annotation is centered above the parent in horizontal
26
+ text or to its right in vertical text. Both parent and annotation contribute
27
+ to inline/cross box size, but only the parent contributes to copied text.
28
+ Accessibility and TUI readings include the annotation in parentheses.
29
+
30
+ ## Consequences
31
+
32
+ Caret, hit testing, selection, and IME rectangles share the axis conversion
33
+ used by painting; stored offsets remain logical UTF-8 byte offsets. Ruby and
34
+ combine-upright cannot be combined on one run. The implementation does not
35
+ claim full CSS Writing Modes or OpenType vertical typography: `VORG`, vertical
36
+ GPOS kerning/mark positioning, and font-specific vertical baseline correction
37
+ remain unsupported. Fonts without vertical tables use horizontal advances.
data/docs/components.md CHANGED
@@ -94,8 +94,8 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
94
94
  | L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
95
95
  | L5 | `FormField` | `(name, value:, label:, control:, validation:, hint:)` | errors + describedby | group/control/alert |
96
96
  | L5 | `Form` | `field`, `on_change`, `on_submit`, `values`, `valid?` | validates before submit | form |
97
- | L5 | `CodeEditor` | `(value, language:, line_numbers:, read_only:)`; `on_change` | multiline editor with scrolling | textbox |
98
- | L5 | `RichText` | `(runs, selectable:, editable:)`; `apply`, `insert`, `delete`, `replace`, `paragraph_style` | styled editing, IME, range selection and caret | text/textbox |
97
+ | L5 | `CodeEditor` | `(value, buffer:, highlighter:, wrap:, language:, line_numbers:, read_only:)`; `on_change` | viewport-only multiline editor with syntax scopes and Tab indentation | textbox |
98
+ | L5 | `RichText` | `(runs, selectable:, editable:, writing_mode:, text_orientation:)`; `apply`, `insert`, `delete`, `replace`, `append`, `insert_embed`, `paragraph_style` | styled editing, inline embeds, ruby, vertical text, IME, range selection and caret | text/textbox |
99
99
 
100
100
  All input components are keyboard operable. Disabled controls remain visible but are
101
101
  removed from focus traversal. Overlay components close on Esc; modal overlays restore
@@ -111,9 +111,30 @@ It may also implement `refine(previous_matches, query)` for incremental queries.
111
111
  unavailable actions, and displays shortcuts with `UI::Kbd`. See [Menus](menus.md).
112
112
 
113
113
  `UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
114
- `bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
115
- center, end, and non-final-line justification, plus bullet/ordered lists and levels.
116
- It is read-only by default; set `editable: true` to enable keyboard editing and IME.
114
+ `bold`, `italic`, `size`, `color`, `font`, `link`, `underline` (`:single`, `:double`,
115
+ `:wavy`), `underline_color`, `strikethrough`, `background`, `baseline`
116
+ (`:superscript` or `:subscript`), `letter_spacing`, `ruby: "reading"`, and
117
+ `combine_upright: true`. Ruby parent text is one unbreakable selection cluster;
118
+ copying omits the annotation, while accessibility and TUI expose it in
119
+ parentheses. Ruby and combine-upright cannot be set together on one run.
120
+ `writing_mode: :vertical_rl` uses top-to-bottom lines and right-to-left columns;
121
+ `text_orientation: :mixed` rotates ordinary Latin by default, whereas `:upright`
122
+ keeps it upright. Paragraph styles include
123
+ alignment, lists, levels, `indent`, `quote`, `background`, and before/after spacing.
124
+ `insert_embed(offset, key:, width:, height:) { |cx| element }` stores U+FFFC in the
125
+ text and lays the element out at that inline position. `append(text, style:)` reuses
126
+ cached layouts for unchanged earlier fragments. RichText is read-only by default;
127
+ set `editable: true` to enable keyboard editing and IME.
128
+
129
+ `UI::CodeEditor` keeps the legacy positional constructor and also accepts a
130
+ line-addressable `buffer:` with `line_count`, `line(index)`, `line_start(index)`,
131
+ `line_of(offset)`, `replace(range, text)`, `undo`, and `redo`. A plain string or
132
+ `TextBuffer` uses the bundled adapter. Optional `highlighter:` provides
133
+ `tokens(line_index, text)` byte ranges with scopes and receives
134
+ `edited(range, new_text)` notifications. Scopes use `theme.syntax` colors. Only
135
+ visible logical lines are shaped; wrapped display rows share one line number.
136
+ The editor supports one caret/selection, standard text actions, IME placement,
137
+ and Tab indentation. See [ADR 020](adr/020-lightweight-code-editor-providers.md).
117
138
 
118
139
  ## Image decoding
119
140
 
data/docs/text.md CHANGED
@@ -38,18 +38,43 @@ Provider objects may be supplied instead of symbols:
38
38
  | `segmenter` | `grapheme_clusters(text)` | Strings that exactly partition the input |
39
39
  | `font_raster` | `rasterize(font, glyph_id, size:, subpixel_x:)` | A glyph bitmap |
40
40
 
41
+ Shapers may additionally accept `script:`, `language:`, `direction:`,
42
+ `features:`, and `writing_mode:`. The typesetter inspects accepted keywords, so an existing
43
+ `shape(glyphs, size:, text:)` provider remains valid. If a provider does not
44
+ accept `direction:`, a right-to-left run is shaped in storage order and its
45
+ glyphs are reversed for display. Hebrew works with this fallback; Arabic
46
+ joining in a legacy provider still requires a direction-aware shaper. The built-in shaper
47
+ accepts `direction: :ltr/:rtl` and a four-character OpenType feature map such
48
+ as `{"liga" => false}`.
49
+
41
50
  Providers may implement `close`; the text system closes them when it closes.
42
51
  Custom mutable providers used by `fork` must also implement `layout_copy` and
43
52
  return an independent provider.
44
53
 
45
54
  The default Ruby shaper supports horizontal Latin, CJK, and kana text, including
46
55
  common OpenType ligatures, contextual substitutions, pair positioning, and
47
- legacy kerning. It is not a replacement for full complex-script shaping:
48
- Arabic/Indic reordering, AAT `morx`, vertical text, complete mark positioning,
49
- and variation-index positioning are unsupported.
50
-
51
- Built-in rasterizers are `:native`/`:alhena`, `:freetype`, and `:coretext`
52
- (`:core_text` is an alias). FreeType and CoreText require their platform library.
56
+ legacy kerning. For Arabic-script OpenType fonts with GSUB, it selects
57
+ `isol`/`fina`/`medi`/`init` from Unicode 18 Joining_Type and then applies
58
+ `rlig`; transparent marks and ZWJ/ZWNJ affect joining. The input `text:` is
59
+ needed to select forms. Fonts using AAT `morx` (including some macOS Arabic
60
+ fonts), advanced Arabic substitutions, complete mark and cursive positioning,
61
+ Indic reordering, complete vertical GPOS positioning, and variation-index
62
+ positioning still need an external shaper. The bundled Abel font has no Arabic
63
+ glyphs. Vertical layout uses `vmtx` advances and GSUB `vert`/`vrt2` where
64
+ available; fonts without vertical metrics retain horizontal advances.
65
+
66
+ Built-in rasterizers are `:native`/`:alhena`, `:freetype`, `:coretext`
67
+ (`:core_text` is an alias), and Windows-only `:directwrite` (`:direct_write`
68
+ is an alias). FreeType, CoreText, and DirectWrite require their platform library.
69
+ DirectWrite remains opt-in; Windows still defaults to `:native`. It loads fonts
70
+ opened by `FontDB` from their file paths, and other in-memory fonts through the
71
+ Windows 10 in-memory font loader. Its default atlas bitmap is grayscale;
72
+ `Platform::Windows::DirectWrite#rasterize(..., antialias: :cleartype)` returns
73
+ an RGB coverage bitmap for callers that can display subpixel coverage. Compare
74
+ it with Alhena using `ruby examples/native_font.rb path/to/font.ttf --directwrite`
75
+ on Windows. Variable-font instances with selected axes use Alhena's rasterizer
76
+ until an axis-aware DirectWrite face is available. Close an explicitly created
77
+ DirectWrite provider when finished.
53
78
  Unimplemented symbolic adapters raise `ArgumentError`; pass a provider object to
54
79
  integrate another shaper, segmenter, font database, or rasterizer.
55
80
 
@@ -121,6 +146,41 @@ offset = paragraph.hit_test(Zaniah::Point.new(40, 20))
121
146
  point = paragraph.offset_to_point(offset)
122
147
  ```
123
148
 
149
+ Bidirectional text uses Unicode 18.0's UAX #9 rules, with Unicode tables
150
+ generated by `tools/generate_unicode_tables.rb` and no runtime data download.
151
+ `Text.new(value, text_direction: :auto)` detects paragraph direction; use
152
+ `:ltr` or `:rtl` to override it. Layout-only callers may pass `direction:`
153
+ to `layout_paragraph` or `layout_line`. `align: :start/:end` follows paragraph
154
+ direction. Text remains in logical UTF-8 byte order for editing and copying.
155
+
156
+ At a boundary between directional runs, the same byte offset can have two
157
+ screen positions. `LineLayout#caret_x(offset, affinity: :downstream)` chooses
158
+ one, and `LineLayout#hit_test(x)` returns `[offset, affinity]`. The old
159
+ `x_for_index` and `index_for_x` methods remain available for callers that do
160
+ not retain affinity. `selection_rects(range)` returns the visual rectangles
161
+ occupied by a logical range. `Text.new(..., caret_movement: :logical)` opts
162
+ out of the default visual left/right arrow movement.
163
+
164
+ Vertical Japanese layout is opt-in. The `width:` argument to a vertical
165
+ paragraph is its inline (top-to-bottom) limit; columns advance right to left.
166
+ On `Text`, use `.h(...)` for that limit:
167
+
168
+ ```ruby
169
+ paragraph = system.layout_paragraph("縦書き", width: 240,
170
+ writing_mode: :vertical_rl)
171
+ text = Zaniah::Text.new("縦書き", writing_mode: :vertical_rl,
172
+ text_orientation: :mixed).h(240)
173
+ ```
174
+
175
+ `text_orientation: :mixed` keeps CJK glyphs upright and rotates ordinary Latin
176
+ letters and digits 90 degrees. `:upright` keeps all glyphs upright. Hit testing,
177
+ caret, selection, composition underline, and IME bounds use vertical geometry;
178
+ offsets and clipboard text stay in logical UTF-8 byte order. This is a
179
+ lightweight vertical layout, not a complete CSS Writing Modes implementation:
180
+ it does not implement `VORG`, vertical kerning/mark positioning, script-specific
181
+ vertical baseline adjustment, or arbitrary sideways writing modes. Font support
182
+ determines whether vertical glyph substitutions and metrics are available.
183
+
124
184
  Wrapping supports `:none`, `:word`, and `:anywhere`; Japanese kinsoku modes are
125
185
  `:push`, `:hanging`, and `:none`. Font fallback is selected per grapheme from the
126
186
  requested font through CJK, emoji, symbol, and general system faces. Colored
@@ -158,8 +218,21 @@ text.insert(text.text.bytesize, " — draft")
158
218
  text.paragraph_style(0...text.text.bytesize, align: :start, list: :bullet, level: 0)
159
219
  ```
160
220
 
161
- Supported inline styles are `bold`, `italic`, `size`, `color`, `font`, and
162
- `link`. Paragraph styles support `align: :start/:center/:end/:justify`,
221
+ Supported inline styles include `bold`, `italic`, `size`, `color`, `font`,
222
+ `link`, decoration/background styles, `ruby:`, and `combine_upright:`.
223
+ `ruby: "reading"` annotates one unbreakable parent run; copying selects only
224
+ the parent text, while accessibility and TUI readings include the annotation
225
+ in parentheses. `combine_upright: true` keeps a short run such as `12` upright
226
+ inside vertical text. Ruby and combine-upright cannot be combined on one run.
227
+
228
+ ```ruby
229
+ rich = Zaniah::UI::RichText.new([
230
+ {text: "漢字", ruby: "かんじ"},
231
+ {text: "12", combine_upright: true}
232
+ ], writing_mode: :vertical_rl).h(240)
233
+ ```
234
+
235
+ Paragraph styles support `align: :start/:center/:end/:justify`,
163
236
  `list: :none/:bullet/:ordered`, and nonnegative nesting levels. `on_change`
164
237
  receives `(text, rich_text)`; selection changes are reported by `on_select`.
165
238
  IME composition uses the existing `TextBuffer` composition path.
data/docs/theme.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Themes and state styles
2
2
 
3
3
  `Zaniah::Theme.dark`, `.light`, and `.high_contrast` provide semantic color,
4
- spacing, radius, shadow, typography, and motion tokens. `App` installs the dark
4
+ spacing, radius, shadow, typography, syntax, and motion tokens. `App` installs the dark
5
5
  theme by default and follows native window appearance changes. Read the active
6
6
  theme with `FrameContext#theme` or `EntityContext#theme`.
7
7
 
@@ -32,6 +32,9 @@ themes' raw color values.
32
32
  - `spacing`: a compact 4-pixel scale indexed by token number.
33
33
  - `radii` and `shadows`: semantic `none`, `sm`, `md`, `lg`, and `full` values.
34
34
  - `typography`: sans/mono families, six sizes, four weights, and three line heights.
35
+ - `syntax`: keyword, string, comment, number, function, type, constant,
36
+ punctuation, operator, variable, and fallback text colors. `syntax.color(scope)`
37
+ accepts dotted highlighter scopes such as `"keyword.control"`.
35
38
  - `motion`: fast/base/slow durations, easing names, and `reduced?`.
36
39
 
37
40
  Records support `with`, so a local override does not mutate the shared theme:
@@ -45,6 +48,9 @@ quiet = base.with(
45
48
  app.global(:theme, quiet)
46
49
  ```
47
50
 
51
+ Omitting `syntax:` in `Theme.new` derives readable syntax colors from `colors`.
52
+ Pass a `Theme::Syntax` value to override them explicitly.
53
+
48
54
  Native windows select dark or light initially and refresh the application theme
49
55
  when system appearance changes. A system reduced-motion preference sets
50
56
  `theme.motion.reduced?` and collapses animation durations to zero.
data/docs/tui.md CHANGED
@@ -29,7 +29,8 @@ tests and non-window integrations.
29
29
  | TreeView | indentation with `▸`/`▾` expansion markers |
30
30
  | Sparkline and all chart types | eight-level Unicode sparkline summary |
31
31
  | Form, FormField | labeled values, `!` errors, and a submit button |
32
- | CodeEditor, RichText | numbered plain-text lines or concatenated text runs |
32
+ | CodeEditor | numbered logical lines; wrap, syntax colors, and inline IME underline degrade to plain cells |
33
+ | RichText | concatenated text runs; ruby readings appear in parentheses; inline embeds appear as U+FFFC and decoration/paragraph backgrounds and vertical geometry degrade to plain cells |
33
34
 
34
35
  Rounded corners, shadows, gradients, and alpha scrims reduce to solid cells. Focus is
35
36
  reported by the terminal cursor/reverse-video capability; motion introduced in M7 is
data/docs/vector.md ADDED
@@ -0,0 +1,60 @@
1
+ # Vector recording
2
+
3
+ Zaniah records the drawing it already performs without taking ownership of PDF
4
+ pages, paper sizes, or export dialogs. Require `zaniah/vector` and render a normal
5
+ element tree offscreen:
6
+
7
+ ```ruby
8
+ document = Zaniah::Vector.record(width: 960, height: 540,
9
+ theme: Zaniah::Theme.light) { slide }
10
+ document.commands.each { |command| export_command(command) }
11
+ ```
12
+
13
+ To inspect a displayed frame, attach a recorder before rendering. `Scene#clear`
14
+ resets its commands each frame; save `recorder.document` before the next frame.
15
+
16
+ ```ruby
17
+ recorder = Zaniah::Vector::Recorder.new
18
+ window.scene.vector_sink = recorder
19
+ window.tick
20
+ document = recorder.document
21
+ window.scene.vector_sink = nil
22
+ ```
23
+
24
+ `Document#width` and `#height` are logical pixels. A `Recorder` attached to a
25
+ window receives that window's content size; a recorder attached to a bare
26
+ `Scene` may instead be initialized with `width:` and `height:`. Coordinates use
27
+ a top-left origin and downward y axis; colors are sRGB. Every command carries
28
+ its transform, clip, opacity, layer, and sequence. `Document#commands` is sorted
29
+ by layer and then sequence, matching `Scene#each_command`.
30
+
31
+ | Command | Export information |
32
+ |---|---|
33
+ | `Quad` | Bounds, fill color or gradient, corner radii, border widths/color/style |
34
+ | `Shadow` | Bounds, corner radii, color, blur, spread, inset |
35
+ | `Path` | Alhena outline, fill, stroke, stroke width, fill rule and stroke cap/join |
36
+ | `GlyphRun` | Alhena font, size, `[glyph_id, x, baseline_y]` triples, original text and UTF-8 byte clusters |
37
+ | `Image` | Decoded `Zaniah::Image`, bounds and source rectangle, plus a frozen pixel snapshot |
38
+ | `Underline` | Position, thickness and wave flag |
39
+ | `Raster` | Frozen source texture bytes, pixel dimensions/format, tint and source rectangle |
40
+
41
+ Ordinary sprites and packed sprite batches become `Raster`; bitmap-color text
42
+ uses that fallback for the whole line. Simple SVG fills and strokes become
43
+ `Path` commands. SVG clipping, gradients, dash arrays, masks, group opacity,
44
+ and references use a `Raster`
45
+ fallback, so they are not silently omitted. The `Raster` bytes are a snapshot
46
+ at recording time, even if the source texture changes later. The converter must
47
+ crop to `source`, apply `color` and `opacity`, then respect `transform` and
48
+ `clip`. It may rasterize a `Path` or `GlyphRun` when its output format lacks
49
+ an equivalent vector primitive.
50
+
51
+ The headless offscreen API works on every OS and does not require a GPU.
52
+ Attaching a recorder to macOS, Windows, X11, or Wayland uses the same `Scene`
53
+ path. The TUI backend captures its terminal cells as a single `Raster` fallback
54
+ at an 8-by-20 cell grid, using a bundled font approximation; terminal font
55
+ appearance is outside Zaniah's control. Use the offscreen API when scalable
56
+ text is required.
57
+
58
+ See [ADR 016](adr/016-semantic-vector-recording.md) for why this is separate
59
+ from the GPU command stream. [Vector and list](vector_and_list.md) documents
60
+ the SVG input subset.
@@ -13,15 +13,24 @@ icon = Zaniah::SVG.parse(
13
13
  ```
14
14
 
15
15
  The renderer supports SVG paths, basic shapes, groups, transforms, view boxes,
16
- inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`, and
17
- user-space clip paths. It is intended for static icons, not arbitrary web SVG.
16
+ inherited fill and stroke, `currentColor`, opacity, local `defs`/`use`,
17
+ user-space clip paths, linear/radial gradients, stroke dash arrays and offsets,
18
+ and local alpha/luminance masks. It is intended for static icons, not arbitrary
19
+ web SVG.
18
20
 
19
- Scripts, external references, CSS stylesheets, gradients, filters, masks, images,
20
- text, markers, dash arrays, nested viewports, and object-bounding-box clips are
21
+ Scripts, external references, CSS stylesheets, filters, images,
22
+ text, markers, nested viewports, and object-bounding-box clips are
21
23
  unsupported and raise `ArgumentError`. Input is bounded to 2 MiB, 10,000 XML
22
24
  nodes, 64 levels, and 100,000 path operations; each output texture is limited to
23
25
  one megapixel.
24
26
 
27
+ SVG gradients support ordered stops, local references, `objectBoundingBox` and
28
+ `userSpaceOnUse` coordinates, and the default pad spread. Other spread methods
29
+ and external references remain unsupported. Masks rasterize within the same
30
+ one-megapixel output limit. Gradient, dashed, and masked shapes are represented
31
+ as a `Vector::Raster` fallback when recording; simple solid outlines remain
32
+ `Vector::Path` commands.
33
+
25
34
  ## Variable-height lists
26
35
 
27
36
  ```ruby
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/zaniah"
4
+ abort "This check needs macOS and a graphical login" unless RUBY_PLATFORM.include?("darwin")
5
+
6
+ window = Zaniah::Platform.open_window(backend: :mac, gpu: :metal, width: 96, height: 72,
7
+ title: "Metal effect check")
8
+ begin
9
+ scene = Zaniah::Scene.new
10
+ scene.quad(4, 4, 30, 24, color: Zaniah::Gradient.linear(angle: 0,
11
+ stops: [[0, "#f00"], [0.5, "#0f0"], [1, "#00f"]]))
12
+ scene.quad(38, 4, 30, 24, color: Zaniah::Gradient.conic(stops: [[0, "#f00"], [1, "#00f"]]))
13
+ scene.shadow(28, 40, 32, 18, color: "#000", blur: 3, spread: 1, radius: 4)
14
+ expected = Zaniah::GPU::Software.new(96, 72).render(scene, clear: "#fff")
15
+ window.device.render(scene, clear: "#fff")
16
+ actual = window.device.pixels
17
+ scale = window.scale_factor
18
+ pixel_width = (96 * scale).round
19
+ samples = [[6, 15], [18, 15], [31, 15], [60, 16], [22, 49], [28, 49], [44, 49], [75, 49]]
20
+ samples.each do |x, y|
21
+ index = (y * 96 + x) * 4
22
+ native_index = (((y + 0.5) * scale).floor * pixel_width + ((x + 0.5) * scale).floor) * 4
23
+ left, right = expected.byteslice(index, 4).bytes, actual.byteslice(native_index, 4).bytes
24
+ raise "effect mismatch at #{x},#{y}: #{right.inspect} != #{left.inspect}" unless
25
+ left.zip(right).all? { |a, b| (a - b).abs <= 8 }
26
+ end
27
+ puts "Metal analytic shadow and gradient check: #{samples.length} samples passed"
28
+ ensure
29
+ window.close
30
+ end
@@ -4,12 +4,27 @@ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
4
  require "zaniah"
5
5
  require "alhena"
6
6
 
7
- provider_name = ARGV.delete("--freetype") ? :freetype : :coretext
8
- require "zaniah/platform/#{provider_name == :freetype ? 'linux/free_type' : 'mac/core_text'}"
9
- provider = provider_name == :freetype ? Zaniah::Platform::Linux::FreeType.new : Zaniah::Platform::Mac::CoreText.new
10
- font = Alhena::Font.open(ARGV.fetch(0))
11
- bitmap = provider.rasterize(font, font.glyph_id("A"), size: 32)
7
+ provider_name = if ARGV.delete("--directwrite") || RUBY_PLATFORM.match?(/mswin|mingw/)
8
+ :directwrite
9
+ elsif ARGV.delete("--freetype") || !RUBY_PLATFORM.include?("darwin")
10
+ :freetype
11
+ else
12
+ :coretext
13
+ end
14
+ require "zaniah/platform/#{ {directwrite: 'windows/direct_write', freetype: 'linux/free_type', coretext: 'mac/core_text'}.fetch(provider_name) }"
15
+ path = File.expand_path(ARGV.fetch(0))
16
+ db = Zaniah::TextSystem::FontDB.new(paths: [path])
17
+ font = db.open(path)
18
+ provider = case provider_name
19
+ when :directwrite then Zaniah::Platform::Windows::DirectWrite.new(font_db: db)
20
+ when :freetype then Zaniah::Platform::Linux::FreeType.new
21
+ else Zaniah::Platform::Mac::CoreText.new
22
+ end
23
+ glyph = font.glyph_id("A".ord)
24
+ bitmap = provider.rasterize(font, glyph, size: 32)
12
25
  raise "empty native font bitmap" unless bitmap.width.positive? && bitmap.height.positive? && bitmap.coverage.bytes.any?(&:positive?)
13
- puts "#{provider_name}: #{bitmap.width}x#{bitmap.height}, bearing=#{bitmap.left},#{bitmap.top}"
14
- puts bitmap.to_ascii
26
+ reference = font.rasterize(glyph, size: 32)
27
+ puts "Alhena #{reference.width}x#{reference.height}, bearing=#{reference.left},#{reference.top} | #{provider_name} #{bitmap.width}x#{bitmap.height}, bearing=#{bitmap.left},#{bitmap.top}"
28
+ left, right = reference.to_ascii.lines, bitmap.to_ascii.lines
29
+ [left.length, right.length].max.times { |index| puts "#{left[index].to_s.chomp.ljust(34)} | #{right[index]}" }
15
30
  provider.close
@@ -17,6 +17,7 @@ module Zaniah
17
17
  case kind
18
18
  when :quad
19
19
  q, i = scene.quads, offset
20
+ texture = scene.quad_texture(offset)
20
21
  data.push(q[i], q[i+1], q[i+2], q[i+3], q[i+4], q[i+5], q[i+6], q[i+7],
21
22
  q[i+8], q[i+9], q[i+10], q[i+11], q[i+12], q[i+13], q[i+14], q[i+15],
22
23
  q[i+16], q[i+17], q[i+18], q[i+19], q[i+20], q[i+21], q[i+22], q[i+23],
@@ -64,6 +65,7 @@ module Zaniah
64
65
  expected = 0
65
66
  scene.each_command do |kind, offset, clip|
66
67
  return unless kind == :quad && offset == expected
68
+ return if scene.quad_texture(offset)
67
69
  if batches.empty? || batches.last[0][2] != clip
68
70
  batches << [[:quad, nil, clip], expected / STRIDE, 1]
69
71
  else