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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +9 -0
- data/docs/adr/012-productivity-components.md +25 -0
- data/docs/components.md +16 -1
- data/docs/images.md +18 -0
- data/docs/layout.md +46 -0
- data/docs/native.md +9 -0
- data/docs/text.md +22 -0
- data/lib/zaniah/image.rb +22 -5
- data/lib/zaniah/input/keymap.rb +12 -0
- data/lib/zaniah/jpeg.rb +431 -0
- data/lib/zaniah/list/height_index.rb +3 -2
- data/lib/zaniah/platform/headless/window.rb +8 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +14 -0
- data/lib/zaniah/platform/linux/window.rb +12 -0
- data/lib/zaniah/platform/mac/window.rb +15 -0
- data/lib/zaniah/platform/windows/window.rb +15 -0
- data/lib/zaniah/ui/charts.rb +299 -0
- data/lib/zaniah/ui/editors.rb +303 -13
- data/lib/zaniah/ui/grid.rb +492 -0
- data/lib/zaniah/ui/rich_text_surface.rb +302 -0
- data/lib/zaniah/ui/terminal_view.rb +22 -0
- data/lib/zaniah/ui.rb +2 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +1 -0
- data/sig/elements.rbs +2 -0
- data/sig/native.rbs +7 -0
- data/sig/platform.rbs +2 -0
- data/sig/ui.rbs +67 -2
- data/sig/zaniah.rbs +14 -0
- metadata +15 -4
data/lib/zaniah/jpeg.rb
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module JPEG
|
|
5
|
+
class Error < ArgumentError; end
|
|
6
|
+
|
|
7
|
+
ZIGZAG = [
|
|
8
|
+
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5,
|
|
9
|
+
12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28,
|
|
10
|
+
35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51,
|
|
11
|
+
58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63
|
|
12
|
+
].freeze
|
|
13
|
+
COS = Array.new(8) do |x|
|
|
14
|
+
Array.new(8) { |u| Math.cos((2 * x + 1) * u * Math::PI / 16) }.freeze
|
|
15
|
+
end.freeze
|
|
16
|
+
SCALE = [1 / Math.sqrt(2), 1, 1, 1, 1, 1, 1, 1].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def decode(bytes, max_pixels: 16_777_216)
|
|
21
|
+
raise Error, "not a JPEG" unless bytes.is_a?(String) && bytes.start_with?("\xFF\xD8".b)
|
|
22
|
+
raise ArgumentError, "max_pixels must be a positive integer" unless max_pixels.is_a?(Integer) && max_pixels.positive?
|
|
23
|
+
Decoder.new(bytes, max_pixels).decode
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class Huffman
|
|
27
|
+
def initialize(counts, values)
|
|
28
|
+
@codes = {}
|
|
29
|
+
code = index = 0
|
|
30
|
+
counts.each_with_index do |count, length|
|
|
31
|
+
raise Error, "oversubscribed JPEG Huffman table" if code + count > (1 << (length + 1))
|
|
32
|
+
count.times do
|
|
33
|
+
value = values[index]
|
|
34
|
+
raise Error, "invalid JPEG Huffman table" unless value
|
|
35
|
+
@codes[[length + 1, code]] = value
|
|
36
|
+
code += 1
|
|
37
|
+
index += 1
|
|
38
|
+
end
|
|
39
|
+
code <<= 1
|
|
40
|
+
end
|
|
41
|
+
raise Error, "invalid JPEG Huffman table" unless index == values.length
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def decode(reader)
|
|
45
|
+
code = 0
|
|
46
|
+
1.upto(16) do |length|
|
|
47
|
+
code = (code << 1) | reader.bit
|
|
48
|
+
return @codes[[length, code]] if @codes.key?([length, code])
|
|
49
|
+
end
|
|
50
|
+
raise Error, "invalid JPEG Huffman code"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class BitReader
|
|
55
|
+
def initialize(bytes, offset)
|
|
56
|
+
@bytes, @offset, @value, @remaining = bytes, offset, 0, 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def bit
|
|
60
|
+
if @remaining.zero?
|
|
61
|
+
@value = data_byte
|
|
62
|
+
@remaining = 8
|
|
63
|
+
end
|
|
64
|
+
@remaining -= 1
|
|
65
|
+
(@value >> @remaining) & 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def bits(count)
|
|
69
|
+
value = 0
|
|
70
|
+
count.times { value = (value << 1) | bit }
|
|
71
|
+
value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def restart!(expected)
|
|
75
|
+
@remaining = 0
|
|
76
|
+
marker = read_marker
|
|
77
|
+
raise Error, "expected JPEG restart marker" unless marker == expected
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def finish!
|
|
81
|
+
@remaining = 0
|
|
82
|
+
raise Error, "JPEG image is missing its end marker" unless read_marker == 0xd9
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def data_byte
|
|
88
|
+
byte = @bytes.getbyte(@offset)
|
|
89
|
+
raise Error, "truncated JPEG scan" unless byte
|
|
90
|
+
@offset += 1
|
|
91
|
+
return byte unless byte == 0xff
|
|
92
|
+
marker = read_marker
|
|
93
|
+
return 0xff if marker == 0
|
|
94
|
+
raise Error, "unexpected marker in JPEG scan"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_marker
|
|
98
|
+
raise Error, "truncated JPEG marker" unless @bytes.getbyte(@offset) == 0xff
|
|
99
|
+
@offset += 1 while @bytes.getbyte(@offset) == 0xff
|
|
100
|
+
marker = @bytes.getbyte(@offset)
|
|
101
|
+
raise Error, "truncated JPEG marker" unless marker
|
|
102
|
+
@offset += 1
|
|
103
|
+
marker
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Decoder
|
|
108
|
+
def initialize(bytes, max_pixels)
|
|
109
|
+
@bytes, @max_pixels, @offset = bytes.b, max_pixels, 2
|
|
110
|
+
@quantization, @huffman, @components = {}, {}, []
|
|
111
|
+
@orientation, @restart_interval = 1, 0
|
|
112
|
+
@adobe_transform = nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def decode
|
|
116
|
+
scan_offset = read_headers
|
|
117
|
+
initialize_planes
|
|
118
|
+
reader = BitReader.new(@bytes, scan_offset)
|
|
119
|
+
decode_scan(reader)
|
|
120
|
+
reader.finish!
|
|
121
|
+
rgba = render_pixels
|
|
122
|
+
orient(*rgba)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def read_headers
|
|
128
|
+
loop do
|
|
129
|
+
marker = next_marker
|
|
130
|
+
break if marker == 0xd9
|
|
131
|
+
length = read_u16
|
|
132
|
+
raise Error, "invalid JPEG segment length" if length < 2 || @offset + length - 2 > @bytes.bytesize
|
|
133
|
+
data = @bytes.byteslice(@offset, length - 2)
|
|
134
|
+
@offset += length - 2
|
|
135
|
+
case marker
|
|
136
|
+
when 0xdb then read_quantization(data)
|
|
137
|
+
when 0xc4 then read_huffman(data)
|
|
138
|
+
when 0xc0 then read_frame(data)
|
|
139
|
+
when 0xc2 then raise Error, "progressive JPEG is not supported"
|
|
140
|
+
when 0xc1 then raise Error, "extended sequential JPEG is not supported"
|
|
141
|
+
when 0xdd
|
|
142
|
+
raise Error, "invalid JPEG restart interval" unless data.bytesize == 2
|
|
143
|
+
@restart_interval = data.unpack1("n")
|
|
144
|
+
when 0xe1 then @orientation = exif_orientation(data)
|
|
145
|
+
when 0xee then @adobe_transform = data.getbyte(11) if data.start_with?("Adobe".b)
|
|
146
|
+
when 0xda then return read_scan_header(data)
|
|
147
|
+
when 0xc3, 0xc5..0xcf
|
|
148
|
+
raise Error, "unsupported JPEG coding process"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
raise Error, "JPEG scan is missing"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def next_marker
|
|
155
|
+
@offset += 1 while @offset < @bytes.bytesize && @bytes.getbyte(@offset) != 0xff
|
|
156
|
+
raise Error, "truncated JPEG marker" if @offset + 1 >= @bytes.bytesize
|
|
157
|
+
@offset += 1 while @bytes.getbyte(@offset) == 0xff
|
|
158
|
+
marker = @bytes.getbyte(@offset)
|
|
159
|
+
raise Error, "invalid JPEG marker" unless marker && marker != 0
|
|
160
|
+
@offset += 1
|
|
161
|
+
marker
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def read_u16
|
|
165
|
+
raise Error, "truncated JPEG segment" if @offset + 2 > @bytes.bytesize
|
|
166
|
+
value = @bytes.unpack1("@#{@offset}n")
|
|
167
|
+
@offset += 2
|
|
168
|
+
value
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def read_quantization(data)
|
|
172
|
+
offset = 0
|
|
173
|
+
while offset < data.bytesize
|
|
174
|
+
info = data.getbyte(offset)
|
|
175
|
+
offset += 1
|
|
176
|
+
precision, id = info >> 4, info & 15
|
|
177
|
+
raise Error, "invalid JPEG quantization table" unless precision <= 1 && id < 4
|
|
178
|
+
bytes = precision.zero? ? 1 : 2
|
|
179
|
+
raise Error, "truncated JPEG quantization table" if offset + 64 * bytes > data.bytesize
|
|
180
|
+
values = 64.times.map do
|
|
181
|
+
value = bytes == 1 ? data.getbyte(offset) : data.unpack1("@#{offset}n")
|
|
182
|
+
offset += bytes
|
|
183
|
+
value
|
|
184
|
+
end
|
|
185
|
+
table = Array.new(64)
|
|
186
|
+
ZIGZAG.each_with_index { |index, zigzag| table[index] = values[zigzag] }
|
|
187
|
+
raise Error, "zero JPEG quantization value" if table.any?(&:zero?)
|
|
188
|
+
@quantization[id] = table.freeze
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def read_huffman(data)
|
|
193
|
+
offset = 0
|
|
194
|
+
while offset < data.bytesize
|
|
195
|
+
info = data.getbyte(offset)
|
|
196
|
+
offset += 1
|
|
197
|
+
kind, id = info >> 4, info & 15
|
|
198
|
+
raise Error, "invalid JPEG Huffman table" unless kind <= 1 && id < 4 && offset + 16 <= data.bytesize
|
|
199
|
+
counts = data.byteslice(offset, 16).bytes
|
|
200
|
+
offset += 16
|
|
201
|
+
length = counts.sum
|
|
202
|
+
raise Error, "truncated JPEG Huffman values" if offset + length > data.bytesize
|
|
203
|
+
@huffman[[kind, id]] = Huffman.new(counts, data.byteslice(offset, length).bytes)
|
|
204
|
+
offset += length
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def read_frame(data)
|
|
209
|
+
raise Error, "duplicate JPEG frame" unless @components.empty?
|
|
210
|
+
raise Error, "invalid JPEG frame" if data.bytesize < 6
|
|
211
|
+
precision, @height, @width, count = data.unpack("CnnC")
|
|
212
|
+
raise Error, "only 8-bit JPEG is supported" unless precision == 8
|
|
213
|
+
raise Error, "invalid JPEG dimensions" unless @width.positive? && @height.positive? && @width * @height <= @max_pixels
|
|
214
|
+
raise Error, "only grayscale and YCbCr/RGB JPEG are supported" unless [1, 3].include?(count) && data.bytesize == 6 + count * 3
|
|
215
|
+
offset = 6
|
|
216
|
+
count.times do
|
|
217
|
+
id, sampling, quant = data.byteslice(offset, 3).bytes
|
|
218
|
+
h, v = sampling >> 4, sampling & 15
|
|
219
|
+
raise Error, "invalid JPEG sampling factors" unless h.between?(1, 4) && v.between?(1, 4) && quant < 4
|
|
220
|
+
raise Error, "duplicate JPEG component" if @components.any? { |component| component[:id] == id }
|
|
221
|
+
@components << {id: id, h: h, v: v, quant: quant, dc: 0, dc_id: nil, ac_id: nil}
|
|
222
|
+
offset += 3
|
|
223
|
+
end
|
|
224
|
+
@max_h = @components.map { |component| component[:h] }.max
|
|
225
|
+
@max_v = @components.map { |component| component[:v] }.max
|
|
226
|
+
raise Error, "too many JPEG blocks per MCU" if @components.sum { |component| component[:h] * component[:v] } > 10
|
|
227
|
+
mcu_w, mcu_h = @max_h * 8, @max_v * 8
|
|
228
|
+
@mcu_columns, @mcu_rows = (@width + mcu_w - 1) / mcu_w, (@height + mcu_h - 1) / mcu_h
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def read_scan_header(data)
|
|
232
|
+
raise Error, "JPEG frame is missing" if @components.empty?
|
|
233
|
+
raise Error, "invalid JPEG scan" if data.empty?
|
|
234
|
+
count, offset = data.getbyte(0), 1
|
|
235
|
+
raise Error, "only a single baseline scan is supported" unless count == @components.length && data.bytesize == 1 + count * 2 + 3
|
|
236
|
+
@scan_components = []
|
|
237
|
+
count.times do
|
|
238
|
+
id, selectors = data.byteslice(offset, 2).bytes
|
|
239
|
+
component = @components.find { |item| item[:id] == id }
|
|
240
|
+
raise Error, "unknown or duplicate JPEG scan component" unless component && !@scan_components.include?(component)
|
|
241
|
+
component[:dc_id], component[:ac_id] = selectors >> 4, selectors & 15
|
|
242
|
+
@scan_components << component
|
|
243
|
+
offset += 2
|
|
244
|
+
end
|
|
245
|
+
start, finish, successive = data.byteslice(offset, 3).bytes
|
|
246
|
+
raise Error, "unsupported non-baseline JPEG scan" unless start.zero? && finish == 63 && successive.zero?
|
|
247
|
+
@components.each do |component|
|
|
248
|
+
raise Error, "missing JPEG quantization table" unless @quantization[component[:quant]]
|
|
249
|
+
raise Error, "missing JPEG Huffman table" unless @huffman[[0, component[:dc_id]]] && @huffman[[1, component[:ac_id]]]
|
|
250
|
+
end
|
|
251
|
+
@offset
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def initialize_planes
|
|
255
|
+
@components.each do |component|
|
|
256
|
+
component[:plane_width] = @mcu_columns * component[:h] * 8
|
|
257
|
+
component[:plane_height] = @mcu_rows * component[:v] * 8
|
|
258
|
+
component[:width] = (@width * component[:h] + @max_h - 1) / @max_h
|
|
259
|
+
component[:height] = (@height * component[:v] + @max_v - 1) / @max_v
|
|
260
|
+
component[:plane] = String.new(capacity: component[:plane_width] * component[:plane_height], encoding: Encoding::BINARY)
|
|
261
|
+
component[:plane] << "\x80".b * (component[:plane_width] * component[:plane_height])
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def decode_scan(reader)
|
|
266
|
+
total = @mcu_columns * @mcu_rows
|
|
267
|
+
total.times do |mcu|
|
|
268
|
+
@scan_components.each do |component|
|
|
269
|
+
component[:v].times do |block_y|
|
|
270
|
+
component[:h].times do |block_x|
|
|
271
|
+
values = decode_block(component, reader)
|
|
272
|
+
x = ((mcu % @mcu_columns) * component[:h] + block_x) * 8
|
|
273
|
+
y = ((mcu / @mcu_columns) * component[:v] + block_y) * 8
|
|
274
|
+
write_block(component, values, x, y)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
if @restart_interval.positive? && (mcu + 1) < total && (mcu + 1) % @restart_interval == 0
|
|
279
|
+
reader.restart!(0xd0 + ((mcu + 1) / @restart_interval - 1) % 8)
|
|
280
|
+
@components.each { |component| component[:dc] = 0 }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def decode_block(component, reader)
|
|
286
|
+
coefficients = Array.new(64, 0)
|
|
287
|
+
dc_size = @huffman.fetch([0, component[:dc_id]]).decode(reader)
|
|
288
|
+
raise Error, "invalid JPEG DC coefficient" if dc_size > 11
|
|
289
|
+
component[:dc] += receive(reader, dc_size)
|
|
290
|
+
coefficients[0] = component[:dc]
|
|
291
|
+
index = 1
|
|
292
|
+
table = @huffman.fetch([1, component[:ac_id]])
|
|
293
|
+
while index < 64
|
|
294
|
+
value = table.decode(reader)
|
|
295
|
+
run, size = value >> 4, value & 15
|
|
296
|
+
if size.zero?
|
|
297
|
+
break if run.zero?
|
|
298
|
+
raise Error, "invalid JPEG zero run" unless run == 15
|
|
299
|
+
index += 16
|
|
300
|
+
raise Error, "invalid JPEG coefficient run" if index > 64
|
|
301
|
+
else
|
|
302
|
+
raise Error, "invalid JPEG AC coefficient" if size > 10
|
|
303
|
+
index += run
|
|
304
|
+
raise Error, "invalid JPEG coefficient run" if index >= 64
|
|
305
|
+
coefficients[ZIGZAG[index]] = receive(reader, size)
|
|
306
|
+
index += 1
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
quant = @quantization.fetch(component[:quant])
|
|
310
|
+
idct(coefficients.each_index.map { |i| coefficients[i] * quant[i] })
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def receive(reader, size)
|
|
314
|
+
return 0 if size.zero?
|
|
315
|
+
value = reader.bits(size)
|
|
316
|
+
value < (1 << (size - 1)) ? value - ((1 << size) - 1) : value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def idct(coefficients)
|
|
320
|
+
temp = Array.new(8) { Array.new(8, 0.0) }
|
|
321
|
+
8.times do |v|
|
|
322
|
+
8.times do |x|
|
|
323
|
+
sum = 0.0
|
|
324
|
+
8.times { |u| sum += SCALE[u] * coefficients[v * 8 + u] * COS[x][u] }
|
|
325
|
+
temp[v][x] = sum
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
Array.new(64) do |index|
|
|
329
|
+
y, x = index.divmod(8)
|
|
330
|
+
sum = 0.0
|
|
331
|
+
8.times { |v| sum += SCALE[v] * temp[v][x] * COS[y][v] }
|
|
332
|
+
[[(sum * 0.25 + 128).round, 0].max, 255].min
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def write_block(component, pixels, x, y)
|
|
337
|
+
8.times do |row|
|
|
338
|
+
bytes = pixels.slice(row * 8, 8).pack("C8")
|
|
339
|
+
component[:plane][(y + row) * component[:plane_width] + x, 8] = bytes
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def render_pixels
|
|
344
|
+
rgba = String.new(capacity: @width * @height * 4, encoding: Encoding::BINARY)
|
|
345
|
+
y_component, cb_component, cr_component = @components
|
|
346
|
+
@height.times do |y|
|
|
347
|
+
@width.times do |x|
|
|
348
|
+
if @components.length == 1
|
|
349
|
+
gray = sample(y_component, x, y)
|
|
350
|
+
rgba << [gray, gray, gray, 255].pack("C4")
|
|
351
|
+
elsif @adobe_transform == 0
|
|
352
|
+
rgba << [sample(y_component, x, y), sample(cb_component, x, y), sample(cr_component, x, y), 255].pack("C4")
|
|
353
|
+
else
|
|
354
|
+
yy = sample(y_component, x, y)
|
|
355
|
+
cb = sample(cb_component, x, y) - 128
|
|
356
|
+
cr = sample(cr_component, x, y) - 128
|
|
357
|
+
r = (yy + 1.402 * cr).round.clamp(0, 255)
|
|
358
|
+
g = (yy - 0.344136 * cb - 0.714136 * cr).round.clamp(0, 255)
|
|
359
|
+
b = (yy + 1.772 * cb).round.clamp(0, 255)
|
|
360
|
+
rgba << [r, g, b, 255].pack("C4")
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
[@width, @height, rgba]
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def sample(component, x, y)
|
|
368
|
+
x_ratio, y_ratio = component[:h].to_f / @max_h, component[:v].to_f / @max_v
|
|
369
|
+
fx = ((x + 0.5) * x_ratio - 0.5).clamp(0, (component[:width] - 1).to_f)
|
|
370
|
+
fy = ((y + 0.5) * y_ratio - 0.5).clamp(0, (component[:height] - 1).to_f)
|
|
371
|
+
x0, y0 = fx.floor, fy.floor
|
|
372
|
+
x1, y1 = [x0 + 1, component[:width] - 1].min, [y0 + 1, component[:height] - 1].min
|
|
373
|
+
a = component[:plane].getbyte(y0 * component[:plane_width] + x0)
|
|
374
|
+
b = component[:plane].getbyte(y0 * component[:plane_width] + x1)
|
|
375
|
+
c = component[:plane].getbyte(y1 * component[:plane_width] + x0)
|
|
376
|
+
d = component[:plane].getbyte(y1 * component[:plane_width] + x1)
|
|
377
|
+
top = a + (b - a) * (fx - x0)
|
|
378
|
+
bottom = c + (d - c) * (fx - x0)
|
|
379
|
+
(top + (bottom - top) * (fy - y0)).round
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def orient(width, height, pixels)
|
|
383
|
+
return [width, height, pixels] if @orientation == 1
|
|
384
|
+
out_width, out_height = @orientation.between?(5, 8) ? [height, width] : [width, height]
|
|
385
|
+
output = String.new(capacity: out_width * out_height * 4, encoding: Encoding::BINARY)
|
|
386
|
+
out_height.times do |y|
|
|
387
|
+
out_width.times do |x|
|
|
388
|
+
sx, sy = case @orientation
|
|
389
|
+
when 2 then [width - 1 - x, y]
|
|
390
|
+
when 3 then [width - 1 - x, height - 1 - y]
|
|
391
|
+
when 4 then [x, height - 1 - y]
|
|
392
|
+
when 5 then [y, x]
|
|
393
|
+
when 6 then [y, height - 1 - x]
|
|
394
|
+
when 7 then [width - 1 - y, height - 1 - x]
|
|
395
|
+
when 8 then [width - 1 - y, x]
|
|
396
|
+
else [x, y]
|
|
397
|
+
end
|
|
398
|
+
output << pixels.byteslice((sy * width + sx) * 4, 4)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
[out_width, out_height, output]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def exif_orientation(data)
|
|
405
|
+
return 1 unless data.start_with?("Exif\0\0".b) && data.bytesize >= 14
|
|
406
|
+
tiff = data.byteslice(6..)
|
|
407
|
+
endian = case tiff.byteslice(0, 2)
|
|
408
|
+
when "II" then :little
|
|
409
|
+
when "MM" then :big
|
|
410
|
+
else return 1
|
|
411
|
+
end
|
|
412
|
+
u16 = ->(offset) { tiff.byteslice(offset, 2)&.unpack1(endian == :little ? "v" : "n") }
|
|
413
|
+
u32 = ->(offset) { tiff.byteslice(offset, 4)&.unpack1(endian == :little ? "V" : "N") }
|
|
414
|
+
return 1 unless u16.call(2) == 42
|
|
415
|
+
ifd = u32.call(4)
|
|
416
|
+
return 1 unless ifd && ifd + 2 <= tiff.bytesize
|
|
417
|
+
count = u16.call(ifd)
|
|
418
|
+
return 1 unless count && ifd + 2 + count * 12 <= tiff.bytesize
|
|
419
|
+
count.times do |index|
|
|
420
|
+
entry = ifd + 2 + index * 12
|
|
421
|
+
next unless u16.call(entry) == 0x0112 && u16.call(entry + 2) == 3 && u32.call(entry + 4) == 1
|
|
422
|
+
value = u16.call(entry + 8)
|
|
423
|
+
return value if value&.between?(1, 8)
|
|
424
|
+
end
|
|
425
|
+
1
|
|
426
|
+
rescue StandardError
|
|
427
|
+
1
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
@@ -19,8 +19,9 @@ module Zaniah
|
|
|
19
19
|
|
|
20
20
|
def update(index, height)
|
|
21
21
|
validate_index(index)
|
|
22
|
-
raise ArgumentError, "height must be finite and
|
|
22
|
+
raise ArgumentError, "height must be finite and nonnegative" unless height.is_a?(Numeric) && height.finite? && height >= 0
|
|
23
23
|
delta = height.to_f - self[index]
|
|
24
|
+
return 0.0 if delta.zero?
|
|
24
25
|
height == @estimate ? @values.delete(index) : @values[index] = height.to_f
|
|
25
26
|
cursor = index + 1
|
|
26
27
|
while cursor <= @count
|
|
@@ -45,7 +46,7 @@ module Zaniah
|
|
|
45
46
|
# Index containing y; count is the sentinel at/beyond the bottom edge.
|
|
46
47
|
def index_at(y)
|
|
47
48
|
raise ArgumentError, "row offset must be finite" unless y.is_a?(Numeric) && y.finite?
|
|
48
|
-
return 0 if y
|
|
49
|
+
return 0 if y.negative?
|
|
49
50
|
index, accumulated = 0, 0.0
|
|
50
51
|
bit = 1 << @count.bit_length
|
|
51
52
|
while bit.positive?
|
|
@@ -48,6 +48,10 @@ module Zaniah
|
|
|
48
48
|
@cursor_style = style
|
|
49
49
|
end
|
|
50
50
|
def displays = [Display.new(0, "Headless", Bounds.new(0, 0, @content_size.width, @content_size.height), @scale_factor, true)]
|
|
51
|
+
def move_to_display(display)
|
|
52
|
+
validate_display!(display)
|
|
53
|
+
raise Error, "#{self.class} does not support native display placement"
|
|
54
|
+
end
|
|
51
55
|
|
|
52
56
|
def popup
|
|
53
57
|
if @popup_component.respond_to?(:popup_data)
|
|
@@ -189,6 +193,10 @@ module Zaniah
|
|
|
189
193
|
|
|
190
194
|
private
|
|
191
195
|
|
|
196
|
+
def validate_display!(display)
|
|
197
|
+
raise ArgumentError, "expected a Zaniah::Platform::Display" unless display.is_a?(Display)
|
|
198
|
+
end
|
|
199
|
+
|
|
192
200
|
def popup_input(event)
|
|
193
201
|
if @popup_component
|
|
194
202
|
unless @popup_component.open?
|
|
@@ -344,6 +344,20 @@ module Zaniah
|
|
|
344
344
|
@fullscreen = !@fullscreen
|
|
345
345
|
@fullscreen ? @connection.request(@toplevel, 11, 0) : @connection.request(@toplevel, 12)
|
|
346
346
|
end
|
|
347
|
+
def move_to_display(display)
|
|
348
|
+
validate_display!(display)
|
|
349
|
+
raise Error, "Wayland does not support arbitrary window positioning; use fullscreen_on"
|
|
350
|
+
end
|
|
351
|
+
# Wayland treats the output as a preference; the compositor decides whether to honor it.
|
|
352
|
+
def fullscreen_on(display)
|
|
353
|
+
validate_display!(display)
|
|
354
|
+
output = @outputs[display.id]&.first
|
|
355
|
+
raise Error, "display is no longer available" unless output
|
|
356
|
+
|
|
357
|
+
@fullscreen = true
|
|
358
|
+
@connection.request(@toplevel, 11, output)
|
|
359
|
+
true
|
|
360
|
+
end
|
|
347
361
|
def close
|
|
348
362
|
return false unless super
|
|
349
363
|
Accessibility.close(self)
|
|
@@ -401,6 +401,18 @@ module Zaniah
|
|
|
401
401
|
event[56, 24] = [2, atom("_NET_WM_STATE_FULLSCREEN"), 0].pack("L!3")
|
|
402
402
|
x(:XSendEvent, [P, L, I, L, P], I, @display, @root, 0, (1 << 19) | (1 << 20), event)
|
|
403
403
|
end
|
|
404
|
+
def move_to_display(display)
|
|
405
|
+
validate_display!(display)
|
|
406
|
+
target = displays.find { |candidate| candidate.id == display.id }
|
|
407
|
+
raise Error, "display is no longer available" unless target
|
|
408
|
+
|
|
409
|
+
x_position = (target.bounds.x * target.scale_factor).round
|
|
410
|
+
y_position = (target.bounds.y * target.scale_factor).round
|
|
411
|
+
moved = x(:XMoveWindow, [P, L, I, I], I, @display, @handle, x_position, y_position)
|
|
412
|
+
raise Error, "XMoveWindow failed" if moved.zero?
|
|
413
|
+
x(:XFlush, [P], I, @display)
|
|
414
|
+
true
|
|
415
|
+
end
|
|
404
416
|
def close
|
|
405
417
|
return false unless super
|
|
406
418
|
Accessibility.close(self)
|
|
@@ -151,6 +151,21 @@ module Zaniah
|
|
|
151
151
|
@on_moved&.call(O.send(@handle, "frame", result: :rect).first(2)) if moved
|
|
152
152
|
end
|
|
153
153
|
def toggle_fullscreen = O.send(@handle, "toggleFullScreen:", 0, args: [:pointer], result: :void)
|
|
154
|
+
def move_to_display(display)
|
|
155
|
+
validate_display!(display)
|
|
156
|
+
screens = O.send(O.klass("NSScreen"), "screens")
|
|
157
|
+
count = O.send(screens, "count", result: :ulong)
|
|
158
|
+
screen = Array.new(count) { |index| O.send(screens, "objectAtIndex:", index, args: [:ulong]) }.find do |candidate|
|
|
159
|
+
description = O.send(candidate, "deviceDescription")
|
|
160
|
+
identifier = O.send(description, "objectForKey:", O.string("NSScreenNumber"), args: [:pointer])
|
|
161
|
+
O.send(identifier, "unsignedIntValue", result: :uint) == display.id
|
|
162
|
+
end
|
|
163
|
+
raise Error, "display is no longer available" unless screen
|
|
164
|
+
|
|
165
|
+
x, y, _width, height = O.send(screen, "frame", result: :rect)
|
|
166
|
+
O.send(@handle, "setFrameTopLeftPoint:", [x, y + height], args: [:point], result: :void)
|
|
167
|
+
true
|
|
168
|
+
end
|
|
154
169
|
|
|
155
170
|
KEYS = {36 => "enter", 48 => "tab", 49 => "space", 51 => "backspace", 53 => "esc", 117 => "delete", 115 => "home", 119 => "end", 116 => "pageup", 121 => "pagedown", 123 => "left", 124 => "right", 125 => "down", 126 => "up"}.freeze
|
|
156
171
|
def native_input(name, event)
|
|
@@ -400,6 +400,21 @@ module Zaniah
|
|
|
400
400
|
@user.fn(:SetWindowPos, [P, P, I, I, I, I, U], I).call(@handle, 0, left, top, right - left, bottom - top, 0x0020)
|
|
401
401
|
end
|
|
402
402
|
end
|
|
403
|
+
def move_to_display(display)
|
|
404
|
+
validate_display!(display)
|
|
405
|
+
target = displays.find { |candidate| candidate.id == display.id }
|
|
406
|
+
raise Error, "display is no longer available" unless target
|
|
407
|
+
|
|
408
|
+
monitor = "\0" * 104
|
|
409
|
+
monitor[0, 4] = [104].pack("I")
|
|
410
|
+
raise Error, "GetMonitorInfoW failed" if @user.fn(:GetMonitorInfoW, [P, P], I).call(target.id, monitor).zero?
|
|
411
|
+
left, top = monitor[4, 8].unpack("l2")
|
|
412
|
+
flags = 0x0001 | 0x0004 | 0x0010 # SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE
|
|
413
|
+
moved = @user.fn(:SetWindowPos, [P, P, I, I, I, I, U], I).call(@handle, 0, left, top, 0, 0, flags)
|
|
414
|
+
raise Error, "SetWindowPos failed" if moved.zero?
|
|
415
|
+
|
|
416
|
+
true
|
|
417
|
+
end
|
|
403
418
|
def close
|
|
404
419
|
return false unless super
|
|
405
420
|
Accessibility.close(self)
|