zaniah 0.2.0 → 0.3.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -0
  3. data/LICENSE.txt +18 -0
  4. data/README.md +14 -4
  5. data/assets/icons/check.svg +1 -0
  6. data/assets/icons/close.svg +1 -0
  7. data/assets/icons/info.svg +1 -0
  8. data/assets/icons/menu.svg +1 -0
  9. data/assets/icons/search.svg +1 -0
  10. data/assets/icons/warning.svg +1 -0
  11. data/docs/accessibility.md +61 -0
  12. data/docs/adr/004-style-resolution.md +17 -0
  13. data/docs/adr/005-instance-layout.md +43 -0
  14. data/docs/adr/006-component-model.md +17 -0
  15. data/docs/adr/007-text-buffer.md +17 -0
  16. data/docs/adr/008-accessibility-bridge.md +19 -0
  17. data/docs/adr/009-grid-scope.md +17 -0
  18. data/docs/animation.md +46 -0
  19. data/docs/components.md +109 -0
  20. data/docs/gallery/command-palette-dark.png +0 -0
  21. data/docs/gallery/command-palette-high_contrast.png +0 -0
  22. data/docs/gallery/command-palette-light.png +0 -0
  23. data/docs/gallery/components-dark.png +0 -0
  24. data/docs/gallery/components-high_contrast.png +0 -0
  25. data/docs/gallery/components-light.png +0 -0
  26. data/docs/gallery/context-menu-dark.png +0 -0
  27. data/docs/gallery/context-menu-high_contrast.png +0 -0
  28. data/docs/gallery/context-menu-light.png +0 -0
  29. data/docs/gallery/dialog-dark.png +0 -0
  30. data/docs/gallery/dialog-high_contrast.png +0 -0
  31. data/docs/gallery/dialog-light.png +0 -0
  32. data/docs/gallery/drawer-left-dark.png +0 -0
  33. data/docs/gallery/drawer-left-high_contrast.png +0 -0
  34. data/docs/gallery/drawer-left-light.png +0 -0
  35. data/docs/gallery/drawer-right-dark.png +0 -0
  36. data/docs/gallery/drawer-right-high_contrast.png +0 -0
  37. data/docs/gallery/drawer-right-light.png +0 -0
  38. data/docs/gallery/menu-dark.png +0 -0
  39. data/docs/gallery/menu-high_contrast.png +0 -0
  40. data/docs/gallery/menu-light.png +0 -0
  41. data/docs/gallery/modal-dark.png +0 -0
  42. data/docs/gallery/modal-high_contrast.png +0 -0
  43. data/docs/gallery/modal-light.png +0 -0
  44. data/docs/gallery/popover-bottom-dark.png +0 -0
  45. data/docs/gallery/popover-bottom-high_contrast.png +0 -0
  46. data/docs/gallery/popover-bottom-light.png +0 -0
  47. data/docs/gallery/popover-left-dark.png +0 -0
  48. data/docs/gallery/popover-left-high_contrast.png +0 -0
  49. data/docs/gallery/popover-left-light.png +0 -0
  50. data/docs/gallery/popover-right-dark.png +0 -0
  51. data/docs/gallery/popover-right-high_contrast.png +0 -0
  52. data/docs/gallery/popover-right-light.png +0 -0
  53. data/docs/gallery/popover-top-dark.png +0 -0
  54. data/docs/gallery/popover-top-high_contrast.png +0 -0
  55. data/docs/gallery/popover-top-light.png +0 -0
  56. data/docs/gallery/toast-danger-dark.png +0 -0
  57. data/docs/gallery/toast-danger-high_contrast.png +0 -0
  58. data/docs/gallery/toast-danger-light.png +0 -0
  59. data/docs/gallery/toast-info-dark.png +0 -0
  60. data/docs/gallery/toast-info-high_contrast.png +0 -0
  61. data/docs/gallery/toast-info-light.png +0 -0
  62. data/docs/gallery/toast-success-dark.png +0 -0
  63. data/docs/gallery/toast-success-high_contrast.png +0 -0
  64. data/docs/gallery/toast-success-light.png +0 -0
  65. data/docs/gallery/toast-warning-dark.png +0 -0
  66. data/docs/gallery/toast-warning-high_contrast.png +0 -0
  67. data/docs/gallery/toast-warning-light.png +0 -0
  68. data/docs/gallery/tooltip-bottom-dark.png +0 -0
  69. data/docs/gallery/tooltip-bottom-high_contrast.png +0 -0
  70. data/docs/gallery/tooltip-bottom-light.png +0 -0
  71. data/docs/gallery/tooltip-left-dark.png +0 -0
  72. data/docs/gallery/tooltip-left-high_contrast.png +0 -0
  73. data/docs/gallery/tooltip-left-light.png +0 -0
  74. data/docs/gallery/tooltip-right-dark.png +0 -0
  75. data/docs/gallery/tooltip-right-high_contrast.png +0 -0
  76. data/docs/gallery/tooltip-right-light.png +0 -0
  77. data/docs/gallery/tooltip-top-dark.png +0 -0
  78. data/docs/gallery/tooltip-top-high_contrast.png +0 -0
  79. data/docs/gallery/tooltip-top-light.png +0 -0
  80. data/docs/index.html +74 -0
  81. data/docs/layout.md +31 -0
  82. data/docs/native.md +7 -2
  83. data/docs/site.css +32 -0
  84. data/docs/text.md +35 -0
  85. data/docs/theme.md +50 -0
  86. data/docs/tui.md +36 -0
  87. data/examples/gallery.rb +155 -0
  88. data/examples/native_ime.rb +13 -6
  89. data/lib/zaniah/accessibility/linux/service.rb +361 -0
  90. data/lib/zaniah/accessibility/linux.rb +23 -0
  91. data/lib/zaniah/accessibility/mac.rb +136 -0
  92. data/lib/zaniah/accessibility/native_tree.rb +53 -0
  93. data/lib/zaniah/accessibility/node.rb +12 -0
  94. data/lib/zaniah/accessibility/tree.rb +61 -0
  95. data/lib/zaniah/accessibility/windows/provider.rb +273 -0
  96. data/lib/zaniah/accessibility/windows.rb +38 -0
  97. data/lib/zaniah/accessibility.rb +63 -0
  98. data/lib/zaniah/animation/animator.rb +95 -0
  99. data/lib/zaniah/animation/easing.rb +45 -0
  100. data/lib/zaniah/animation/spring.rb +36 -0
  101. data/lib/zaniah/animation/transition.rb +5 -0
  102. data/lib/zaniah/animation.rb +6 -0
  103. data/lib/zaniah/app.rb +22 -2
  104. data/lib/zaniah/color.rb +16 -0
  105. data/lib/zaniah/devtools/hot_reload.rb +40 -0
  106. data/lib/zaniah/devtools/inspector.rb +111 -0
  107. data/lib/zaniah/devtools/stats_overlay.rb +16 -0
  108. data/lib/zaniah/devtools.rb +13 -0
  109. data/lib/zaniah/element.rb +210 -15
  110. data/lib/zaniah/frame_context.rb +4 -1
  111. data/lib/zaniah/gpu/frame_encoder.rb +10 -1
  112. data/lib/zaniah/gpu/instance_packing.rb +33 -5
  113. data/lib/zaniah/gpu/metal.rb +26 -10
  114. data/lib/zaniah/gpu/open_gl.rb +35 -14
  115. data/lib/zaniah/gpu/software.rb +116 -14
  116. data/lib/zaniah/gpu/vulkan/scene.frag +51 -0
  117. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  118. data/lib/zaniah/gpu/vulkan/scene.vert +50 -0
  119. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  120. data/lib/zaniah/gpu/vulkan.rb +282 -36
  121. data/lib/zaniah/gpu.rb +8 -3
  122. data/lib/zaniah/input/dispatcher.rb +96 -12
  123. data/lib/zaniah/input/focus_handle.rb +13 -4
  124. data/lib/zaniah/input/focus_tree.rb +77 -0
  125. data/lib/zaniah/input/keymap.rb +77 -0
  126. data/lib/zaniah/input.rb +1 -0
  127. data/lib/zaniah/interactivity.rb +30 -0
  128. data/lib/zaniah/layout/engine.rb +173 -36
  129. data/lib/zaniah/layout/grid.rb +134 -0
  130. data/lib/zaniah/layout/style.rb +16 -1
  131. data/lib/zaniah/layout.rb +1 -0
  132. data/lib/zaniah/length.rb +3 -0
  133. data/lib/zaniah/length_units.rb +7 -0
  134. data/lib/zaniah/list.rb +27 -15
  135. data/lib/zaniah/platform/appearance.rb +11 -0
  136. data/lib/zaniah/platform/headless/window.rb +101 -9
  137. data/lib/zaniah/platform/linux/appearance_aware.rb +8 -0
  138. data/lib/zaniah/platform/linux/wayland_window.rb +3 -1
  139. data/lib/zaniah/platform/linux/window.rb +4 -2
  140. data/lib/zaniah/platform/mac/app.rb +1 -1
  141. data/lib/zaniah/platform/mac/core_text.rb +3 -0
  142. data/lib/zaniah/platform/mac/window.rb +9 -1
  143. data/lib/zaniah/platform/tui/window.rb +1 -1
  144. data/lib/zaniah/platform/windows/window.rb +12 -2
  145. data/lib/zaniah/platform.rb +1 -0
  146. data/lib/zaniah/scene.rb +137 -28
  147. data/lib/zaniah/scroll_state.rb +117 -0
  148. data/lib/zaniah/scroll_view.rb +94 -0
  149. data/lib/zaniah/style/gradient.rb +28 -0
  150. data/lib/zaniah/style/ring.rb +13 -0
  151. data/lib/zaniah/style/shadow.rb +13 -0
  152. data/lib/zaniah/style/style_set.rb +43 -0
  153. data/lib/zaniah/style/transform.rb +40 -0
  154. data/lib/zaniah/svg.rb +19 -0
  155. data/lib/zaniah/text.rb +261 -6
  156. data/lib/zaniah/text_buffer.rb +119 -0
  157. data/lib/zaniah/text_selection.rb +18 -0
  158. data/lib/zaniah/text_system/atlas_cache.rb +1 -1
  159. data/lib/zaniah/text_system/fallback.rb +24 -0
  160. data/lib/zaniah/text_system/font_db.rb +4 -1
  161. data/lib/zaniah/text_system/kinsoku.rb +28 -0
  162. data/lib/zaniah/text_system/line_breaker.rb +54 -0
  163. data/lib/zaniah/text_system/paragraph.rb +99 -0
  164. data/lib/zaniah/text_system/renderer.rb +13 -2
  165. data/lib/zaniah/text_system/typesetter.rb +4 -0
  166. data/lib/zaniah/text_system.rb +3 -0
  167. data/lib/zaniah/theme.rb +39 -0
  168. data/lib/zaniah/ui/charts.rb +165 -0
  169. data/lib/zaniah/ui/choice_fields.rb +134 -0
  170. data/lib/zaniah/ui/component.rb +73 -0
  171. data/lib/zaniah/ui/controls.rb +439 -0
  172. data/lib/zaniah/ui/data.rb +364 -0
  173. data/lib/zaniah/ui/editors.rb +67 -0
  174. data/lib/zaniah/ui/forms.rb +131 -0
  175. data/lib/zaniah/ui/layout_components.rb +205 -0
  176. data/lib/zaniah/ui/modal.rb +129 -0
  177. data/lib/zaniah/ui/overlays.rb +282 -0
  178. data/lib/zaniah/ui/pickers.rb +169 -0
  179. data/lib/zaniah/ui/primitives.rb +199 -0
  180. data/lib/zaniah/ui/scrollbar.rb +104 -0
  181. data/lib/zaniah/ui/structure.rb +224 -0
  182. data/lib/zaniah/ui/text_inputs.rb +169 -0
  183. data/lib/zaniah/ui/variants.rb +23 -0
  184. data/lib/zaniah/ui.rb +18 -0
  185. data/lib/zaniah/unicode.rb +42 -0
  186. data/lib/zaniah/uniform_list.rb +27 -6
  187. data/lib/zaniah/version.rb +1 -1
  188. data/lib/zaniah.rb +14 -0
  189. data/sig/accessibility.rbs +34 -0
  190. data/sig/animation.rbs +54 -0
  191. data/sig/app.rbs +2 -1
  192. data/sig/devtools.rbs +37 -0
  193. data/sig/elements.rbs +88 -6
  194. data/sig/gpu.rbs +9 -2
  195. data/sig/input.rbs +31 -5
  196. data/sig/layout.rbs +2 -1
  197. data/sig/platform.rbs +24 -4
  198. data/sig/scene.rbs +15 -2
  199. data/sig/style.rbs +82 -0
  200. data/sig/text.rbs +35 -0
  201. data/sig/theme.rbs +64 -0
  202. data/sig/ui.rbs +390 -0
  203. data/sig/zaniah.rbs +56 -3
  204. data/tools/generate_component_gallery.rb +48 -0
  205. metadata +147 -2
