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 +4 -4
- data/CHANGELOG.md +4 -0
- data/lib/zaniah/gif.rb +189 -0
- data/lib/zaniah/image.rb +12 -1
- data/lib/zaniah/ui/terminal_view.rb +107 -0
- data/lib/zaniah/ui.rb +1 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/ui_terminal_view.rbs +15 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00bd64bac42b39487e5b1d5d5c8c259fe2ced7a238dd07d6b073e72b5c3023f7
|
|
4
|
+
data.tar.gz: e4bf0fbcb4d460a26e47a0abdd941e4a1904906cc6e98546f2ec42a9cac2517f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 598ddd2173a36d1bcd284d713da20d36202f679e1d3ef3eaba5c8338120aeeb9e2b30ed3d02d926d09df0608e3d34a50dc3684226bec0ac97005faee9cf511ff
|
|
7
|
+
data.tar.gz: 6ef86677fcd7e1bcf1fcc76b1917cf499a10d099a6e97b421de5bd4cf7a4f42040d022f9e8cceb4baac54eee5842494fbf01a7f911bfb7863bb0ce43d0c6339d
|
data/CHANGELOG.md
CHANGED
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
|
-
|
|
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
data/lib/zaniah/version.rb
CHANGED
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.
|
|
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
|