zaniah 0.6.1 → 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: bf9a2c8a283e0a76e76d5c3e8bd3f10c8a54d6da3fd4736e96d5f9ad5c35a50a
4
- data.tar.gz: 1374f35e06698a1e3ba7dd793a7189339adc8bb57035a1c6032c96264afc4550
3
+ metadata.gz: 00bd64bac42b39487e5b1d5d5c8c259fe2ced7a238dd07d6b073e72b5c3023f7
4
+ data.tar.gz: e4bf0fbcb4d460a26e47a0abdd941e4a1904906cc6e98546f2ec42a9cac2517f
5
5
  SHA512:
6
- metadata.gz: bace7f7d90e3f029821584b609c22af25f9281cabde5e4a8ce1082aa98a05423e3f994dcc0f3890ee16fc6631e7acd006ec1040e8a56ae62734bb74645989de3
7
- data.tar.gz: d0d64e184d2f2930eeb4264e58b9f6687af8fa466a65de07560a8931f5327122332959e85ad2496c48fbc8e1c6e0f02456d9c3979d62182117092e46d654bcb7
6
+ metadata.gz: 598ddd2173a36d1bcd284d713da20d36202f679e1d3ef3eaba5c8338120aeeb9e2b30ed3d02d926d09df0608e3d34a50dc3684226bec0ac97005faee9cf511ff
7
+ data.tar.gz: 6ef86677fcd7e1bcf1fcc76b1917cf499a10d099a6e97b421de5bd4cf7a4f42040d022f9e8cceb4baac54eee5842494fbf01a7f911bfb7863bb0ce43d0c6339d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 — 2026-09-21
4
+
5
+ - Add GIF decoding and a damage-aware terminal grid view.
6
+
3
7
  ## 0.6.1 — 2026-09-21
4
8
 
5
9
  - Add a deterministic headless demo image and regeneration task.
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.1"
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zaniah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -219,6 +219,7 @@ files:
219
219
  - lib/zaniah/ffi/wayland.rb
220
220
  - lib/zaniah/frame_context.rb
221
221
  - lib/zaniah/geometry.rb
222
+ - lib/zaniah/gif.rb
222
223
  - lib/zaniah/gpu.rb
223
224
  - lib/zaniah/gpu/buffer.rb
224
225
  - lib/zaniah/gpu/frame_encoder.rb
@@ -354,6 +355,7 @@ files:
354
355
  - lib/zaniah/ui/primitives.rb
355
356
  - lib/zaniah/ui/scrollbar.rb
356
357
  - lib/zaniah/ui/structure.rb
358
+ - lib/zaniah/ui/terminal_view.rb
357
359
  - lib/zaniah/ui/text_inputs.rb
358
360
  - lib/zaniah/ui/tree_view.rb
359
361
  - lib/zaniah/ui/variants.rb
@@ -379,6 +381,7 @@ files:
379
381
  - sig/text.rbs
380
382
  - sig/theme.rbs
381
383
  - sig/ui.rbs
384
+ - sig/ui_terminal_view.rbs
382
385
  - sig/zaniah.rbs
383
386
  - tools/check_dependencies.rb
384
387
  - tools/generate_component_gallery.rb