data/docs/index.html ADDED
@@ -0,0 +1,74 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="Zaniah is a pure Ruby UI toolkit for native GPU, headless, and terminal applications.">
7
+ <title>Zaniah — Ruby UI, from one tree</title>
8
+ <link rel="stylesheet" href="site.css">
9
+ </head>
10
+ <body>
11
+ <header class="masthead">
12
+ <a class="brand" href="../README.md"><img src="../assets/logo.png" alt="" width="42" height="42">Zaniah</a>
13
+ <nav aria-label="Primary"><a href="#gallery">Gallery</a><a href="#capabilities">Capabilities</a><a href="#start">Start</a><a href="components.md">Reference</a></nav>
14
+ </header>
15
+
16
+ <main>
17
+ <section class="hero">
18
+ <div>
19
+ <h1>Ruby UI,<br>from one tree.</h1>
20
+ <p>Build native GPU windows, deterministic PNG tests, and terminal interfaces with the same Ruby element tree.</p>
21
+ <div class="actions"><a class="primary" href="#start">Build a window</a><a href="https://github.com/noxdea/zaniah">View source</a></div>
22
+ </div>
23
+ <dl class="backends" aria-label="Rendering backends">
24
+ <div><dt>macOS</dt><dd>Metal / OpenGL</dd></div>
25
+ <div><dt>Linux</dt><dd>Wayland / X11</dd></div>
26
+ <div><dt>Windows</dt><dd>Win32 / WGL</dd></div>
27
+ <div><dt>Tests</dt><dd>Pure Ruby pixels</dd></div>
28
+ <div><dt>Terminal</dt><dd>ANSI cells</dd></div>
29
+ </dl>
30
+ </section>
31
+
32
+ <section class="gallery" id="gallery">
33
+ <div class="section-heading"><h2>The component surface</h2><p>Controls, data views, charts, forms, overlays, focus, motion, and accessibility are rendered by Zaniah itself.</p></div>
34
+ <figure><img src="gallery/components-dark.png" alt="Dark Zaniah component gallery showing controls, inputs, navigation, tables, trees, charts, and forms" loading="lazy"><figcaption>Dark theme · generated by the headless backend</figcaption></figure>
35
+ <figure><img src="gallery/components-light.png" alt="Light Zaniah component gallery showing the same controls and data components" loading="lazy"><figcaption>Light theme · the same Ruby tree</figcaption></figure>
36
+ <figure><img src="gallery/components-high_contrast.png" alt="High-contrast Zaniah component gallery" loading="lazy"><figcaption>High contrast · keyboard focus and semantic colors remain visible</figcaption></figure>
37
+ <div class="overlay-grid">
38
+ <figure><img src="gallery/menu-dark.png" alt="Menu component" loading="lazy"><figcaption>Menu</figcaption></figure>
39
+ <figure><img src="gallery/dialog-light.png" alt="Dialog component" loading="lazy"><figcaption>Dialog</figcaption></figure>
40
+ <figure><img src="gallery/drawer-right-dark.png" alt="Right drawer component" loading="lazy"><figcaption>Drawer</figcaption></figure>
41
+ <figure><img src="gallery/toast-success-light.png" alt="Success toast component" loading="lazy"><figcaption>Toast</figcaption></figure>
42
+ </div>
43
+ </section>
44
+
45
+ <section class="capabilities" id="capabilities">
46
+ <div class="section-heading"><h2>One pipeline, five useful views</h2><p>Rendering features stay connected to input, semantics, testing, and performance.</p></div>
47
+ <div class="capability"><h3>Layout</h3><p>Flex, Grid, sticky positioning, virtual lists, bidirectional scrolling, and keyed movement.</p><code>display: :grid</code></div>
48
+ <div class="capability"><h3>Text</h3><p>OpenType shaping, fallback, Japanese line breaking, grapheme-safe editing, selection, and native IME.</p><code>TextBuffer.new</code></div>
49
+ <div class="capability"><h3>Interaction</h3><p>State styles, spatial focus, capture, popovers, modals, transitions, springs, and reduced motion.</p><code>.transition(:background)</code></div>
50
+ <div class="capability"><h3>Semantics</h3><p>A diffed accessibility tree is queryable through NSAccessibility, UI Automation, and AT-SPI.</p><code>window.accessibility_tree</code></div>
51
+ <div class="capability"><h3>Inspection</h3><p>Press F12 for bounds, resolved styles, frame timing, and command counts. Reload Ruby files without dropping entities.</p><code>Zaniah::DevTools.attach</code></div>
52
+ </section>
53
+
54
+ <section class="start" id="start">
55
+ <div>
56
+ <h2>A window is ordinary Ruby</h2>
57
+ <p>The UI layer is opt-in. Use core elements alone, or load the component library when the application needs it.</p>
58
+ <ul><li><a href="components.md">Component reference</a></li><li><a href="animation.md">Animation</a></li><li><a href="accessibility.md">Accessibility</a></li><li><a href="tui.md">Terminal behavior</a></li><li><a href="theme.md">Themes</a></li></ul>
59
+ </div>
60
+ <pre><code>require "zaniah/ui"
61
+
62
+ app = Zaniah::App.new
63
+ app.open_window(backend: :mac) do
64
+ Zaniah::UI::Button.new("Save")
65
+ .icon(:check)
66
+ .on_click { save }
67
+ end
68
+ app.run</code></pre>
69
+ </section>
70
+ </main>
71
+
72
+ <footer><span>Zaniah is MIT licensed.</span><a href="https://github.com/noxdea/zaniah">GitHub</a><a href="https://rubygems.org/gems/zaniah">RubyGems</a></footer>
73
+ </body>
74
+ </html>
data/docs/layout.md ADDED
@@ -0,0 +1,31 @@
1
+ # Layout and scrolling
2
+
3
+ Zaniah uses flex layout by default. Set `display: :grid` for a bounded Grid
4
+ subset with explicit tracks, row-first automatic placement, spans, gaps, `fr`,
5
+ `minmax`, and `auto` tracks.
6
+
7
+ ```ruby
8
+ include Zaniah::LengthUnits
9
+
10
+ Zaniah::Div.new.style(
11
+ display: :grid,
12
+ grid_template_columns: [px(120), fr(1), fr(2)],
13
+ column_gap: 8
14
+ ).children(items)
15
+ ```
16
+
17
+ Place an item with `grid_column: 2` or span inclusive tracks with
18
+ `grid_column: 1..3`. Named areas and `auto-fit`/`auto-fill` are not supported.
19
+
20
+ `ScrollView` accepts one child and clips it to the viewport. Its `axis` is
21
+ `:vertical`, `:horizontal`, or `:both`; use `scroll_to` for programmatic
22
+ scrolling and `scroll_state` for offsets and edge checks.
23
+
24
+ ```ruby
25
+ view = Zaniah::ScrollView.new(axis: :vertical).w(320).h(240).child(content)
26
+ view.scroll_to(120)
27
+ ```
28
+
29
+ `aspect_ratio` derives an unspecified dimension. Logical edge properties such
30
+ as `padding_start` and `margin_end` follow `direction: :ltr` or `:rtl`.
31
+ `position: :sticky` is effective inside a `ScrollView`.
data/docs/native.md CHANGED
@@ -28,6 +28,10 @@ window.run
28
28
  On Linux, pass `display_server: :wayland` or `:x11` to require one display
