zaniah 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 905d6e52f6013be2a7b8e0223ece509526a9cde3a6198c8386e8b59903bf2dde
4
- data.tar.gz: 57db74ac492c60d4d0f310c25596005c89faa706e2c8750cb6322b8091ac3671
3
+ metadata.gz: 00bd64bac42b39487e5b1d5d5c8c259fe2ced7a238dd07d6b073e72b5c3023f7
4
+ data.tar.gz: e4bf0fbcb4d460a26e47a0abdd941e4a1904906cc6e98546f2ec42a9cac2517f
5
5
  SHA512:
6
- metadata.gz: 27209b822cd741f79110228e9ddefc63eccf3eee8ffe64f2467570c4dd9fbc126176cf1e39289d8afa3b144577335809a1aa7b77d21deba013050e32eb374fcf
7
- data.tar.gz: 739e4c67fd1de82b7fce3e0682aa9ecb1380cdf629f315f61387989f64d89379247840e6d691a36e0d17bb148a30a7eb5fa82cd5d749857b6592c864e2880340
6
+ metadata.gz: 598ddd2173a36d1bcd284d713da20d36202f679e1d3ef3eaba5c8338120aeeb9e2b30ed3d02d926d09df0608e3d34a50dc3684226bec0ac97005faee9cf511ff
7
+ data.tar.gz: 6ef86677fcd7e1bcf1fcc76b1917cf499a10d099a6e97b421de5bd4cf7a4f42040d022f9e8cceb4baac54eee5842494fbf01a7f911bfb7863bb0ce43d0c6339d
data/CHANGELOG.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.6.2 — 2026-09-21
4
4
 
5
- - Add validated declarative element descriptions with event routing, tree diffs, and keyed surface reuse.
5
+ - Add GIF decoding and a damage-aware terminal grid view.
6
+
7
+ ## 0.6.1 — 2026-09-21
8
+
9
+ - Add a deterministic headless demo image and regeneration task.
6
10
 
7
11
  ## 0.6.0 — 2026-09-19
8
12
 
9
- - Add the validated declarative Describe layer for remote UI trees.
13
+ - Add the validated declarative Describe layer for remote UI trees, including event routing, tree diffs, keyed surface reuse, and element descriptions.
10
14
 
11
15
  ## 0.5.3 — 2026-09-17
12
16
 
data/README.md CHANGED
@@ -28,6 +28,8 @@ Zaniah is a pure Ruby UI toolkit for building desktop and terminal interfaces.
28
28
  It renders through native GPU APIs, a deterministic headless backend, or an ANSI
29
29
  terminal without extension compilation or a rendering subprocess.
30
30
 
31
+ ![Zaniah headless rendering](docs/media/overview.png)
32
+
31
33
  Zaniah is named for a star in Virgo. The toolkit is the drawing surface beneath
32
34
  the editor, providing the native rendering layer for its UI.
33
35
 
