zaniah 0.9.0 → 0.11.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/docs/adr/019-bidirectional-caret-affinity.md +35 -0
  4. data/docs/adr/020-lightweight-code-editor-providers.md +36 -0
  5. data/docs/adr/021-dock-layout-tree.md +33 -0
  6. data/docs/adr/022-vertical-writing-and-ruby.md +37 -0
  7. data/docs/components.md +56 -5
  8. data/docs/gallery/components-dark.png +0 -0
  9. data/docs/gallery/components-high_contrast.png +0 -0
  10. data/docs/gallery/components-light.png +0 -0
  11. data/docs/native.md +35 -0
  12. data/docs/text.md +81 -8
  13. data/docs/theme.md +7 -1
  14. data/docs/tui.md +9 -1
  15. data/examples/gallery.rb +32 -6
  16. data/examples/native_font.rb +22 -7
  17. data/lib/zaniah/clipboard.rb +23 -0
  18. data/lib/zaniah/element.rb +47 -2
  19. data/lib/zaniah/input/data_drop.rb +25 -0
  20. data/lib/zaniah/input/dispatcher.rb +1 -0
  21. data/lib/zaniah/input/keymap.rb +22 -0
  22. data/lib/zaniah/input/magnify.rb +7 -0
  23. data/lib/zaniah/input.rb +2 -0
  24. data/lib/zaniah/platform/headless/window.rb +70 -2
  25. data/lib/zaniah/platform/linux/wayland_window.rb +81 -11
  26. data/lib/zaniah/platform/linux/window.rb +158 -15
  27. data/lib/zaniah/platform/mac/window.rb +118 -11
  28. data/lib/zaniah/platform/windows/direct_write.rb +147 -0
  29. data/lib/zaniah/platform/windows/drag_drop.rb +273 -0
  30. data/lib/zaniah/platform/windows/window.rb +21 -2
  31. data/lib/zaniah/text.rb +150 -45
  32. data/lib/zaniah/text_system/font_db.rb +6 -1
  33. data/lib/zaniah/text_system/line_breaker.rb +23 -1
  34. data/lib/zaniah/text_system/line_layout.rb +66 -1
  35. data/lib/zaniah/text_system/paragraph.rb +172 -25
  36. data/lib/zaniah/text_system/renderer.rb +43 -12
  37. data/lib/zaniah/text_system/shaper.rb +77 -13
  38. data/lib/zaniah/text_system/typesetter.rb +125 -15
  39. data/lib/zaniah/theme.rb +35 -1
  40. data/lib/zaniah/ui/calendar.rb +215 -0
  41. data/lib/zaniah/ui/code_editor_surface.rb +185 -0
  42. data/lib/zaniah/ui/dock_layout.rb +102 -0
  43. data/lib/zaniah/ui/dock_workspace.rb +271 -0
  44. data/lib/zaniah/ui/editors.rb +289 -34
  45. data/lib/zaniah/ui/feedback.rb +122 -0
  46. data/lib/zaniah/ui/hover_card.rb +95 -0
  47. data/lib/zaniah/ui/property_grid.rb +154 -0
  48. data/lib/zaniah/ui/rich_text_surface.rb +413 -51
  49. data/lib/zaniah/ui/zoom_pan_view.rb +106 -0
  50. data/lib/zaniah/ui.rb +8 -0
  51. data/lib/zaniah/unicode/arabic_joining.rb +38 -0
  52. data/lib/zaniah/unicode/arabic_joining_data.rb +7 -0
  53. data/lib/zaniah/unicode/bidi.rb +343 -0
  54. data/lib/zaniah/unicode/bidi_data.rb +9 -0
  55. data/lib/zaniah/version.rb +1 -1
  56. data/lib/zaniah.rb +1 -0
  57. data/sig/arabic_joining.rbs +8 -0
  58. data/sig/bidi.rbs +18 -0
  59. data/sig/clipboard.rbs +20 -0
  60. data/sig/elements.rbs +12 -3
  61. data/sig/input.rbs +28 -4
  62. data/sig/platform.rbs +8 -0
  63. data/sig/shaper.rbs +1 -1
  64. data/sig/text.rbs +23 -11
  65. data/sig/theme.rbs +18 -0
  66. data/sig/ui.rbs +150 -4
  67. data/tools/generate_arabic_joining.rb +40 -0
  68. data/tools/generate_component_gallery.rb +2 -1
  69. data/tools/generate_unicode_tables.rb +67 -0
  70. metadata +25 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc06ae1720a8d05abbc0da78849a355f7c36565c62b3907f941c80dd066594c5