29
29
  server. On macOS, pass `gpu: :opengl` to use OpenGL instead of Metal.
30
30
 
31
+ For offscreen Vulkan rendering, use `GPU.create(backend: :vulkan, width:, height:)`.
32
+ It accepts the same `Scene` instance layout as Metal, OpenGL, and Software and
33
+ supports capture through `pixels` or `write_png`.
34
+
31
35
  Native backends require Ruby's Fiddle library and the platform graphics
32
36
  libraries. Fiddle is not a default gem on Ruby 4, so applications using native
33
37
  windows must install it. Linux file dialogs and URL opening use `zenity` and
@@ -62,5 +66,6 @@ ruby examples/linux_smoke.rb --wayland
62
66
  ruby examples/native_watch.rb
63
67
  ```
64
68
 
65
- Native input, IME, dialogs, and terminal integration also require manual checks
66
- on the target operating system.
69
+ Linux XIM composition is exercised in CI with Xvfb and IBus/KKC. Native input,
70
+ IME on macOS and Windows, dialogs, and terminal integration also require manual
71
+ checks on the target operating system.
data/docs/site.css ADDED
@@ -0,0 +1,32 @@
1
+ @font-face{font-family:Abel;src:url("../assets/fonts/Abel-Regular.ttf") format("truetype");font-display:swap}
2
+ :root{--ground:#f4f7fb;--paper:#fff;--ink:#17233b;--muted:#5f6e85;--blue:#245b9b;--ruby:#c33b52;--rule:#d8e2ef;--code:#eaf0f7;color-scheme:light}
3
+ *{box-sizing:border-box}
4
+ html{scroll-behavior:smooth}
5
+ body{margin:0;background:var(--ground);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
6
+ a{color:inherit;text-decoration-thickness:1px;text-underline-offset:.2em}
7
+ a:focus-visible{outline:3px solid var(--ruby);outline-offset:4px}
8
+ .masthead,main,footer{width:min(1180px,calc(100% - 40px));margin-inline:auto}
9
+ .masthead{height:82px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--rule)}
10
+ .brand{display:flex;align-items:center;gap:10px;font:28px/1 Abel,sans-serif;text-decoration:none;letter-spacing:.02em}
11
+ .brand img{object-fit:contain}
12
+ nav{display:flex;gap:24px;font-size:14px}nav a{text-decoration:none}nav a:hover{text-decoration:underline}
13
+ .hero{min-height:570px;display:grid;grid-template-columns:minmax(0,1.45fr) minmax(250px,.55fr);align-items:center;gap:8vw;border-bottom:1px solid var(--rule)}
14
+ h1,h2,h3{font-family:Abel,sans-serif;font-weight:400;margin:0}
15
+ h1{font-size:clamp(64px,10vw,132px);line-height:.82;letter-spacing:-.035em;max-width:760px}
16
+ .hero p{max-width:590px;font-size:clamp(19px,2.1vw,27px);line-height:1.3;color:var(--muted);margin:38px 0 28px}
17
+ .actions{display:flex;gap:20px;align-items:center}.actions a{font-weight:650}.actions .primary{background:var(--blue);color:#fff;padding:11px 17px;text-decoration:none}
18
+ .backends{margin:0;border-top:3px solid var(--ink)}.backends div{display:flex;justify-content:space-between;gap:20px;padding:13px 0;border-bottom:1px solid var(--rule)}
19
+ .backends dt{font-weight:700}.backends dd{margin:0;color:var(--muted);text-align:right}
20
+ .gallery,.capabilities,.start{padding:92px 0;border-bottom:1px solid var(--rule)}
21
+ .section-heading{display:grid;grid-template-columns:1fr 1fr;gap:40px;margin-bottom:40px;align-items:end}
22
+ .section-heading h2,.start h2{font-size:clamp(42px,6vw,76px);line-height:.95}.section-heading p,.start p{max-width:540px;color:var(--muted);margin:0}
23
+ figure{margin:0 0 48px;background:var(--paper);border:1px solid var(--rule);padding:12px}figure img{display:block;width:100%;height:auto}figcaption{font-size:13px;color:var(--muted);padding:10px 2px 0}
24
+ .overlay-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.overlay-grid figure{margin:0}
25
+ .capability{display:grid;grid-template-columns:180px 1fr 250px;gap:32px;align-items:baseline;border-top:1px solid var(--rule);padding:22px 0}.capability:last-child{border-bottom:1px solid var(--rule)}
26
+ .capability h3{font-size:28px}.capability p{margin:0;color:var(--muted);max-width:620px}.capability code{background:var(--code);padding:5px 8px;color:var(--blue);justify-self:start}
27
+ .start{display:grid;grid-template-columns:1fr 1fr;gap:8vw}.start p{margin:24px 0}.start ul{columns:2;padding-left:20px}.start li{margin:7px 0}
28
+ pre{margin:0;background:var(--ink);color:#edf4ff;padding:28px;overflow:auto;border-left:5px solid var(--ruby);font-size:14px;line-height:1.65}
29
+ footer{display:flex;gap:24px;padding:28px 0 50px;color:var(--muted);font-size:14px}footer span{margin-right:auto}
30
+ @media(max-width:760px){.masthead{height:auto;padding:18px 0;align-items:flex-start}.masthead nav{display:grid;grid-template-columns:1fr 1fr;gap:6px 18px}.hero{grid-template-columns:1fr;padding:80px 0}.section-heading,.start,.overlay-grid{grid-template-columns:1fr}.capability{grid-template-columns:1fr;gap:4px}.capability code{margin-top:8px}.gallery,.capabilities,.start{padding:64px 0}}
31
+ @media(max-width:480px){.masthead,main,footer{width:min(100% - 24px,1180px)}.brand{font-size:24px}.brand img{width:34px;height:34px}h1{font-size:60px}.hero p{font-size:18px}.actions{align-items:flex-start;flex-direction:column}.start ul{columns:1}footer{flex-wrap:wrap}footer span{width:100%}}
32
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
data/docs/text.md CHANGED
@@ -88,3 +88,38 @@ ruby --yjit -Ilib script/shaper_oracle assets/fonts/Abel-Regular.ttf /path/to/fo
88
88
  ```
