zaniah 0.9.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/docs/adr/019-bidirectional-caret-affinity.md +35 -0
- data/docs/adr/020-lightweight-code-editor-providers.md +36 -0
- data/docs/adr/022-vertical-writing-and-ruby.md +37 -0
- data/docs/components.md +26 -5
- data/docs/text.md +81 -8
- data/docs/theme.md +7 -1
- data/docs/tui.md +2 -1
- data/examples/native_font.rb +22 -7
- data/lib/zaniah/input/keymap.rb +1 -0
- data/lib/zaniah/platform/windows/direct_write.rb +147 -0
- data/lib/zaniah/text.rb +150 -45
- data/lib/zaniah/text_system/font_db.rb +6 -1
- data/lib/zaniah/text_system/line_breaker.rb +23 -1
- data/lib/zaniah/text_system/line_layout.rb +66 -1
- data/lib/zaniah/text_system/paragraph.rb +172 -25
- data/lib/zaniah/text_system/renderer.rb +43 -12
- data/lib/zaniah/text_system/shaper.rb +77 -13
- data/lib/zaniah/text_system/typesetter.rb +125 -15
- data/lib/zaniah/theme.rb +35 -1
- data/lib/zaniah/ui/code_editor_surface.rb +185 -0
- data/lib/zaniah/ui/editors.rb +289 -34
- data/lib/zaniah/ui/rich_text_surface.rb +413 -51
- data/lib/zaniah/ui.rb +1 -0
- data/lib/zaniah/unicode/arabic_joining.rb +38 -0
- data/lib/zaniah/unicode/arabic_joining_data.rb +7 -0
- data/lib/zaniah/unicode/bidi.rb +343 -0
- data/lib/zaniah/unicode/bidi_data.rb +9 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/arabic_joining.rbs +8 -0
- data/sig/bidi.rbs +18 -0
- data/sig/elements.rbs +7 -3
- data/sig/platform.rbs +8 -0
- data/sig/shaper.rbs +1 -1
- data/sig/text.rbs +23 -11
- data/sig/theme.rbs +18 -0
- data/sig/ui.rbs +67 -4
- data/tools/generate_arabic_joining.rb +40 -0
- data/tools/generate_unicode_tables.rb +67 -0
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bad32e7fbe4f1abe64589fbc41d73f242c4ed3aeee9f760c67a4d205d0c8d8d7
|
|
4
|
+
data.tar.gz: b57324bacd30eeea83d04407f689e9ca47d79178d1f0228acf5396fa2ea27881
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5bbf0bebdad77e095cb6faba2a614cf5129558dd10629c94f2e6ad9966f9cb50dfe32a37b551569cdcfb1ba74085d4c64a3667740ff1699fa7b8ce03c553ad4c
|
|
7
|
+
data.tar.gz: 9d2491ddb19ce68065497f484b9bba0aa4e026fa998a31e3bca3d7bd41f0ed6edd805f0096aefd28ce2b1acf9309f0b96785cbbce88d67ebea10526094060a2f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.9.0 — 2026-09-24
|
|
4
11
|
|
|
5
12
|
- Add semantic vector recording for UI scenes, including text, images, and raster fallbacks for unsupported drawing.
|
|
@@ -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
|
|
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`,
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
48
|
-
|
|
49
|
-
and
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
162
|
-
`link
|
|
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
|
|
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/examples/native_font.rb
CHANGED
|
@@ -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("--
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
puts bitmap.
|
|
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
|
data/lib/zaniah/input/keymap.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Zaniah
|
|
|
23
23
|
primary = mac ? "cmd" : "ctrl"
|
|
24
24
|
map = new(**options)
|
|
25
25
|
.bind("tab", :focus_next)
|
|
26
|
+
.bind("tab", :insert_tab, context: "in_code_editor")
|
|
26
27
|
.bind("shift-tab", :focus_previous)
|
|
27
28
|
.bind("left", :focus_left, context: "!in_text_field && !in_list")
|
|
28
29
|
.bind("right", :focus_right, context: "!in_text_field && !in_list")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "alhena"
|
|
5
|
+
require_relative "../../ffi/com"
|
|
6
|
+
|
|
7
|
+
module Zaniah
|
|
8
|
+
module Platform
|
|
9
|
+
module Windows
|
|
10
|
+
# Rasterizes the same glyph IDs that the selected shaper produced. DirectWrite
|
|
11
|
+
# owns the font face and glyph cache; Zaniah still owns layout and its atlas.
|
|
12
|
+
class DirectWrite
|
|
13
|
+
P, I, F = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_FLOAT
|
|
14
|
+
FACTORY5_IID = [0x958db99a, 0xbe2a, 0x4f09].pack("L<S<S<") +
|
|
15
|
+
[0xaf, 0x7d, 0x65, 0x18, 0x98, 0x03, 0xd1, 0xd3].pack("C8")
|
|
16
|
+
MAX_PIXELS = 16_777_216
|
|
17
|
+
|
|
18
|
+
def initialize(font_db: nil)
|
|
19
|
+
raise Error, "DirectWrite is available only on Windows" unless RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
20
|
+
@font_db, @faces = font_db, {}.compare_by_identity
|
|
21
|
+
@library = FFI::Library.new("dwrite.dll")
|
|
22
|
+
output = pointer_out
|
|
23
|
+
FFI::COM.check(@library.fn(:DWriteCreateFactory, [I, P, P], I).call(0, FACTORY5_IID, output))
|
|
24
|
+
@factory = output.unpack1("J")
|
|
25
|
+
raise Error, "DirectWrite factory unavailable" if @factory.zero?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cache_key = "directwrite/#{RUBY_PLATFORM}/factory5"
|
|
29
|
+
|
|
30
|
+
def rasterize(font, glyph, size:, subpixel_x: 0, antialias: :grayscale, **_options)
|
|
31
|
+
raise IOError, "DirectWrite provider closed" unless @factory
|
|
32
|
+
raise ArgumentError, "invalid font size" unless size.is_a?(Numeric) && size.finite? && size.positive?
|
|
33
|
+
raise ArgumentError, "invalid glyph ID" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1) && glyph <= 0xffff
|
|
34
|
+
raise ArgumentError, "invalid subpixel position" unless subpixel_x.is_a?(Numeric) && subpixel_x.finite?
|
|
35
|
+
raise ArgumentError, "antialias must be grayscale or cleartype" unless %i[grayscale cleartype].include?(antialias)
|
|
36
|
+
# CreateFontFace selects a collection face, not a variable-font instance.
|
|
37
|
+
# Preserve requested axes until a DirectWrite axis-aware face path exists.
|
|
38
|
+
if font.respond_to?(:axis_values) && !font.axis_values.empty?
|
|
39
|
+
return font.rasterize(glyph, size: size, subpixel_x: subpixel_x,
|
|
40
|
+
lcd: antialias == :cleartype ? :rgb : nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
face = native_face(font)
|
|
44
|
+
index = [glyph].pack("S<")
|
|
45
|
+
advance = [0.0].pack("e")
|
|
46
|
+
run = [face, size.to_f, 1, Fiddle::Pointer[index].to_i,
|
|
47
|
+
Fiddle::Pointer[advance].to_i, 0, 0, 0].pack("Q<eL<Q<Q<Q<L<L<")
|
|
48
|
+
output = pointer_out
|
|
49
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 23, [P, F, P, I, I, F, F, P], I,
|
|
50
|
+
run, 1.0, 0, 4, 0, subpixel_x.to_f, 0.0, output))
|
|
51
|
+
analysis = output.unpack1("J")
|
|
52
|
+
bounds = "\0".b * 16
|
|
53
|
+
FFI::COM.check(FFI::COM.vcall(analysis, 3, [I, P], I, 1, bounds))
|
|
54
|
+
left, top, right, bottom = bounds.unpack("l<4")
|
|
55
|
+
width, height = right - left, bottom - top
|
|
56
|
+
return Alhena::Bitmap.new(width: 0, height: 0, coverage: "") if width.zero? || height.zero?
|
|
57
|
+
raise Error, "DirectWrite bitmap exceeds size limit" unless width.positive? && height.positive? && width * height <= MAX_PIXELS
|
|
58
|
+
rgb = "\0".b * (width * height * 3)
|
|
59
|
+
FFI::COM.check(FFI::COM.vcall(analysis, 4, [I, P, P, I], I, 1, bounds, rgb, rgb.bytesize))
|
|
60
|
+
coverage = if antialias == :cleartype
|
|
61
|
+
rgb
|
|
62
|
+
else
|
|
63
|
+
gray = "\0".b * (width * height)
|
|
64
|
+
gray.bytesize.times do |index|
|
|
65
|
+
offset = index * 3
|
|
66
|
+
gray.setbyte(index, (rgb.getbyte(offset) + rgb.getbyte(offset + 1) + rgb.getbyte(offset + 2) + 1) / 3)
|
|
67
|
+
end
|
|
68
|
+
gray
|
|
69
|
+
end
|
|
70
|
+
Alhena::Bitmap.new(width: width, height: height, left: left, top: -top,
|
|
71
|
+
channels: antialias == :cleartype ? 3 : 1, coverage: coverage)
|
|
72
|
+
ensure
|
|
73
|
+
FFI::COM.release(analysis) if analysis && !analysis.zero?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def close
|
|
77
|
+
return unless @factory
|
|
78
|
+
@faces.each_value { |face| FFI::COM.release(face) }
|
|
79
|
+
@faces.clear
|
|
80
|
+
if @memory_loader
|
|
81
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 14, [P], I, @memory_loader))
|
|
82
|
+
FFI::COM.release(@memory_loader)
|
|
83
|
+
end
|
|
84
|
+
FFI::COM.release(@factory)
|
|
85
|
+
@factory = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def pointer_out = [0].pack("J")
|
|
91
|
+
|
|
92
|
+
def native_face(font)
|
|
93
|
+
@faces[font] ||= begin
|
|
94
|
+
path = @font_db.path_for(font) if @font_db&.respond_to?(:path_for)
|
|
95
|
+
path = nil if path && (!File.file?(path) || File.binread(path) != font.data)
|
|
96
|
+
file = path ? file_from_path(path) : file_from_memory(font.data)
|
|
97
|
+
supported, _file_type, face_type, count = analyze(file)
|
|
98
|
+
raise Error, "DirectWrite cannot read font data" if supported.zero? || font.index >= count
|
|
99
|
+
output = pointer_out
|
|
100
|
+
files = [file].pack("J")
|
|
101
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 9, [I, I, P, I, I, P], I,
|
|
102
|
+
face_type, 1, files, font.index, 0, output))
|
|
103
|
+
output.unpack1("J")
|
|
104
|
+
ensure
|
|
105
|
+
FFI::COM.release(file) if file && !file.zero?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def file_from_path(path)
|
|
110
|
+
output = pointer_out
|
|
111
|
+
wide = path.encode("UTF-16LE").b + "\0\0".b
|
|
112
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 7, [P, P, P], I, wide, 0, output))
|
|
113
|
+
output.unpack1("J")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def file_from_memory(bytes)
|
|
117
|
+
raise Error, "DirectWrite font data exceeds size limit" if bytes.bytesize > 0xffffffff
|
|
118
|
+
unless @memory_loader
|
|
119
|
+
output = pointer_out
|
|
120
|
+
# Factory5 adds this method after 3 + 21 + 2 + 5 + 9 + 3 + 1 entries.
|
|
121
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 44, [P], I, output))
|
|
122
|
+
loader = output.unpack1("J")
|
|
123
|
+
begin
|
|
124
|
+
FFI::COM.check(FFI::COM.vcall(@factory, 13, [P], I, loader))
|
|
125
|
+
@memory_loader = loader
|
|
126
|
+
rescue StandardError
|
|
127
|
+
FFI::COM.release(loader) unless loader.zero?
|
|
128
|
+
raise
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
output = pointer_out
|
|
132
|
+
# A null owner asks DirectWrite to copy bytes, so no Ruby object is pinned.
|
|
133
|
+
FFI::COM.check(FFI::COM.vcall(@memory_loader, 4, [P, P, I, P, P], I,
|
|
134
|
+
@factory, bytes, bytes.bytesize, 0, output))
|
|
135
|
+
output.unpack1("J")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def analyze(file)
|
|
139
|
+
supported, file_type, face_type, count = Array.new(4) { [0].pack("L<") }
|
|
140
|
+
FFI::COM.check(FFI::COM.vcall(file, 5, [P, P, P, P], I,
|
|
141
|
+
supported, file_type, face_type, count))
|
|
142
|
+
[supported, file_type, face_type, count].map { |value| value.unpack1("L<") }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|