4
- data.tar.gz: fd00f6d9bd08c097a5ffa35ce079a83f949c07874d6ff7c76cdc802f7ddd8129
3
+ metadata.gz: 3a6d77e1edea911ee42064986e375f163213f45bedaeda9c6f3e409b42914cbd
4
+ data.tar.gz: 21970a583f79a01502abf982811820361a7628dc0ffffca0a2f308fa4852a6b7
5
5
  SHA512:
6
- metadata.gz: 769bfa73b348d773f2139eb34039a7250f6878986375bdd25c361c3922b16d61ab68a3d14c59f8ef7f3cfd68e04a43d868286b624d1996d2fc2afdb92b23f861
7
- data.tar.gz: a9642fa580bd9595e15c48903e698b95dfc5a6f876ca0d71438e488899522e2855351cef18a124e005dae5636e53b500aa46e90e101575c12796fc4e9ae80992
6
+ metadata.gz: 83d536f11608784994079181c05524fb3935cddf6076728967b78fc54b48ae91889c387efc047159f767dac8743ae5cb89f7c3497f75afa7b733133f6bc7e982
7
+ data.tar.gz: caa3b692cbe5435abc9571b0ccc3a09dc46db71c022a0ab2f80903dab7336c3818c3ae05ecc3ee6a0aa8ba657fb45bfa61a5218ec236546f229ba9d80614ace2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-09-25
4
+
5
+ - Add segmented controls, alerts, hover cards, calendar and date-range inputs.
6
+ - Add zoomable views, dockable workspaces, and editable property grids.
7
+ - Add typed drag-and-drop across desktop backends with explicit move completion.
8
+
9
+ ## 0.10.0 — 2026-09-24
10
+
11
+ - Add Unicode bidirectional text layout, caret affinity, and Arabic joining.
12
+ - Add vertical writing, ruby annotations, inline embeds, and richer text decoration.
13
+ - Add a viewport-based code editor with optional buffer and syntax-highlighter adapters.
14
+ - Use DirectWrite shaping on Windows while retaining portable text fallback.
15
+
3
16
  ## 0.9.0 — 2026-09-24
4
17
 
5
18
  - 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,33 @@
1
+ # ADR 021: Represent dock layouts as a validated tree
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ An application needs to save tabbed, split workspaces and restore them across
9
+ sessions. Dragging a tab changes both its group and potentially the split tree.
10
+ A fixed five-region dock cannot represent nested splits or stable panel identity.
11
+
12
+ ## Decision
13
+
14
+ `UI::DockLayout` stores tab groups and binary splits with stable string IDs.
15
+ `to_h` and `from_h` provide a plain-data round trip; parsing rejects duplicate
16
+ IDs, empty groups, invalid active tabs, invalid orientations, and out-of-range
17
+ ratios. `UI::DockWorkspace` derives visible panes from this model and reuses
18
+ `UI::SplitPane` for dividers. Layout changes produce a new validated model.
19
+ The application owns persistence and handles `on_detach` notifications; the
20
+ component does not create windows.
21
+
22
+ ## Alternatives considered
23
+
24
+ - A flat list of panels plus coordinates would make nested resizing and
25
+ restoration ambiguous.
26
+ - Extending `DockPanel` would change its simple five-region contract.
27
+ - Making detach automatically create windows would assume application policy.
28
+
29
+ ## Consequences
30
+
31
+ Persisted layouts can be validated independently of rendering. Empty groups
32
+ are pruned after a move. Applications can choose their own storage format and
33
+ window ownership; they must keep panel IDs stable across releases.
@@ -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
@@ -32,6 +32,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
32
32
  | L0 | `Card` | `(*children)`, `child` | theme surface | group |
33
33
  | L0 | `Badge` | `(text, variant:)` | neutral/accent/success/warning/danger | text |
34
34
  | L0 | `Kbd` | `(keys, platform:)`; `.for(action, keymap:)` | OS shortcut notation, including multi-stroke bindings | text |
35
+ | L0 | `Alert` | `(title, message:, variant:, action:, dismissible:, live:)`; `dismiss` | info/success/warning/danger; persistent until dismissed | alert when live, otherwise status |
35
36
  | L0 | `Avatar` | `(name, image:, size:)` | initials or PNG | image |