89
89
 
90
90
  The shaping oracle is optional and requires `hb-shape`; it is not used at runtime.
91
+
92
+ ## Paragraphs, selection, and editing
93
+
94
+ Single-line layout remains the default. Enable wrapping explicitly on a `Text`
95
+ element or construct a paragraph for layout-only work:
96
+
97
+ ```ruby
98
+ text = Zaniah::Text.new("長い文章…", wrap: :word, line_height: 1.5,
99
+ letter_spacing: 0.2, align: :start, kinsoku: :push).w(320)
100
+
101
+ paragraph = system.layout_paragraph("Text", width: 320, wrap: :word)
102
+ offset = paragraph.hit_test(Zaniah::Point.new(40, 20))
103
+ point = paragraph.offset_to_point(offset)
104
+ ```
105
+
106
+ Wrapping supports `:none`, `:word`, and `:anywhere`; Japanese kinsoku modes are
107
+ `:push`, `:hanging`, and `:none`. Font fallback is selected per grapheme from the
108
+ requested font through CJK, emoji, symbol, and general system faces. Colored
109
+ COLR, CBDT, and sbix glyphs use the RGBA atlas.
110
+
111
+ Use `selectable` for pointer/keyboard selection and `editable` for a UTF-8 text
112
+ buffer with undo, redo, grapheme-safe changes, and IME composition:
113
+
114
+ ```ruby
115
+ buffer = Zaniah::TextBuffer.new("hello")
116
+ field = Zaniah::Text.new(buffer.to_s).editable(buffer)
117
+
118
+ buffer.insert(buffer.bytesize, " 👋")
119
+ buffer.undo.redo
120
+ ```
121
+
122
+ Offsets are UTF-8 byte offsets and edits must land on extended grapheme-cluster
123
+ boundaries. The string-backed buffer is intended for ordinary fields and
124
+ documents up to tens of thousands of characters; a piece table is deliberately
125
+ outside the current scope.
data/docs/theme.md ADDED
@@ -0,0 +1,50 @@
1
+ # Themes and state styles
2
+
3
+ `Zaniah::Theme.dark`, `.light`, and `.high_contrast` provide semantic color,
4
+ spacing, radius, shadow, typography, and motion tokens. `App` installs the dark
5
+ theme by default and follows native window appearance changes. Read the active
6
+ theme with `FrameContext#theme` or `EntityContext#theme`.
7
+
8
+ ```ruby
9
+ theme = cx.theme
10
+ card = Zaniah::Div.new.bg(theme.colors.surface).p(theme.spacing[4])
11
+ ```
12
+
13
+ State styles resolve in this order, with later states winning:
14
+ `selected`, `hover`, `active`, `focus`, `focus_visible`, `disabled`.
15
+
16
+ ```ruby
17
+ Zaniah::Div.new
18
+ .bg(theme.colors.surface)
19
+ .hover { |style| style.bg(theme.colors.surface_hover) }
20
+ .active { |style| style.bg(theme.colors.surface_pressed) }
21
+ .disabled { |style| style.opacity(0.5) }
22
+ ```
23
+
24
+ Create a custom theme by constructing `Theme` with the same token value objects.
25
+ Public token names are semantic; applications should not depend on the built-in
26
+ themes' raw color values.
27
+
28
+ ## Token groups
29
+
30
+ - `colors`: background/surface states, border/focus, text, accent, status,
31
+ overlay, selection, and ring colors.
32
+ - `spacing`: a compact 4-pixel scale indexed by token number.
33
+ - `radii` and `shadows`: semantic `none`, `sm`, `md`, `lg`, and `full` values.
34
+ - `typography`: sans/mono families, six sizes, four weights, and three line heights.
35
+ - `motion`: fast/base/slow durations, easing names, and `reduced?`.
36
+
37
+ Records support `with`, so a local override does not mutate the shared theme:
38
+
39
+ ```ruby
40
+ base = Zaniah::Theme.light
41
+ quiet = base.with(
42
+ colors: base.colors.with(accent: Zaniah::Color.parse("#245b9b")),
43
+ motion: base.motion.with(reduced: true)
44
+ )
45
+ app.global(:theme, quiet)
46
+ ```
47
+
48
+ Native windows select dark or light initially and refresh the application theme
49
+ when system appearance changes. A system reduced-motion preference sets
50
+ `theme.motion.reduced?` and collapses animation durations to zero.
data/docs/tui.md ADDED
@@ -0,0 +1,36 @@
1
+ # TUI component degradation
2
+
3
+ `Platform.open_window(backend: :tui)` renders the ordinary element tree as terminal
4
+ cells. `Component#tui_cells` also exposes the deterministic fallback used by snapshot
5
+ tests and non-window integrations.
6
+
7
+ | Component family | Terminal representation |
8
+ | --- | --- |
9
+ | Label, Badge, Avatar | text, `[badge]`, `(initials)` |
10
+ | Icon | Unicode check/close/search/menu/info/warning symbol; unlabeled unknown icons are omitted |
11
+ | Divider, Card | `─`/`│` and box-drawing characters |
12
+ | Skeleton, Spinner, ProgressBar, Meter | shade cells, `◌`, or a ten-cell progress track; animation jumps to its terminal state |
13
+ | Button, IconButton, ToggleButton | `[ label ]`; disabled uses `( label )` |
14
+ | Checkbox, Radio, Switch | `[x]`, `(o)`, `[on ]` |
15
+ | Slider, RangeSlider | ten-cell track and numeric value/range |
16
+ | Text inputs | `[value]`; password values are masked and validation errors have `!` |
17
+ | Select, Combobox, MultiSelect | labeled brackets, filtered menu marker, or comma-separated selected values |
18
+ | DatePicker, TimePicker, ColorPicker | labeled ISO date, 24-hour time, or hex color in brackets |
19
+ | Tooltip, Popover, Menu, Dropdown | status text or a box/menu with `>` selection marker |
20
+ | Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
21
+ | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
22
+ | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
23
+ | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; focus stays inside until dismissed |
24
+ | SplitPane, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
25
+ | ListView | visible rows with `>` on the selected item |
26
+ | Toast | live status text |
27
+ | Table, DataGrid | header and visible rows separated with `|` |
28
+ | TreeView | indentation with `▸`/`▾` expansion markers |
29
+ | Sparkline, LineChart, BarChart | eight-level Unicode sparkline summary |
30
+ | Form, FormField | labeled values, `!` errors, and a submit button |
31
+ | CodeEditor, RichText | numbered plain-text lines or concatenated text runs |
32
+
33
+ Rounded corners, shadows, gradients, and alpha scrims reduce to solid cells. Focus is
34
+ reported by the terminal cursor/reverse-video capability; motion introduced in M7 is
35
+ sampled at its final state. New components must document text, focus, disabled, and
36
+ overlay behavior in this table before release.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah/ui"
5
+
6
+ module Gallery
7
+ module_function
8
+
9
+ def section(title, *children, height: nil)
10
+ card = Zaniah::UI::Card.new(Zaniah::UI::Label.new(title, size: :lg),
11
+ Zaniah::Div.new.flex_row.style(flex_wrap: :wrap).gap(10).children(children.flatten.compact))
12
+ card.h(height) if height
13
+ card
14
+ end
15
+
16
+ def page
17
+ clicks = ->(*) {}
18
+ tabs = Zaniah::UI::Tabs.new([["Overview", Zaniah::UI::Label.new("Overview panel")], ["Details", Zaniah::UI::Label.new("Details panel")]]).w(220)
19
+ rows = Array.new(40) { |index| {id: index + 1, name: "Record #{index + 1}", status: index.even? ? "Ready" : "Queued"} }
20
+ validation = Zaniah::UI::Validation.new.required.format(/@/, message: "Enter an email address")
21
+ foundation = Zaniah::Div.new.w_full.gap(8)
22
+ .child(Zaniah::Div.new.flex_row.items_center.gap(12).children([
23
+ Zaniah::UI::Label.new("Label"), Zaniah::UI::Icon.new(:info, label: "Information"),
24
+ Zaniah::UI::Badge.new("New", variant: :accent), Zaniah::UI::Avatar.new("Ruby UI"),
25
+ Zaniah::UI::Spacer.new(4), Zaniah::UI::Skeleton.new]))
26
+ .child(Zaniah::UI::Divider.new)
27
+ .child(Zaniah::UI::EmptyState.new("No results", message: "Try another query").h(64))
28
+ button_variants = %i[primary secondary ghost danger].map do |variant|
29
+ Zaniah::Div.new.flex_row.gap(8).children(%i[sm md lg].map { |size| Zaniah::UI::Button.new("#{variant} #{size}", variant: variant, size: size) })
30
+ end
31
+ variants = Zaniah::Div.new.w_full.gap(8).children(button_variants)
32
+ .child(Zaniah::Div.new.flex_row.gap(8).children(%i[neutral accent success warning danger].map { |variant| Zaniah::UI::Badge.new(variant, variant: variant) }))
33
+ .child(Zaniah::Div.new.flex_row.gap(14).children(%i[default muted inverse].map { |tone| Zaniah::UI::Label.new(tone, tone: tone) }))
34
+ .child(Zaniah::Div.new.flex_row.items_center.gap(14).children(%i[xs sm md lg xl].map { |size| Zaniah::UI::Label.new(size, size: size) }))
35
+ structure = Zaniah::Div.new.w_full.gap(10)
36
+ .child(Zaniah::Div.new.flex_row.gap(12).children([tabs,
37
+ Zaniah::UI::Accordion.new([["Section one", Zaniah::UI::Label.new("Content")], ["Section two", Zaniah::UI::Label.new("More")]]).w(240),
38
+ Zaniah::UI::Collapsible.new("Disclosure", Zaniah::UI::Label.new("Visible"), open: true).w(210),
39
+ Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Sidebar", size: :sm), width: 100).h(80)]))
40
+ .child(Zaniah::UI::Toolbar.new(Zaniah::UI::Button.new("New", size: :sm), Zaniah::UI::Button.new("Edit", size: :sm)))
41
+ .child(Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm)))
42
+ data = Zaniah::Div.new.w_full.gap(12)
43
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
44
+ Zaniah::UI::Table.new(rows.first(8), columns: [{key: :id, width: 54}, {key: :name, width: 136}], height: 190, row_key: ->(row) { row[:id] }).w(200),
45
+ Zaniah::UI::DataGrid.new(rows, columns: [{key: :id, width: 54}, {key: :name, width: 130, editable: true}, {key: :status, width: 90}], height: 190, row_key: ->(row) { row[:id] }).w(285),
46
+ Zaniah::UI::TreeView.new([{id: :src, label: "lib", children: [{id: :core, label: "zaniah.rb"}, {id: :ui, label: "ui", children: ["controls.rb", "data.rb"]}]}], height: 190).w(240)]))
47
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
48
+ Zaniah::UI::Sparkline.new([2, 5, 3, 8, 6, 9], width: 190, height: 120, label: "Frames"),
49
+ Zaniah::UI::LineChart.new({Frames: [2, 5, 3, 8, 6, 9], Input: [1, 2, 2, 4, 3, 5]}, width: 300, height: 150),
50
+ Zaniah::UI::BarChart.new({GPU: [4, 7, 6], TUI: [3, 4, 5]}, width: 300, height: 150)]))
51
+ choices = Zaniah::Div.new.w_full.gap(10)
52
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
53
+ Zaniah::UI::Select.new([["Ruby", :ruby], ["Zig", :zig]], value: :ruby),
54
+ Zaniah::UI::Combobox.new(%w[Ruby Zig Rust], value: "Ruby", label: "Language"),
55
+ Zaniah::UI::MultiSelect.new(%w[GPU TUI A11y], value: %w[GPU A11y], label: "Targets")]))
56
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
57
+ Zaniah::UI::DatePicker.new("2026-09-13"), Zaniah::UI::TimePicker.new("14:30"),
58
+ Zaniah::UI::ColorPicker.new("#2563eb")]))
59
+ workspaces = Zaniah::Div.new.w_full.gap(12)
60
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
61
+ Zaniah::UI::SplitPane.new(Zaniah::UI::Label.new("Left pane"), Zaniah::UI::Label.new("Right pane")).w(300).h(100),
62
+ Zaniah::UI::Resizable.new(Zaniah::UI::Label.new("Resize me"), width: 180, height: 100),
63
+ Zaniah::UI::ListView.new(%w[Alpha Beta Gamma Delta], height: 100, selected: 1).w(180)]))
64
+ .child(Zaniah::UI::DockPanel.new(
65
+ center: Zaniah::UI::Label.new("Editor"), top: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Top", size: :sm)),
66
+ left: Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Files", size: :sm), width: 90),
67
+ bottom: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm))).w(420).h(120))
68
+ editors = Zaniah::Div.new.w_full.flex_row.gap(12).children([
69
+ Zaniah::UI::CodeEditor.new("def hello\n :world\nend\n", language: :ruby).w(390).h(150),
70
+ Zaniah::UI::RichText.new([{text: "Rich ", size: 20}, {text: "text", color: "#2563eb", size: 20}]).w(260)])
71
+ Zaniah::Div.new.h(2480).p(16).gap(12)
72
+ .child(section("Foundation", foundation, height: 180))
73
+ .child(section("Actions",
74
+ Zaniah::UI::Button.new("Primary").icon(:check).on_click(&clicks),
75
+ Zaniah::UI::IconButton.new(:menu, label: "Menu", variant: :secondary),
76
+ Zaniah::UI::ToggleButton.new("Toggle"),
77
+ Zaniah::UI::ButtonGroup.new(Zaniah::UI::Button.new("One", size: :sm), Zaniah::UI::Button.new("Two", size: :sm)),
78
+ Zaniah::UI::Checkbox.new("Mixed", value: :mixed), Zaniah::UI::Radio.new("Radio", value: true),
79
+ Zaniah::UI::RadioGroup.new([["A", :a], ["B", :b]], value: :a), Zaniah::UI::Switch.new("Switch", value: true)))
80
+ .child(section("Values",
81
+ Zaniah::UI::Slider.new(value: 35, label: "Slider"), Zaniah::UI::RangeSlider.new(value: [20, 80], label: "Range"),
82
+ Zaniah::UI::ProgressBar.new(value: 60), Zaniah::UI::Spinner.new, Zaniah::UI::Meter.new(value: 72, low: 25, high: 80)))
83
+ .child(section("Text input",
84
+ Zaniah::UI::TextField.new("Ruby", label: "Text field", clearable: true),
85
+ Zaniah::UI::TextArea.new("Multiple\nlines", label: "Text area", rows: 3),
86
+ Zaniah::UI::SearchInput.new("", placeholder: "Search"), Zaniah::UI::PasswordInput.new("secret", label: "Password"),
87
+ Zaniah::UI::NumberInput.new(4, min: 0, max: 10), Zaniah::UI::TagInput.new(%w[ruby ui], placeholder: "Tags")))
88
+ .child(section("Navigation",
89
+ Zaniah::UI::MenuBar.new([["File", [["Open", clicks], ["Disabled", nil]]]]),
90
+ Zaniah::UI::Dropdown.new("Choose", items: [["First", 1], ["Second", 2]]),
91
+ Zaniah::UI::Breadcrumb.new([["Home", clicks], ["Gallery", nil]]), Zaniah::UI::Pagination.new(page: 4, pages: 12)))
92
+ .child(section("Structure", structure, height: 235))
93
+ .child(section("Variants", variants, height: 285))
94
+ .child(section("Data", data, height: 440))
95
+ .child(section("Choice and pickers", choices, height: 210))
96
+ .child(section("Workspace layout", workspaces, height: 300))
97
+ .child(section("Editors", editors, height: 210))
98
+ .child(section("Forms", Zaniah::UI::Form.new.field(name: :email, label: "Release email", value: "ruby@example.com", validation: validation).w(320), height: 150))
99
+ end
100
+
101
+ def overlay(name, window)
102
+ anchor = Zaniah::Bounds.new(280, 80, 80, 32)
103
+ content = Zaniah::UI::Label.new("Overlay content")
104
+ items = [["First command", ->(*) {}], ["Unavailable", nil], ["Close", ->(*) {}]]
105
+ case name
106
+ when /\Atooltip-(top|bottom|left|right)\z/ then Zaniah::UI::Tooltip.new("Helpful text", anchor: anchor, side: Regexp.last_match(1).to_sym)
107
+ when /\Apopover-(top|bottom|left|right)\z/ then Zaniah::UI::Popover.new(content, anchor: anchor, side: Regexp.last_match(1).to_sym)
108
+ when /\Adrawer-(left|right)\z/ then Zaniah::UI::Drawer.new(content, title: "Drawer", side: Regexp.last_match(1).to_sym)
109
+ when /\Atoast-(info|success|warning|danger)\z/ then Zaniah::UI::Toast.new("Saved", variant: Regexp.last_match(1).to_sym)
110
+ when "tooltip" then Zaniah::UI::Tooltip.new("Helpful text", anchor: anchor)
111
+ when "popover" then Zaniah::UI::Popover.new(content, anchor: anchor)
112
+ when "menu" then Zaniah::UI::Menu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
113
+ when "context-menu" then Zaniah::UI::ContextMenu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
114
+ when "modal" then Zaniah::UI::Modal.new(content, title: "Modal")
115
+ when "dialog" then Zaniah::UI::Dialog.new(content, title: "Dialog")
116
+ when "drawer" then Zaniah::UI::Drawer.new(content, title: "Drawer")
117
+ when "toast" then Zaniah::UI::Toast.new("Saved", variant: :success)
118
+ when "command-palette" then Zaniah::UI::CommandPalette.new(items, open: true)
119
+ end
120
+ end
121
+ end
122
+
123
+ if $PROGRAM_NAME == __FILE__
124
+ backend_option = ARGV.find { |argument| argument.start_with?("--backend=") }&.split("=", 2)&.last || "headless"
125
+ backend = if backend_option == "native"
126
+ RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux
127
+ else
128
+ backend_option.to_sym
129
+ end
130
+ window = Zaniah::Platform.open_window(backend: backend, width: 900, height: 700, title: "Zaniah component gallery")
131
+ unless backend == :tui
132
+ require "alhena"
133
+ font_path = File.expand_path("../assets/fonts/Abel-Regular.ttf", __dir__)
134
+ window.text_system = Zaniah::TextSystem::Renderer.new(font: Alhena::Font.open(font_path), font_db: Zaniah::TextSystem::FontDB.new(paths: []))
135
+ end
136
+ overlay_name = ARGV.find { |argument| argument.start_with?("--overlay=") }&.split("=", 2)&.last
137
+ scroll = Zaniah::ScrollView.new(scrollbar: :overlay).child(Gallery.page)
138
+ root = Zaniah::Div.new.child(scroll)
139
+ root.child(Gallery.overlay(overlay_name, window)) if overlay_name
140
+ window.draw { root }
141
+
142
+ if ARGV.include?("--check")
143
+ window.tick
144
+ output = ARGV.find { |argument| argument.end_with?(".png") }
145
+ window.write_png(output) if output && backend != :tui
146
+ raise "gallery did not render" if window.scene.commands.empty?
147
+ if !%i[headless tui].include?(backend) && (!window.device.respond_to?(:draw_calls) || !window.device.draw_calls&.positive?)
148
+ raise "gallery produced no native GPU draw calls"
149
+ end
150
+ puts "gallery: #{backend} rendered #{window.scene.commands.length} scene commands"
151
+ window.close
152
+ else
153
+ window.run
154
+ end
155
+ end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Run inside a disposable D-Bus/X11 session with IBus and Mozc installed.
3
+ # Run inside a disposable D-Bus/X11 session with a Japanese IBus engine installed.
4
4
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
5
  require "zaniah"
