zaniah 0.6.2 → 0.6.3

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.
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module UI
5
+ class RichTextSurface < Element
6
+ Run = Data.define(:start, :finish, :layout, :x, :style, :color, :size)
7
+ Line = Data.define(:start, :finish, :x, :y, :width, :height, :ascent, :runs, :paragraph, :first_in_paragraph)
8
+
9
+ def initialize(owner)
10
+ super()
11
+ @owner = owner
12
+ @layout_cache = {}
13
+ return unless owner.instance_variable_get(:@selectable) || owner.editable?
14
+
15
+ if owner.instance_variable_get(:@selectable)
16
+ on_mouse_down { |event, _cx| begin_selection(event) }
17
+ on_drag { |event, _cx| extend_selection(event) }
18
+ end
19
+ focusable(context: owner.editable? ? {in_text_field: true} : {in_text: true}) do |action|
20
+ owner.editable? ? owner.text_action(action) : action == :select_all && select_all
21
+ end
22
+ @focus_handle.on_input = ->(event) { owner.input(event) }
23
+ end
24
+
25
+ def request_layout(cx)
26
+ @cx = cx
27
+ @display_text = @owner.display_text
28
+ @visual_spans = display_spans
29
+ width = resolved_width(@style[:width], cx.window.content_size.width)
30
+ @font_size = @style[:font_size] || cx.theme.typography.size_md
31
+ @lines = layout_lines(width, cx)
32
+ @layout_node = Layout::Node.new(style: @style, measure: lambda do |available, _height|
33
+ target = @style[:width].is_a?(Numeric) ? width : [available, 0].max
34
+ @lines = layout_lines(target, cx) if target != width
35
+ [@lines.map { |line| line.x + line.width }.max || 0, @lines.last ? @lines.last.y + @lines.last.height : line_height]
36
+ end)
37
+ end
38
+
39
+ def prepaint(bounds, state, cx)
40
+ @bounds = bounds
41
+ super
42
+ end
43
+
44
+ def paint(bounds, state, prepaint, cx)
45
+ super
46
+ selection = @owner.selection.range
47
+ paint_selection(bounds, selection, cx) if @owner.instance_variable_get(:@selectable) && !selection.begin.eql?(selection.end)
48
+ @lines.each do |line|
49
+ line.runs.each do |run|
50
+ next unless run.layout && cx.text_system
51
+ cx.text_system.paint_line(cx.scene, run.layout, x: bounds.x + line.x + run.x,
52
+ y: bounds.y + line.y + line.ascent, color: run.color)
53
+ if run.style[:link]
54
+ cx.scene.underline(bounds.x + line.x + run.x, bounds.y + line.y + line.ascent + run.size,
55
+ [run.layout.width, 1].max, color: run.color)
56
+ end
57
+ end
58
+ paint_list_marker(bounds, line, cx)
59
+ end
60
+ paint_composition(bounds, cx) if @owner.buffer.composition
61
+ paint_caret(bounds, cx) if @owner.editable? && @owner.selection.collapsed? && cx.dispatcher.focused == @focus_handle
62
+ cx.window.text_runs.concat(@lines.flat_map do |line|
63
+ line.runs.map { |run| [bounds.x + line.x + run.x, bounds.y + line.y, @display_text.byteslice(run.start...run.finish), run.color] }
64
+ end) if cx.window.respond_to?(:text_runs)
65
+ end
66
+
67
+ def hit_test(point)
68
+ line = @lines.find { |item| point.y < item.y + item.height } || @lines.last
69
+ return 0 unless line
70
+ run = line.runs.find { |item| point.x < line.x + item.x + item.layout&.width.to_f } || line.runs.last
71
+ return line.start unless run
72
+ run.start + (run.layout ? run.layout.index_for_x(point.x - line.x - run.x) : 0).clamp(0, run.finish - run.start)
73
+ end
74
+
75
+ private
76
+
77
+ def resolved_width(value, available)
78
+ value = value.resolve(available) if value.is_a?(Length)
79
+ value.is_a?(Numeric) ? [value, 0].max : available
80
+ end
81
+
82
+ def display_spans
83
+ spans = @owner.spans.map { |span| [span.start, span.finish, span.style] }
84
+ composition = @owner.buffer.composition
85
+ return spans unless composition
86
+ range = @owner.selection.range
87
+ at, delta = range.begin, composition.text.bytesize - range.size
88
+ style = @owner.style_at(at)
89
+ spans = spans.flat_map do |first, finish, value|
90
+ if finish <= range.begin
91
+ [[first, finish, value]]
92
+ elsif first >= range.end
93
+ [[first + delta, finish + delta, value]]
94
+ else
95
+ pieces = []
96
+ pieces << [first, range.begin, value] if first < range.begin
97
+ pieces << [range.begin + composition.text.bytesize, finish + delta, value] if finish > range.end
98
+ pieces
99
+ end
100
+ end
101
+ spans << [at, at + composition.text.bytesize, style] unless style.empty?
102
+ spans.sort_by(&:first)
103
+ end
104
+
105
+ # ponytail: each candidate prefix is shaped during line breaking; long unbroken paragraphs are quadratic. Cache wins for repeated fragments; add a streaming breaker if profiling proves this hot.
106
+ def layout_lines(width, cx)
107
+ breaker = TextSystem::LineBreaker.new(@display_text, wrap: :word, kinsoku: :push)
108
+ ranges = breaker.ranges_with_offsets(width) do |_candidate, first, finish|
109
+ fragments(first, finish).sum { |from, to, style| fragment_layout(from, to, style, cx).width }
110
+ end
111
+ y = 0.0
112
+ ranges.each_with_index.map do |range, index|
113
+ paragraph_index = @display_text.byteslice(0...range.begin).count("\n")
114
+ para = @owner.paragraph_style_at([range.begin, @owner.text.bytesize].min)
115
+ indent = Integer(para[:level] || 0) * 16 + (para[:list] && para[:list] != :none ? 20 : 0)
116
+ parts = fragments(range.begin, range.end).map do |first, finish, style|
117
+ Run.new(first, finish, fragment_layout(first, finish, style, cx), 0, style,
118
+ style[:color] || cx.theme.colors.text, style[:size] || @font_size)
119
+ end
120
+ width_used = parts.sum { |part| part.layout.width }
121
+ available = [width - indent, 0].max
122
+ next_range = ranges[index + 1]
123
+ next_paragraph = next_range && @display_text.byteslice(0...next_range.begin).count("\n")
124
+ if para[:align] == :justify && next_range && next_paragraph == paragraph_index
125
+ parts = justified_parts(parts, range, available, width_used)
126
+ width_used = available
127
+ end
128
+ align = para[:align] || :start
129
+ offset = case align
130
+ when :center then indent + [(available - width_used) / 2.0, 0].max
131
+ when :end then indent + [available - width_used, 0].max
132
+ else indent
133
+ end
134
+ x = 0.0
135
+ runs = parts.map do |part|
136
+ Run.new(part.start, part.finish, part.layout, x, part.style, part.color, part.size).tap { x += part.layout.width }
137
+ end
138
+ ascent = runs.map { |run| run.layout&.ascent || run.size * 0.8 }.max || @font_size * 0.8
139
+ descent = runs.map { |run| run.layout&.descent || run.size * 0.2 }.max || @font_size * 0.2
140
+ height = [line_height, ascent + descent].max
141
+ previous_break = @display_text.byteslice(0...range.begin).rindex("\n")
142
+ item = Line.new(range.begin, range.end, offset, y, width_used, height, ascent, runs, paragraph_index,
143
+ range.begin == (previous_break ? previous_break + 1 : 0))
144
+ y += height
145
+ item
146
+ end
147
+ end
148
+
149
+ def justified_parts(parts, range, available, line_width)
150
+ return parts if available <= line_width
151
+ spaces = []
152
+ @display_text.byteslice(range).to_enum(:scan, /\s+/).each { spaces << Regexp.last_match.end(0) }
153
+ return parts if spaces.empty?
154
+ extra = (available - line_width) / spaces.length
155
+ parts.map do |part|
156
+ layout = part.layout
157
+ shift = ->(byte) { spaces.count { |finish| finish <= byte } * extra }
158
+ carets = layout.carets.map { |byte, x| [byte, x + shift.call(part.start + byte - range.begin)] }.freeze
159
+ glyphs = layout.glyphs.map do |glyph|
160
+ TextSystem::Glyph.new(glyph.font, glyph.id, glyph.start, glyph.finish,
161
+ glyph.x + shift.call(part.start + glyph.start - range.begin), glyph.advance)
162
+ end.freeze
163
+ adjusted = TextSystem::LineLayout.new(layout.text, glyphs, layout.width + shift.call(part.finish - range.begin),
164
+ layout.ascent, layout.descent, layout.size, carets)
165
+ Run.new(part.start, part.finish, adjusted, part.x, part.style, part.color, part.size)
166
+ end
167
+ end
168
+
169
+ def fragments(first, finish)
170
+ return [] if first == finish
171
+ boundaries = [first, finish]
172
+ @visual_spans.each do |from, to, _style|
173
+ boundaries << from if from > first && from < finish
174
+ boundaries << to if to > first && to < finish
175
+ end
176
+ boundaries.uniq.sort.each_cons(2).map { |from, to| [from, to, style_at_display(from)] }
177
+ end
178
+
179
+ def style_at_display(offset)
180
+ span = @visual_spans.find { |first, finish, _| offset >= first && offset < finish }
181
+ span ? span[2] : {}
182
+ end
183
+
184
+ def fragment_layout(first, finish, style, cx)
185
+ text = @display_text.byteslice(first...finish)
186
+ size = style[:size] || @font_size
187
+ font = font_for(style, cx)
188
+ key = [text, size, font&.object_id]
189
+ @layout_cache[key] ||= cx.text_system&.layout_line(text, font: font, size: size) || approximate_layout(text, size)
190
+ end
191
+
192
+ def approximate_layout(text, size)
193
+ byte = 0
194
+ x = 0.0
195
+ carets = [[0, 0.0]]
196
+ text.grapheme_clusters.each do |cluster|
197
+ byte += cluster.bytesize
198
+ x += Unicode.width(cluster) * size * 0.6
199
+ carets << [byte, x]
200
+ end
201
+ TextSystem::LineLayout.new(text, [].freeze, x, size * 0.8, size * 0.2, size, carets)
202
+ end
203
+
204
+ def font_for(style, cx)
205
+ return style[:font] if style[:font]
206
+ return unless cx.text_system
207
+ font = cx.text_system.respond_to?(:font) ? cx.text_system.font : nil
208
+ return font unless style[:bold] || style[:italic]
209
+ database = cx.text_system.respond_to?(:font_db) ? cx.text_system.font_db : nil
210
+ database&.find(family: font&.family, weight: style[:bold] ? 700 : 400,
211
+ style: style[:italic] ? :italic : :normal) || font
212
+ rescue StandardError
213
+ font
214
+ end
215
+
216
+ def line_height = @font_size * 1.4
217
+
218
+ def paint_selection(bounds, range, cx)
219
+ @lines.each do |line|
220
+ first, finish = [range.begin, line.start].max, [range.end, line.finish].min
221
+ next if finish <= first
222
+ line.runs.each do |run|
223
+ from, to = [first, run.start].max, [finish, run.finish].min
224
+ next if to <= from || !run.layout
225
+ left = run.layout.x_for_index(from - run.start)
226
+ right = run.layout.x_for_index(to - run.start)
227
+ cx.scene.quad(bounds.x + line.x + run.x + left, bounds.y + line.y,
228
+ [right - left, 1].max, line.height, color: cx.theme.colors.selection)
229
+ end
230
+ end
231
+ end
232
+
233
+ def paint_list_marker(bounds, line, cx)
234
+ return unless line.first_in_paragraph
235
+ para = @owner.paragraph_style_at([line.start, @owner.text.bytesize].min)
236
+ marker = case para[:list]
237
+ when :bullet then "•"
238
+ when :ordered then "#{line.paragraph + 1}."
239
+ else return
240
+ end
241
+ layout = cx.text_system&.layout_line(marker, size: @font_size)
242
+ cx.text_system&.paint_line(cx.scene, layout, x: bounds.x + line.x - 18,
243
+ y: bounds.y + line.y + line.ascent, color: cx.theme.colors.text)
244
+ end
245
+
246
+ def paint_caret(bounds, cx)
247
+ offset = @owner.selection.range.begin + (@owner.buffer.composition&.selection&.first || 0)
248
+ line = @lines.find { |item| offset.between?(item.start, item.finish) } || @lines.last
249
+ return unless line
250
+ run = line.runs.find { |item| offset.between?(item.start, item.finish) } || line.runs.last
251
+ x = line.x + (run ? run.x + (run.layout ? run.layout.x_for_index((offset - run.start).clamp(0, run.finish - run.start)) : 0) : 0)
252
+ ascent, descent = run&.layout ? [run.layout.ascent, run.layout.descent] : [@font_size * 0.8, @font_size * 0.2]
253
+ y, height = line.y + line.ascent - ascent, ascent + descent
254
+ color = run&.color || cx.theme.colors.text
255
+ cx.scene.layer(Scene::LAYER_FOCUS_RING) { cx.scene.quad(bounds.x + x, bounds.y + y, 1, height, color: color) }
256
+ cx.window.ime_state = Bounds.new(bounds.x + x, bounds.y + y, 1, height)
257
+ end
258
+
259
+ def paint_composition(bounds, cx)
260
+ offset = @owner.selection.range.begin
261
+ finish = offset + @owner.buffer.composition.text.bytesize
262
+ first, last = point_for(offset), point_for(finish)
263
+ cx.scene.layer(Scene::LAYER_FOCUS_RING) do
264
+ cx.scene.underline(bounds.x + first.x, bounds.y + first.y + @font_size * 1.25,
265
+ [last.x - first.x, 1].max, color: cx.theme.colors.text)
266
+ end
267
+ end
268
+
269
+ def point_for(offset)
270
+ line = @lines.find { |item| offset.between?(item.start, item.finish) } || @lines.last
271
+ return Point.new(0, 0) unless line
272
+ run = line.runs.find { |item| offset.between?(item.start, item.finish) } || line.runs.last
273
+ x = line.x + (run ? run.x + run.layout.x_for_index((offset - run.start).clamp(0, run.finish - run.start)) : 0)
274
+ Point.new(x, line.y)
275
+ end
276
+
277
+ def begin_selection(event)
278
+ offset = hit_test(Point.new(event.position.x - @bounds.x, event.position.y - @bounds.y))
279
+ @owner.selection = event.click_count == 2 ? word_selection(offset) : TextSelection.new(offset)
280
+ @owner.instance_variable_get(:@cx)&.window&.request_frame
281
+ end
282
+
283
+ def extend_selection(event)
284
+ return unless @owner.selection
285
+ offset = hit_test(Point.new(event.position.x - @bounds.x, event.position.y - @bounds.y))
286
+ @owner.selection = TextSelection.new(@owner.selection.anchor, offset)
287
+ @owner.instance_variable_get(:@cx)&.window&.request_frame
288
+ end
289
+
290
+ def word_selection(offset)
291
+ range = Unicode.word_range_at(@owner.text, [offset, @owner.text.bytesize].min)
292
+ TextSelection.new(range.begin, range.end)
293
+ end
294
+
295
+ def select_all
296
+ @owner.selection = TextSelection.new(0, @owner.text.bytesize)
297
+ @owner.instance_variable_get(:@cx)&.window&.request_frame
298
+ true
299
+ end
300
+ end
301
+ end
302
+ end
@@ -35,6 +35,7 @@ module Zaniah
35
35
  end