36
37
  | L0 | `Skeleton` | `(width:, height:)` | pulsing loading placeholder | progressbar/busy |
37
38
  | L0 | `EmptyState` | `(title, message:, icon:, action:)` | compositional | group |
@@ -42,6 +43,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
42
43
  | L1 | `Checkbox` | `(label, value:, disabled:)`; `on_change` | true/false/mixed | checkbox |
43
44
  | L1 | `Radio` | `(label, value:, disabled:)`; `on_change` | selected/unselected | radio |
44
45
  | L1 | `RadioGroup` | `(options, value:)`; `on_change` | one selected value | radiogroup |
46
+ | L1 | `SegmentedControl` | `(options, value:)`; `on_change` | mutually exclusive segments; arrows/Home/End | radiogroup/radio |
45
47
  | L1 | `Switch` | `(label, value:, disabled:)`; `on_change` | on/off | switch |
46
48
  | L1 | `Slider` | `(value:, min:, max:, step:, label:)`; `on_change` | pointer + arrow/Home/End/Page keys | slider |
47
49
  | L1 | `RangeSlider` | `(value: [low, high], ...)` | two thumbs | slider |
@@ -50,6 +52,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
50
52
  | L1 | `Meter` | `(value:, low:, high:, optimum:)` | thresholds | meter |
51
53
  | L2 | `Tooltip` | `(text, anchor:, side:, open:)` | top/bottom/left/right | tooltip |
52
54
  | L2 | `Popover` | `(content, anchor:, side:, width:, height:, open:, modal:)` | flipped and viewport-clamped | group |
55
+ | L2 | `HoverCard` | `(content, anchor:, open_delay:, close_delay:)` | hover or focus opens after a delay; non-modal | dialog |
53
56
  | L2 | `ContextMenu`, `Menu` | `(items, anchor:, open:)` | pointer + arrows/Home/End/Enter/Esc | menu/menuitem |
54
57
  | L2 | `MenuBar` | `(menus)` or `.from(app.menu_bar)` | declarative menu model or legacy pairs | menubar |
55
58
  | L2 | `Dropdown` | `(label, items:, value:)`; `on_change` | menu-backed | button |
@@ -63,6 +66,8 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
63
66
  | L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:, matcher:)`; `on_change` | editable, filtered choices with highlighted matches | combobox |
64
67
  | L2 | `MultiSelect` | `(items, value:, label:, disabled:)`; `on_change` | multiple selected badges | listbox |
65
68
  | L2 | `DatePicker` | `(value, min:, max:, label:, disabled:)`; `on_change` | ISO date, day/week keyboard steps | combobox |
69
+ | L2 | `Calendar` | `(value:, min:, max:, range:, week_start:, month_names:)`; `on_change` | arrow keys move days/weeks, Page keys move months, Enter selects | grid/gridcell |
70
+ | L2 | `DateRangePicker` | `(value:, min:, max:, week_start:, month_names:)`; `on_change` | Calendar-backed start/end selection | combobox |
66
71
  | L2 | `TimePicker` | `(value, step:, label:, disabled:)`; `on_change` | 24-hour time, minute/hour keyboard steps | combobox |
67
72
  | L2 | `ColorPicker` | `(value, label:, swatches:, disabled:)`; `on_change` | hex input and swatches | combobox |
68
73
  | L3 | `Tabs` | `(items, selected:)`; `on_change` | arrows/Home/End | tab/tabpanel |
@@ -82,11 +87,14 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
82
87
  | L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
83
88
  | L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
84
89
  | L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
90
+ | L3 | `ZoomPanView` | `(content, zoom:, min_zoom:, max_zoom:)`; `fit`, `zoom_to`, `view_to_content` | drag to pan, pinch or Ctrl+wheel to zoom, `+`/`-`/`0` when focused | group |
85
91
  | L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
92
+ | L3 | `DockWorkspace` | `(layout, render:)`; `on_layout_change`, `on_detach`; `DockLayout.to_h`/`.from_h` | drag tabs to move/split, arrows select, Alt+arrows move, Ctrl+Shift+arrows split, Ctrl+Shift+D requests detach | tablist/tab/tabpanel/separator |
86
93
  | L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
87
94
  | L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit`, `on_copy`, `on_paste` | virtual rows, sorting, resizing, editing, typed clipboard hooks | table/row/cell |