6
6
  require "open3"
7
7
 
8
+ engine = "kkc"
9
+
8
10
  window = Zaniah::Platform.open_window(backend: :linux, display_server: :x11, width: 400, height: 240, title: "IBus Japanese input check")
9
11
  events = []
10
12
  window.on_input { |event| events << event }
@@ -17,7 +19,7 @@ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
17
19
  window.tick
18
20
  sleep 0.01
19
21
  end
20
- selector = Process.spawn("ibus", "engine", "mozc-on")
22
+ selector = Process.spawn("ibus", "engine", engine)
21
23
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
22
24
  until (result = Process.waitpid2(selector, Process::WNOHANG))
23
25
  raise "IBus engine selection timed out" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
@@ -25,9 +27,14 @@ until (result = Process.waitpid2(selector, Process::WNOHANG))
25
27
  sleep 0.01
26
28
  end
27
29
  current, = Open3.capture2e("ibus", "engine")
28
- # IBus CLI may return 1 after success when Mozc's layout is "default".
29
- # Its active engine, not setxkbmap's exit status, determines readiness.
30
- raise "Mozc engine selection failed: #{result.last.inspect}, current=#{current.inspect}" unless current.strip == "mozc-on"
30
+ # Engine selection may report setxkbmap's status after switching successfully.
31
+ # The active engine determines readiness.
32
+ raise "IBus engine selection failed: #{result.last.inspect}, current=#{current.inspect}" unless current.strip == engine
33
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
34
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
35
+ window.tick
36
+ sleep 0.01
37
+ end
31
38
  sender = Process.spawn("xdotool", "type", "--delay", "100", "nihon")
32
39
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
33
40
  until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
@@ -46,5 +53,5 @@ preedit = events.grep(Zaniah::Input::Composition).map(&:text)
46
53
  committed = events.grep(Zaniah::Input::TextInput).map(&:text).join
47
54
  raise "Japanese preedit missing: #{events.inspect}" unless preedit.any? { |text| text.match?(/[にほ日]/) }
48
55
  raise "Japanese commit missing or duplicated: #{events.inspect}" unless ["日本", "にほん"].include?(committed)
49
- puts "IBus/Mozc: preedit=#{preedit.inspect}, committed=#{committed.inspect}"
56
+ puts "IBus/#{engine}: preedit=#{preedit.inspect}, committed=#{committed.inspect}"
50
57
  window.close