36
36
 
37
37
  def select(range)
38
+ # The range addresses cells in row-major order.
38
39
  @selection = range && Range === range ? range : nil
39
40
  self
40
41
  end
@@ -74,13 +75,34 @@ module Zaniah
74
75
  cx.scene.quad(bounds.x + column * @cell_width, bounds.y + row * @cell_height,
75
76
  (run - column) * @cell_width, @cell_height, color: color(cell.background))
76
77
  end
78
+ if @selection&.cover?(row * @grid.columns + column)
79
+ cx.scene.quad(bounds.x + column * @cell_width, bounds.y + row * @cell_height,
80
+ @cell_width, @cell_height, color: "#ffffff33")
81
+ end
77
82
  if cell.width != 0 && cell.text != " "
78
83
  paint_text(cell.text, cell.foreground || @foreground, bounds, row, column, cx)
79
84
  end
85
+ paint_attributes(cell, bounds, row, column, cx)
80
86
  column += 1
81
87
  end
82
88
  end
83
89
 
90
+ def paint_attributes(cell, bounds, row, column, cx)
91
+ attributes = cell.attributes || {}
92
+ return unless cx.scene.respond_to?(:underline)
93
+
94
+ x = bounds.x + column * @cell_width
95
+ y = bounds.y + row * @cell_height
96
+ if attributes[:underline]
97
+ thickness = attributes[:underline] == 2 ? 2 : 1
98
+ cx.scene.underline(x, y + @cell_height - 2, @cell_width, color: color(cell.foreground || @foreground), thickness: thickness)
99
+ end
100
+ if attributes[:strikethrough]
101
+ cx.scene.underline(x, y + @cell_height * 0.52, @cell_width,
102
+ color: color(cell.foreground || @foreground))
103
+ end
104
+ end
105
+
84
106
  def paint_text(text, tint, bounds, row, column, cx)