88
95
  | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize`, `on_copy`, `on_paste` | two-axis virtualization, frozen panes, visible-cell resize/fill and typed clipboard hooks | table |
89
96
  | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
97
+ | L4 | `PropertyGrid` | `(schema, values, height:, row_height:)`; `on_change`, `set` | typed existing controls, `Validation`, virtual rows | table/row/cell |
90
98
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
91
99
  | L5 | `LineChart`, `BarChart`, `StackedBarChart`, `AreaChart` | `(series, width:, height:, colors:, label:)`; `AreaChart(stacked:)` | shared axes, ticks, grid lines, color-keyed legend, tooltip | image |
92
100
  | L5 | `PieChart`, `DonutChart` | `(data, width:, height:, colors:, label:)` | slices, shared color-keyed legend, tooltip | image |
@@ -94,13 +102,35 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
94
102
  | L5 | `Validation` | `required`, `format`, `length`, `number`, `rule` | composable rules | n/a |
95
103
  | L5 | `FormField` | `(name, value:, label:, control:, validation:, hint:)` | errors + describedby | group/control/alert |
96
104
  | 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 |
105
+ | L5 | `CodeEditor` | `(value, buffer:, highlighter:, wrap:, language:, line_numbers:, read_only:)`; `on_change` | viewport-only multiline editor with syntax scopes and Tab indentation | textbox |
106
+ | 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
107
 
100
108
  All input components are keyboard operable. Disabled controls remain visible but are
101
109
  removed from focus traversal. Overlay components close on Esc; modal overlays restore
102
110
  the previous focus. See [TUI](tui.md) for terminal representations.
103
111
 
112
+ `Calendar` and `DateRangePicker` use Ruby's `Date` and ISO 8601 strings. Pass
113
+ `week_start: 0..6` (Sunday is 0) and twelve `month_names:` to localize the grid
114
+ without an i18n dependency. A range picker returns a two-element array of dates;
115
+ its second value is `nil` while the user is choosing the end. `HoverCard` accepts
116
+ an element/component as its anchor to open on pointer hover or keyboard focus, or
117
+ a `Point`/`Bounds` for positioned use. Its delays follow the window's injected clock.
118
+
119
+ `ZoomPanView` uses content-local coordinates for `zoom_to` and `view_to_content`.
120
+ Call `fit` after the first layout; it scales the content into the current viewport.
121
+ Native macOS pinch emits `Input::Magnify`, while Ctrl+wheel provides a desktop fallback.
122
+
123
+ `DockLayout` is a validated tree of tab groups and horizontal/vertical splits with
124
+ stable string IDs. Use `DockLayout.tabs` and `.split` to build it, and persist
125
+ `layout.to_h` in the application; `DockLayout.from_h` restores it. `DockWorkspace`
126
+ passes the active panel ID to `render:` and emits a new layout on tab changes.
127
+ `on_detach` is a notification only; the application decides whether to open a
128
+ window and remove the panel. A pointer drop in the outer 20% of a group splits it.
129
+ `PropertyGrid` schema entries use `key`, optional `label`, `type` (`text`,
130
+ `textarea`, `number`, `boolean`, `select`, `color`, `date`, `time`), `options` for
131
+ select, and an optional `Validation`. Invalid edits remain in the control while
132
+ the last valid value is retained. Both components expose terminal fallbacks.
133
+
104
134
  `Combobox` and `CommandPalette` default to case-insensitive substring matching in
105
135
  input order. Pass `matcher:` to either component, or set a default with
106
136
  `Zaniah.configure { |config| config.matcher = matcher }`. A matcher implements
@@ -111,9 +141,30 @@ It may also implement `refine(previous_matches, query)` for incremental queries.
111
141
  unavailable actions, and displays shortcuts with `UI::Kbd`. See [Menus](menus.md).
112
142
 
113
143
  `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.