Binary file
Binary file
data/lib/zaniah/gif.rb ADDED
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ # Small, dependency-free GIF87a/GIF89a decoder. Frames are returned as RGBA
5
+ # snapshots so callers can choose whether to animate or keep the first frame.
6
+ module GIF
7
+ Frame = Data.define(:pixels, :delay, :disposal)
8
+ module_function
9
+
10
+ def decode(bytes, max_pixels: 16_777_216)
11
+ raise ArgumentError, "not a GIF" unless bytes.start_with?("GIF87a", "GIF89a")
12
+ reader = Reader.new(bytes)
13
+ reader.skip(6)
14
+ width, height, packed, background, = reader.bytes(7).unpack("vvCCC")
15
+ raise ArgumentError, "GIF too large" unless width.positive? && height.positive? && width * height <= max_pixels
16
+ global_palette = palette(reader, packed)
17
+ canvas = Array.new(width * height, [0, 0, 0, 0].freeze)
18
+ frames, gce = [], {delay: 0, disposal: 0, transparent: nil}
19
+
20
+ until reader.eof?
21
+ marker = reader.byte
22
+ case marker
23
+ when 0x3b then break
24
+ when 0x21
25
+ label = reader.byte
26
+ if label == 0xf9
27
+ reader.byte # block size
28
+ flags, delay, transparent = reader.bytes(4).unpack("CvvC")
29
+ reader.byte
30
+ gce = {delay: delay * 0.01, disposal: (flags >> 2) & 7, transparent: flags.anybits?(1) ? transparent : nil}
31
+ else
32
+ skip_subblocks(reader)
33
+ end
34
+ when 0x2c
35
+ frame = read_frame(reader, global_palette, width, height, canvas, gce)
36
+ frames << frame[:frame]
37
+ canvas = frame[:canvas]
38
+ gce = {delay: 0, disposal: 0, transparent: nil}
39
+ else
40
+ raise ArgumentError, "invalid GIF block"
41
+ end
42
+ end
43
+ raise ArgumentError, "GIF has no frames" if frames.empty?
44
+
45
+ [width, height, frames.freeze]
46
+ rescue IndexError => error
47
+ raise ArgumentError, "truncated GIF: #{error.message}"
48
+ end
49
+
50
+ def palette(reader, packed)
51
+ return [] unless packed.anybits?(0x80)
52
+ size = 2**((packed & 7) + 1)
53
+ reader.bytes(size * 3).bytes.each_slice(3).map { |rgb| [*rgb, 255].freeze }.freeze
54
+ end
55
+ private_class_method :palette
56
+
57
+ def read_frame(reader, global_palette, width, height, previous, gce)
58
+ left, top, frame_width, frame_height, packed = reader.bytes(9).unpack("vvvvC")
59
+ local_palette = palette(reader, packed)
60
+ palette = local_palette.empty? ? global_palette : local_palette
61
+ raise ArgumentError, "GIF frame has no palette" if palette.empty?
62
+ interlaced = packed.anybits?(0x40)
63
+ min_code_size = reader.byte
64
+ compressed = subblocks(reader)
65
+ indices = lzw_decode(compressed, min_code_size, frame_width * frame_height)
66
+ indices = deinterlace(indices, frame_width, frame_height) if interlaced
67
+ canvas = previous.map(&:dup)
68
+ saved = canvas.map(&:dup) if gce[:disposal] == 3
69
+ frame_height.times do |row|
70
+ frame_width.times do |column|
71
+ index = indices[row * frame_width + column]
72
+ next if index == gce[:transparent]
73
+ color = palette[index] || [0, 0, 0, 0].freeze
74
+ x, y = left + column, top + row
75
+ canvas[y * width + x] = color if x.between?(0, width - 1) && y.between?(0, height - 1)
76
+ end
77
+ end
78
+ pixels = canvas.flatten.pack("C*")
79
+ frame = Frame.new(pixels, gce[:delay], gce[:disposal])
80
+ disposed = case gce[:disposal]
81
+ when 2
82
+ canvas.map.with_index { |color, index| image_in_frame?(index, left, top, frame_width, frame_height, width) ? [0, 0, 0, 0].freeze : color }
83
+ when 3 then saved || canvas
84
+ else canvas
85
+ end
86
+ {frame: frame, canvas: disposed}
87
+ end
88
+ private_class_method :read_frame
89
+
90
+ def image_in_frame?(index, left, top, frame_width, frame_height, width)
91
+ x, y = index % width, index / width
92
+ x >= left && x < left + frame_width && y >= top && y < top + frame_height
93
+ end
94
+ private_class_method :image_in_frame?
95
+
96
+ def skip_subblocks(reader)
97
+ loop do
98
+ length = reader.byte
99
+ break if length.zero?
100
+ reader.skip(length)
101
+ end
102
+ end
103
+ private_class_method :skip_subblocks
104
+
105
+ def subblocks(reader)
106
+ result = +"".b
107
+ loop do
108
+ length = reader.byte
109
+ break if length.zero?
110
+ result << reader.bytes(length)
111
+ end
112
+ result
113
+ end
114
+ private_class_method :subblocks
115
+
116
+ def deinterlace(indices, width, height)
117
+ output = Array.new(indices.length)
118
+ offset = 0
119
+ [0, 4, 2, 1].zip([8, 8, 4, 2]).each do |start, step|
120
+ (start...height).step(step) do |row|
121
+ output[row * width, width] = indices[offset, width]
122
+ offset += width
123
+ end
124
+ end
125
+ output
126
+ end
127
+ private_class_method :deinterlace
128
+
129
+ def lzw_decode(bytes, minimum, expected)
130
+ raise ArgumentError, "invalid GIF LZW size" unless minimum.between?(2, 8)
131
+ clear, stop, code_size = 1 << minimum, (1 << minimum) + 1, minimum + 1
132
+ table = (0...clear).map { |value| [value] } + [nil, nil]
133
+ bit_offset, previous, output = 0, nil, []
134
+ read_code = lambda do
135
+ value = 0
136
+ code_size.times do |bit|
137
+ byte = bytes.getbyte((bit_offset + bit) / 8) || 0
138
+ value |= ((byte >> ((bit_offset + bit) % 8)) & 1) << bit
139
+ end
140
+ bit_offset += code_size
141
+ value
142
+ end
143
+ loop do
144
+ code = read_code.call
145
+ break if code == stop || output.length >= expected
146
+ if code == clear
147
+ table = (0...clear).map { |value| [value] } + [nil, nil]
148
+ code_size = minimum + 1
149
+ previous = nil
150
+ next
151
+ end
152
+ entry = if code < table.length && table[code]
153
+ table[code]
154
+ elsif previous
155
+ previous + [previous.first]
156
+ else
157
+ raise ArgumentError, "invalid GIF LZW code"
158
+ end
159
+ output.concat(entry)
160
+ if previous
161
+ table << previous + [entry.first]
162
+ code_size += 1 if table.length == (1 << code_size) && code_size < 12
163
+ end
164
+ previous = entry
165
+ end
166
+ raise ArgumentError, "truncated GIF image data" if output.length < expected
167
+ output.first(expected)
168
+ end
169
+ private_class_method :lzw_decode
170
+
171
+ class Reader
172
+ def initialize(bytes)
173
+ @bytes, @offset = bytes, 0
174
+ end
175
+
176
+ def eof? = @offset >= @bytes.bytesize
177
+ def byte = bytes(1).getbyte(0)
178
+ def bytes(length)
179
+ value = @bytes.byteslice(@offset, length)
180
+ raise IndexError, "unexpected end of input" unless value && value.bytesize == length
181
+ @offset += length
182
+ value
183
+ end
184
+ def skip(length)
185
+ bytes(length)
186
+ end
187
+ end
188
+ end
189
+ end
data/lib/zaniah/image.rb CHANGED
@@ -4,10 +4,21 @@ module Zaniah
4
4
  class Image < Element