85
107
  return unless cx.text_system
86
108
  line = cx.text_system.layout_line(text, size: @cell_height * 0.78)
data/lib/zaniah/ui.rb CHANGED
@@ -13,9 +13,11 @@ require_relative "ui/structure"
13
13
  require_relative "ui/modal"
14
14
  require_relative "ui/layout_components"
15
15
  require_relative "ui/pane_grid"
16
+ require_relative "ui/grid"
16
17
  require_relative "ui/data"
17
18
  require_relative "ui/tree_view"
18
19
  require_relative "ui/charts"
19
20
  require_relative "ui/forms"
20
21
  require_relative "ui/editors"
22
+ require_relative "ui/rich_text_surface"
21
23
  require_relative "ui/terminal_view"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
- VERSION = "0.6.2"
4
+ VERSION = "0.6.3"
5
5
  end
data/lib/zaniah.rb CHANGED
@@ -25,6 +25,7 @@ require_relative "zaniah/text_selection"
25
25
  require_relative "zaniah/text_buffer"
26
26
  require_relative "zaniah/scene"
27
27
  require_relative "zaniah/png"
28
+ require_relative "zaniah/jpeg"
28
29
  require_relative "zaniah/gif"
29
30
  require_relative "zaniah/gpu"
30
31
  require_relative "zaniah/layout"