144
+ `bold`, `italic`, `size`, `color`, `font`, `link`, `underline` (`:single`, `:double`,
145
+ `:wavy`), `underline_color`, `strikethrough`, `background`, `baseline`
146
+ (`:superscript` or `:subscript`), `letter_spacing`, `ruby: "reading"`, and
147
+ `combine_upright: true`. Ruby parent text is one unbreakable selection cluster;
148
+ copying omits the annotation, while accessibility and TUI expose it in
149
+ parentheses. Ruby and combine-upright cannot be set together on one run.
150
+ `writing_mode: :vertical_rl` uses top-to-bottom lines and right-to-left columns;
151
+ `text_orientation: :mixed` rotates ordinary Latin by default, whereas `:upright`
152
+ keeps it upright. Paragraph styles include
153
+ alignment, lists, levels, `indent`, `quote`, `background`, and before/after spacing.
154
+ `insert_embed(offset, key:, width:, height:) { |cx| element }` stores U+FFFC in the
155
+ text and lays the element out at that inline position. `append(text, style:)` reuses
156
+ cached layouts for unchanged earlier fragments. RichText is read-only by default;
157
+ set `editable: true` to enable keyboard editing and IME.
158
+
159
+ `UI::CodeEditor` keeps the legacy positional constructor and also accepts a
160
+ line-addressable `buffer:` with `line_count`, `line(index)`, `line_start(index)`,
161
+ `line_of(offset)`, `replace(range, text)`, `undo`, and `redo`. A plain string or
162
+ `TextBuffer` uses the bundled adapter. Optional `highlighter:` provides
163
+ `tokens(line_index, text)` byte ranges with scopes and receives
164
+ `edited(range, new_text)` notifications. Scopes use `theme.syntax` colors. Only
165
+ visible logical lines are shaped; wrapped display rows share one line number.
166
+ The editor supports one caret/selection, standard text actions, IME placement,
167
+ and Tab indentation. See [ADR 020](adr/020-lightweight-code-editor-providers.md).
117
168
 
118
169
  ## Image decoding
119
170
 
Binary file
Binary file
data/docs/native.md CHANGED
@@ -70,6 +70,41 @@ applications. macOS also preserves arbitrary MIME types under reversible
70
70
  Wayland use their native format mechanisms. Native clipboard exchange still
71
71
  depends on a running desktop session and should be checked on each target OS.
72
72
 
73
+ ## Typed drag and drop
74
+
75
+ An element can declare the MIME types it accepts and optionally offer a drag
76
+ source. `on_drag_over` returns `:copy`, `:move`, `:link`, or `:none`; the chosen
77
+ operation must be offered by the source. A drop carries `Clipboard::Content`,
78
+ its logical window position, and the operation. File drops also emit the older
79
+ `Input::FileDrop` event so existing listeners continue to work.
80
+ Offering `:move` requires `on_move:` to remove the source data; it runs once
81
+ after a successful move, or when an X11 target acknowledges the `DELETE` selection.
82
+
83
+ ```ruby
84
+ target.on_drop(types: ["image/png", "text/plain"]) do |drop, _cx|
85
+ insert(drop.content)
86
+ end
87
+ target.on_drag_over { |drag, _cx| drag.accepts?("image/png") ? :copy : :none }
88
+ source.draggable do |_mouse, _cx|
89
+ Zaniah::DragData.new(items: [Zaniah::Clipboard::Item.new("text/plain" => "hello")], operations: [:copy])
90
+ end
91
+ ```
92
+
93
+ Headless records the source in `drag_history`; tests may call `drag_over` and
94
+ `complete_drag` to deliver it deterministically. macOS uses AppKit dragging
95
+ sessions; X11 uses XDND (Ctrl requests copy, Shift move, Ctrl+Shift link when
96
+ offered); Wayland uses `wl_data_device`. A target needs `on_drop` and a matching
97
+ MIME type before it can accept a drag. Wayland cannot offer
98
+ link-only drags because its standard data-device action set has only copy and
99
+ move. All desktop protocols depend on another application accepting the MIME
100
+ type and operation. Windows currently preserves file drops through
101
+ `WM_DROPFILES` compatibility and also uses OLE `IDataObject`/`IDropTarget` and
102
+ `IDropSource` for typed data and native drag sources. A preview image is used
103
+ on macOS; Windows, X11, and Wayland currently use the native default cursor
104
+ without a custom preview. TUI has no cross-process drag protocol and only the
105
+ in-process Headless test path.
106
+ X11 XDND proxy windows are not supported.
107
+
73
108
  ## Window state and decorations
74
109
 
75
110
  `window.frame` uses logical screen coordinates; `window.state.to_h` can be saved
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
@@ -8,28 +8,36 @@ tests and non-window integrations.
8
8
  | --- | --- |
9
9
  | Label, Badge, Avatar | text, `[badge]`, `(initials)` |
10
10
  | Kbd | textual shortcut, such as `Ctrl+Shift+P`; multiple strokes are space-separated |
