zaniah 0.6.2 → 0.6.4

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,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 positive" unless height.is_a?(Numeric) && height.finite? && height.positive?
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 <= 0
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)