data/sig/elements.rbs CHANGED
@@ -125,6 +125,8 @@ module Zaniah
125
125
  end
126
126
  class Image < Element
127
127
  def initialize: (String path) -> void
128
+ def self.from_bytes: (String bytes, ?format: :auto | :png | :jpeg | :jpg | :gif) -> Image
129
+ attr_reader frames: Array[GIF::Frame]?
128
130
  end
129
131
  class Canvas < Element
130
132
  def initialize: () { (Bounds, FrameContext) -> void } -> void
data/sig/native.rbs CHANGED
@@ -19,6 +19,7 @@ module Zaniah
19
19
  def cursor_style=: (cursor style) -> void
20
20
  def open_url: (String url) -> void
21
21
  def toggle_fullscreen: () -> void
22
+ def move_to_display: (Display display) -> bool
22
23
  end
23
24
  end
24
25
  module Linux
@@ -37,6 +38,7 @@ module Zaniah
37
38
  def cursor_style=: (cursor style) -> void
38
39
  def open_url: (String url) -> bool
39
40
  def toggle_fullscreen: () -> void
41
+ def move_to_display: (Display display) -> bool
40
42
  end
41
43
  class WaylandWindow < Headless::Window
42
44
  def initialize: (?gpu: :opengl | :gl, ?width: Numeric, ?height: Numeric, ?title: String, ?scale_factor: Numeric) -> void