11
+ | Alert | persistent `! title: message`; dismissed alerts disappear, live alerts announce through accessibility |
11
12
  | Icon | Unicode check/close/search/menu/info/warning symbol; unlabeled unknown icons are omitted |
12
13
  | Divider, Card | `─`/`│` and box-drawing characters |
13
14
  | Skeleton, Spinner, ProgressBar, Meter | shade cells, `◌`, or a ten-cell progress track; animation jumps to its terminal state |
14
15
  | Button, IconButton, ToggleButton | `[ label ]`; disabled uses `( label )` |
15
16
  | Checkbox, Radio, Switch | `[x]`, `(o)`, `[on ]` |
17
+ | SegmentedControl | `[A\|(B)\|C]`, with the selected segment in parentheses; arrows move selection |
16
18
  | Slider, RangeSlider | ten-cell track and numeric value/range |
17
19
  | Text inputs | `[value]`; password values are masked and validation errors have `!` |
18
20
  | Select, Combobox, MultiSelect | labeled brackets, filtered menu marker, or comma-separated selected values |
19
21
  | DatePicker, TimePicker, ColorPicker | labeled ISO date, 24-hour time, or hex color in brackets |
22
+ | Calendar, DateRangePicker | month grid with selected day in `[dd]`; picker shows `[start – end]`, with `…` for an unfinished range |
20
23
  | Tooltip, Popover, Menu, MenuBar, Dropdown | status text or a box/menu with `>` selection marker; nested menus expose a Back row |
24
+ | HoverCard | content text while open; hidden while closed; focus uses the same open delay as hover |
21
25
  | Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
22
26
  | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
23
27
  | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
24
28
  | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; palette filters results and supports Up/Down/Enter; focus stays inside until dismissed |
25
29
  | SplitPane, PaneGrid, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
30
+ | ZoomPanView | zoom percentage and content text; focused `+`/`-`/`0` changes zoom, but pointer panning has no cell equivalent |
31
+ | DockWorkspace | active tab in parentheses inside `[tabs]`, split groups separated by `│` or `───`; arrows select, Alt+arrows move and Ctrl+Shift+arrows split without dragging |
26
32
  | ListView | visible rows with `>` on the selected item |
27
33
  | Toast | live status text |
28
34
  | Table, DataGrid | header and visible rows separated with `|` |
29
35
  | TreeView | indentation with `▸`/`▾` expansion markers |
36
+ | PropertyGrid | up to 20 `label: value` rows, with `!` and the validation message on errors; Up/Down/Page keys move the virtual row selection |
30
37
  | Sparkline and all chart types | eight-level Unicode sparkline summary |
31
38
  | Form, FormField | labeled values, `!` errors, and a submit button |
32
- | CodeEditor, RichText | numbered plain-text lines or concatenated text runs |
39
+ | CodeEditor | numbered logical lines; wrap, syntax colors, and inline IME underline degrade to plain cells |
40
+ | 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
41
 
34
42
  Rounded corners, shadows, gradients, and alpha scrims reduce to solid cells. Focus is
35
43
  reported by the terminal cursor/reverse-video capability; motion introduced in M7 is
data/examples/gallery.rb CHANGED
@@ -57,19 +57,43 @@ module Gallery
57
57
  .child(Zaniah::Div.new.flex_row.gap(12).children([
58
58
  Zaniah::UI::DatePicker.new("2026-09-13"), Zaniah::UI::TimePicker.new("14:30"),
59
59
  Zaniah::UI::ColorPicker.new("#2563eb")]))
60
+ feedback_dates = Zaniah::Div.new.w_full.gap(12)
61
+ .child(Zaniah::Div.new.flex_row.items_center.gap(16).children([
62
+ Zaniah::UI::SegmentedControl.new([["Day", :day], ["Week", :week], ["Month", :month]], value: :week),
63
+ Zaniah::UI::DateRangePicker.new(value: ["2026-09-01", "2026-09-30"])]))
64
+ .child(Zaniah::UI::Alert.new("Export complete", message: "Your file is ready", variant: :success, dismissible: true))
65
+ .child(Zaniah::UI::Calendar.new(value: "2026-09-24", week_start: 1))
66
+ dock_layout = Zaniah::UI::DockLayout.split(id: :work, orientation: :horizontal, ratio: 0.42,
67
+ first: Zaniah::UI::DockLayout.tabs(id: :left, panels: %i[Files Search]),
68
+ second: Zaniah::UI::DockLayout.tabs(id: :right, panels: %i[Editor Preview]))
69
+ property_schema = [
70
+ {key: :title, label: "Title", type: :text},
71
+ {key: :count, label: "Count", type: :number},
72
+ {key: :visible, label: "Visible", type: :boolean}
73
+ ]
60
74
  workspaces = Zaniah::Div.new.w_full.gap(12)