5
5
  def initialize(path)
6
6
  super()
7
- width, height, pixels = PNG.decode(File.binread(path))
7
+ bytes = File.binread(path)
8
+ width, height, pixels = if bytes.start_with?(PNG::SIGNATURE)
9
+ PNG.decode(bytes)
10
+ elsif bytes.start_with?("GIF87a", "GIF89a")
11
+ gif_width, gif_height, frames = GIF.decode(bytes)
12
+ @frames = frames
13
+ [gif_width, gif_height, frames.first.pixels]
14
+ else
15
+ raise ArgumentError, "unsupported image format"
16
+ end
8
17
  @texture = GPU::Texture.new(width, height, data: pixels)
9
18
  end
10
19
 
20
+ attr_reader :frames
21
+
11
22
  def request_layout(_cx)
12
23
  @layout_node = Layout::Node.new(style: @style, measure: ->(_width, _height) { [@texture.width, @texture.height] })
13
24
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module UI
5
+ # A fixed-cell terminal surface. It deliberately bypasses child elements:
6
+ # terminal grids are updated by row and are too dense for an element tree.
7
+ class TerminalView < Element
8
+ ANSI_COLORS = %w[#000000 #800000 #008000 #808000 #000080 #800080 #008080 #c0c0c0
9
+ #808080 #ff0000 #00ff00 #ffff00 #0000ff #ff00ff #00ffff #ffffff].freeze
10
+
11
+ attr_reader :grid, :damage, :cell_width, :cell_height, :selection
12
+
13
+ def initialize(grid: nil, cell_width: 8, cell_height: 18, background: "#111318", foreground: "#ddd")
14
+ super()
15
+ @cell_width, @cell_height = Float(cell_width), Float(cell_height)
16
+ raise ArgumentError, "cell dimensions must be positive" unless @cell_width.positive? && @cell_height.positive?
17
+ @background, @foreground = background, foreground
18
+ @damage = nil
19
+ self.grid = grid if grid
20
+ end
21
+
22
+ def grid=(value)
23
+ unless value.respond_to?(:cells) && value.respond_to?(:columns) && value.respond_to?(:rows)
24
+ raise ArgumentError, "grid must expose cells, columns, and rows"
25
+ end
26
+ @grid = value
27
+ @damage = 0...value.rows
28
+ value
29
+ end
30
+
31
+ def update(value = @grid, damage: nil)
32
+ self.grid = value unless value.equal?(@grid)
33
+ @damage = damage || value.damage || (0...value.rows)
34
+ self
35
+ end
36
+
37
+ def select(range)
38
+ @selection = range && Range === range ? range : nil
39
+ self
40
+ end
41
+
42
+ def request_layout(_cx)
43
+ columns = @grid ? @grid.columns : 0
44
+ rows = @grid ? @grid.rows : 0
45
+ @layout_node = Layout::Node.new(style: @style,
46
+ measure: ->(_width, _height) { [columns * @cell_width, rows * @cell_height] })
47
+ end
48
+
49
+ def paint(bounds, state, prepaint, cx)
50
+ super
51
+ return self unless @grid
52
+
53
+ rows = @damage || (0...@grid.rows)
54
+ rows = [rows.begin, 0].max...[rows.end, @grid.rows].min
55
+ cx.scene.clip(bounds) do
56
+ rows.each { |row| paint_row(row, bounds, cx) }
57
+ paint_cursor(bounds, cx) if @grid.cursor_visible
58
+ end
59
+ @grid.clear_damage if @grid.respond_to?(:clear_damage)
60
+ @damage = nil
61
+ self
62
+ end
63
+
64
+ private
65
+
66
+ def paint_row(row, bounds, cx)
67
+ cells = @grid.cells.fetch(row)
68
+ column = 0
69
+ while column < cells.length
70
+ cell = cells[column]
71
+ if cell.background
72
+ run = column + 1
73
+ run += 1 while run < cells.length && cells[run].background == cell.background
74
+ cx.scene.quad(bounds.x + column * @cell_width, bounds.y + row * @cell_height,
75
+ (run - column) * @cell_width, @cell_height, color: color(cell.background))
76
+ end
77
+ if cell.width != 0 && cell.text != " "
78
+ paint_text(cell.text, cell.foreground || @foreground, bounds, row, column, cx)
79
+ end
80
+ column += 1
81
+ end
82
+ end
83
+
84
+ def paint_text(text, tint, bounds, row, column, cx)
85
+ return unless cx.text_system
86
+ line = cx.text_system.layout_line(text, size: @cell_height * 0.78)
87
+ cx.text_system.paint_line(cx.scene, line, x: bounds.x + column * @cell_width,
88
+ y: bounds.y + row * @cell_height + @cell_height * 0.82, color: color(tint))
89
+ end
90
+
91
+ def paint_cursor(bounds, cx)
92
+ x, y = @grid.cursor_x, @grid.cursor_y
93
+ cx.scene.layer(Scene::LAYER_SELECTION) do
94
+ cx.scene.quad(bounds.x + x * @cell_width, bounds.y + y * @cell_height,
95
+ @cell_width, @cell_height, color: "#ffffff55")
96
+ end
97
+ end
98
+
99
+ def color(value)
100
+ return value if value.is_a?(String)
101
+ return "##{value.map { |part| format("%02x", part) }.join}" if value.is_a?(Array)
102
+ return ANSI_COLORS.fetch(value, @foreground) if value.is_a?(Integer)
103
+ @foreground
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/zaniah/ui.rb CHANGED
@@ -18,3 +18,4 @@ require_relative "ui/tree_view"
18
18
  require_relative "ui/charts"
19
19
  require_relative "ui/forms"
20
20
  require_relative "ui/editors"
21
+ 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.0"
4
+ VERSION = "0.6.2"
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/gif"
28
29
  require_relative "zaniah/gpu"
29
30
  require_relative "zaniah/layout"
30
31
  require_relative "zaniah/style/style_set"
@@ -0,0 +1,15 @@
1
+ module Zaniah
2
+ module UI
3
+ class TerminalView < Zaniah::Element
4
+ attr_reader grid: untyped?
5
+ attr_reader damage: Range[Integer]?
6
+ attr_reader cell_width: Float
7
+ attr_reader cell_height: Float
8
+ attr_reader selection: Range[Integer]?
9
+ def initialize: (grid: untyped?, cell_width: Numeric, cell_height: Numeric, background: String, foreground: String) -> void
10
+ def grid=: (untyped value) -> untyped
11
+ def update: (?untyped value, ?damage: Range[Integer]) -> self
12
+ def select: (Range[Integer]?) -> self
13
+ end
14
+ end
15
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
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-19 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: alhena
@@ -151,6 +151,8 @@ files:
151
151
  - docs/gallery/tooltip-top-light.png
152
152
  - docs/index.html
153
153
  - docs/layout.md
154
+ - docs/media/overview.apng
155
+ - docs/media/overview.png
154
156
  - docs/native.md
155
157
  - docs/process_pool.md
156
158
  - docs/site.css
@@ -217,6 +219,7 @@ files:
217
219
  - lib/zaniah/ffi/wayland.rb
218
220
  - lib/zaniah/frame_context.rb
219
221
  - lib/zaniah/geometry.rb
222
+ - lib/zaniah/gif.rb
220
223
  - lib/zaniah/gpu.rb
221
224
  - lib/zaniah/gpu/buffer.rb
222
225
  - lib/zaniah/gpu/frame_encoder.rb
@@ -352,6 +355,7 @@ files:
352
355
  - lib/zaniah/ui/primitives.rb
353
356
  - lib/zaniah/ui/scrollbar.rb
354
357
  - lib/zaniah/ui/structure.rb
358
+ - lib/zaniah/ui/terminal_view.rb
355
359
  - lib/zaniah/ui/text_inputs.rb
356
360
  - lib/zaniah/ui/tree_view.rb
357
361
  - lib/zaniah/ui/variants.rb
@@ -377,6 +381,7 @@ files:
377
381
  - sig/text.rbs
378
382
  - sig/theme.rbs
379
383
  - sig/ui.rbs
384
+ - sig/ui_terminal_view.rbs
380
385
  - sig/zaniah.rbs
381
386
  - tools/check_dependencies.rb
382
387
  - tools/generate_component_gallery.rb