@@ -53,6 +55,10 @@ module Zaniah
53
55
  def cursor_style=: (cursor style) -> void
54
56
  def open_url: (String url) -> bool
55
57
  def toggle_fullscreen: () -> void
58
+ # Wayland does not support arbitrary client window placement.
59
+ def move_to_display: (Display display) -> bool
60
+ # Best-effort output preference; the compositor decides whether to honor it.
61
+ def fullscreen_on: (Display display) -> bool
56
62
  end
57
63
  end
58
64
  module Windows
@@ -72,6 +78,7 @@ module Zaniah
72
78
  def cursor_style=: (cursor style) -> void
73
79
  def open_url: (String url) -> void
74
80
  def toggle_fullscreen: () -> void
81
+ def move_to_display: (Display display) -> bool
75
82
  end
76
83
  class Terminal
77
84
  attr_reader pid: Integer
data/sig/platform.rbs CHANGED
@@ -85,6 +85,8 @@ module Zaniah
85
85
  def set_cursor: (cursor style) -> cursor
86
86
  def cursor_style=: (cursor style) -> cursor
87
87
  def displays: () -> Array[Display]
88
+ # Raises Error for backends without native physical-display placement.
89
+ def move_to_display: (Display display) -> bool
88
90
  def popup: () -> Popup?