61
75
  .child(Zaniah::Div.new.flex_row.gap(12).children([
62
76
  Zaniah::UI::SplitPane.new(Zaniah::UI::Label.new("Left pane"), Zaniah::UI::Label.new("Right pane")).w(300).h(100),
63
77
  Zaniah::UI::Resizable.new(Zaniah::UI::Label.new("Resize me"), width: 180, height: 100),
64
78
  Zaniah::UI::ListView.new(%w[Alpha Beta Gamma Delta], height: 100, selected: 1).w(180)]))
65
- .child(Zaniah::UI::DockPanel.new(
66
- center: Zaniah::UI::Label.new("Editor"), top: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Top", size: :sm)),
67
- left: Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Files", size: :sm), width: 90),
68
- bottom: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm))).w(420).h(120))
79
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
80
+ Zaniah::UI::DockPanel.new(
81
+ center: Zaniah::UI::Label.new("Editor"), top: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Top", size: :sm)),
82
+ left: Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Files", size: :sm), width: 90),
83
+ bottom: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm))).w(420).h(120),
84
+ Zaniah::UI::ZoomPanView.new(Zaniah::Div.new.w(110).h(70).p(8).bg("#2563eb")
85
+ .child(Zaniah::UI::Label.new("Zoom / pan", size: :sm)), zoom: 1.3).w(180).h(120)
86
+ ]))
87
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
88
+ Zaniah::UI::DockWorkspace.new(dock_layout,
89
+ render: ->(id) { Zaniah::Div.new.p(10).child(Zaniah::UI::Label.new("#{id} panel")) }).w(440).h(190),
90
+ Zaniah::UI::PropertyGrid.new(property_schema,
91
+ {title: "Workspace", count: 3, visible: true}, height: 190).w(340)
92
+ ]))
69
93
  editors = Zaniah::Div.new.w_full.flex_row.gap(12).children([
70
94
  Zaniah::UI::CodeEditor.new("def hello\n :world\nend\n", language: :ruby).w(390).h(150),
71
95
  Zaniah::UI::RichText.new([{text: "Rich ", size: 20}, {text: "text", color: "#2563eb", size: 20}]).w(260)])
72
- Zaniah::Div.new.h(2480).p(16).gap(12)
96
+ Zaniah::Div.new.h(3200).p(16).gap(12)
73
97
  .child(section("Foundation", foundation, height: 180))
74
98
  .child(section("Actions",
75
99
  Zaniah::UI::Button.new("Primary").icon(:check).on_click(&clicks),
@@ -94,7 +118,8 @@ module Gallery
94
118
  .child(section("Variants", variants, height: 285))
95
119
  .child(section("Data", data, height: 440))
96
120
  .child(section("Choice and pickers", choices, height: 210))
97
- .child(section("Workspace layout", workspaces, height: 300))
121
+ .child(section("Feedback and dates", feedback_dates, height: 410))
122
+ .child(section("Workspace layout", workspaces, height: 530))
98
123
  .child(section("Editors", editors, height: 210))
99
124
  .child(section("Forms", Zaniah::UI::Form.new.field(name: :email, label: "Release email", value: "ruby@example.com", validation: validation).w(320), height: 150))
100
125
  end
@@ -110,6 +135,7 @@ module Gallery
110
135
  when /\Atoast-(info|success|warning|danger)\z/ then Zaniah::UI::Toast.new("Saved", variant: Regexp.last_match(1).to_sym)
111
136
  when "tooltip" then Zaniah::UI::Tooltip.new("Helpful text", anchor: anchor)
112
137
  when "popover" then Zaniah::UI::Popover.new(content, anchor: anchor)
138
+ when "hover-card" then Zaniah::UI::HoverCard.new(Zaniah::UI::Label.new("Keyboard shortcuts"), anchor: anchor).open
113
139
  when "menu" then Zaniah::UI::Menu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
114
140
  when "context-menu" then Zaniah::UI::ContextMenu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
115
141
  when "modal" then Zaniah::UI::Modal.new(content, title: "Modal")