89
91
  def resize: (Numeric width, Numeric height) -> void
90
92
  def input: (Input::event event) -> void
data/sig/ui.rbs CHANGED
@@ -290,6 +290,36 @@ module Zaniah
290
290
  def accessibility_action: (Accessibility::Node node, Symbol action) -> bool?
291
291
  def accessibility_value: (Accessibility::Node node, Numeric value) -> bool?
292
292
  end
293
+ class Grid < Component
294
+ class Area < Data
295
+ attr_reader rows: Range[Integer]
296
+ attr_reader columns: Range[Integer]
297
+ def self.new: (Range[Integer] rows, Range[Integer] columns) -> Area
298
+ | (rows: Range[Integer], columns: Range[Integer]) -> Area
299
+ end
300
+ attr_reader selection: Array[Area]
301
+ attr_reader scroll_state: ScrollState
302
+ attr_reader visible_rows: Range[Integer]?
303
+ attr_reader visible_columns: Range[Integer]?
304
+ def initialize: (rows: Integer, columns: Integer, ?row_height: Numeric | Proc, ?column_width: Numeric | Proc, ?frozen_rows: Integer, ?frozen_columns: Integer, ?estimated_row_height: Numeric, ?estimated_column_width: Numeric, ?overscan: Integer) { (Integer, Integer, Bounds, FrameContext) -> _Renderable? } -> void
305
+ def selection=: (Array[Area] areas) -> Array[Area]
306
+ def on_select: () { (Array[Area], Input::MouseDown?, FrameContext?) -> void } -> self
307
+ def on_edit: () { (Integer, Integer, Input::MouseDown?, FrameContext?) -> void } -> self
308
+ def on_fill: () { (Area, Area, FrameContext?) -> void } -> self
309
+ def on_resize: () { (Symbol, Integer, Numeric, FrameContext?) -> void } -> self
310
+ def set_row_height: (Integer index, Numeric height) -> self
311
+ def set_column_width: (Integer index, Numeric width) -> self
312
+ def hide_row: (Integer index) -> self
313
+ def unhide_row: (Integer index) -> self
314
+ def hide_column: (Integer index) -> self
315
+ def unhide_column: (Integer index) -> self
316
+ def hide_rows: (Array[Integer] indices, hidden: bool) -> self
317
+ def hide_columns: (Array[Integer] indices, hidden: bool) -> self
318
+ def row_hidden?: (Integer index) -> bool
319
+ def column_hidden?: (Integer index) -> bool
320
+ def freeze_panes: (rows: Integer, columns: Integer) -> self
321
+ def scroll_to: (row: Integer, column: Integer, ?align: :start | :center | :end | :nearest) -> self
322
+ end
293
323
  class Resizable < Component
294
324
  attr_reader width: Float
295
325
  attr_reader height: Float
@@ -378,6 +408,22 @@ module Zaniah
378
408
  end
379
409
  class BarChart < LineChart
380
410
  end
411
+ class StackedBarChart < BarChart
412
+ end
413
+ class PieChart < Sparkline
414
+ attr_reader slices: Array[[String, Float]]
415
+ def initialize: (Hash[untyped, Numeric] | Array[Numeric] data, ?width: Numeric, ?height: Numeric, ?colors: Array[color]?, ?label: String) -> void
416
+ end
417
+ class DonutChart < PieChart
418
+ def initialize: (Hash[untyped, Numeric] | Array[Numeric] data, ?width: Numeric, ?height: Numeric, ?colors: Array[color]?, ?label: String) -> void
419
+ end
420
+ class ScatterChart < Sparkline
421
+ attr_reader points: Hash[String, Array[[Float, Float]]]
422
+ def initialize: (Hash[untyped, Array[[Numeric, Numeric]]] | Array[[Numeric, Numeric]] series, ?width: Numeric, ?height: Numeric, ?colors: Array[color]?, ?label: String) -> void
423
+ end
424
+ class AreaChart < LineChart
425
+ def initialize: (Hash[untyped, Array[Numeric]] | Array[Numeric] series, ?width: Numeric, ?height: Numeric, ?colors: Array[color]?, ?stacked: bool, ?label: String) -> void
426
+ end
381
427
  class CodeEditor < Component
382
428
  attr_reader buffer: TextBuffer
383
429
  def initialize: (?String | TextBuffer value, ?language: Symbol?, ?line_numbers: bool, ?read_only: bool) -> void
@@ -385,8 +431,27 @@ module Zaniah
385
431
  def on_change: () { (String, FrameContext?) -> void } -> self
386
432
  end
387
433
  class RichText < Component
388
- attr_reader runs: Array[Hash[Symbol, untyped]]
389
- def initialize: (Array[untyped] runs, ?selectable: bool) -> void
434
+ class Span < Data
435
+ attr_reader start: Integer
436
+ attr_reader finish: Integer
437
+ attr_reader style: Hash[Symbol, untyped]
438
+ end
439
+ attr_reader buffer: TextBuffer
440
+ attr_reader selection: TextSelection
441
+ attr_reader paragraph_styles: Array[Hash[Symbol, untyped]]
442
+ def initialize: (String | Array[untyped] runs, ?selectable: bool, ?editable: bool) -> void
443
+ def text: () -> String
444
+ def runs: () -> Array[Hash[Symbol, untyped]]
445
+ def spans: () -> Array[Span]
446
+ def editable?: () -> bool
447
+ def selection=: (TextSelection value) -> TextSelection
448
+ def on_change: () { (String, RichText) -> void } -> self
449
+ def on_select: () { (TextSelection, RichText) -> void } -> self
450
+ def apply: (Range[Integer] range, Hash[Symbol, untyped] style) -> self
451
+ def insert: (Integer offset, String value, ?style: Hash[Symbol, untyped]?) -> self
452
+ def delete: (Range[Integer] range) -> self
453
+ def replace: (Range[Integer] range, String value, ?style: Hash[Symbol, untyped]?) -> self
454
+ def paragraph_style: (Range[Integer] range, ?align: :start | :center | :end | :justify?, ?list: :none | :bullet | :ordered?, ?level: Integer?) -> self
390
455
  end
391
456
 
392
457
  class Validation
data/sig/zaniah.rbs CHANGED
@@ -172,4 +172,18 @@ module Zaniah
172
172
  def self.decode: (String bytes, ?max_pixels: Integer) -> [Integer, Integer, String]
173
173
  def self.write: (String path, Integer width, Integer height, String rgba) -> Integer
174
174
  end
175
+ module JPEG
176
+ class Error < ArgumentError
177
+ end
178
+ def self.decode: (String bytes, ?max_pixels: Integer) -> [Integer, Integer, String]
179
+ end
180
+
181
+ module GIF
182
+ class Frame < Data
183
+ attr_reader pixels: String
184
+ attr_reader delay: Numeric
185
+ attr_reader disposal: Integer
186
+ end
187
+ def self.decode: (String bytes, ?max_pixels: Integer) -> [Integer, Integer, Array[Frame]]
188
+ end
175
189
  end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.6.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-21 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: alhena
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: 0.2.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 0.4.0
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: 0.2.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.4.0
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: rexml
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -84,6 +90,7 @@ files:
84
90
  - docs/adr/009-grid-scope.md
85
91
  - docs/adr/010-text-overlay-layout.md
86
92
  - docs/adr/011-stable-accessibility-identity.md
93
+ - docs/adr/012-productivity-components.md
87
94
  - docs/adr/README.md
88
95
  - docs/animation.md
89
96
  - docs/components.md
@@ -149,6 +156,7 @@ files:
149
156
  - docs/gallery/tooltip-top-dark.png
150
157
  - docs/gallery/tooltip-top-high_contrast.png
151
158
  - docs/gallery/tooltip-top-light.png
159
+ - docs/images.md
152
160
  - docs/index.html
153
161
  - docs/layout.md
154
162
  - docs/media/overview.apng
@@ -255,6 +263,7 @@ files:
255
263
  - lib/zaniah/input/scroll_wheel.rb
256
264
  - lib/zaniah/input/text_input.rb
257
265
  - lib/zaniah/interactivity.rb
266
+ - lib/zaniah/jpeg.rb
258
267
  - lib/zaniah/layout.rb
259
268
  - lib/zaniah/layout/available_space.rb
260
269
  - lib/zaniah/layout/engine.rb
@@ -347,12 +356,14 @@ files:
347
356
  - lib/zaniah/ui/data.rb
348
357
  - lib/zaniah/ui/editors.rb
349
358
  - lib/zaniah/ui/forms.rb
359
+ - lib/zaniah/ui/grid.rb
350
360
  - lib/zaniah/ui/layout_components.rb
351
361
  - lib/zaniah/ui/modal.rb
352
362
  - lib/zaniah/ui/overlays.rb
353
363
  - lib/zaniah/ui/pane_grid.rb
354
364
  - lib/zaniah/ui/pickers.rb
355
365
  - lib/zaniah/ui/primitives.rb
366
+ - lib/zaniah/ui/rich_text_surface.rb
356
367
  - lib/zaniah/ui/scrollbar.rb
357
368
  - lib/zaniah/ui/structure.rb
358
369
  - lib/zaniah/ui/terminal_view.rb