autogui 1.0.0

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,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "stringio"
5
+
6
+ module AutoGUI
7
+ # Packed RGB bitmap used by screenshot and locate* functions.
8
+ # +data+ is a binary string of 3-byte RGB pixels, row-major, top-down.
9
+ class Image
10
+ attr_reader :width, :height, :data
11
+
12
+ def initialize(width, height, data = nil)
13
+ @width = width.to_i
14
+ @height = height.to_i
15
+ expected = @width * @height * 3
16
+ @data =
17
+ if data.nil?
18
+ "\x00".b * expected
19
+ else
20
+ d = data.dup.force_encoding(Encoding::BINARY)
21
+ raise AutoGUIException, "image data size mismatch" unless d.bytesize == expected
22
+
23
+ d
24
+ end
25
+ end
26
+
27
+ def size
28
+ [@width, @height]
29
+ end
30
+
31
+ def [](x, y = nil)
32
+ if y.nil? && x.is_a?(Array)
33
+ y = x[1]
34
+ x = x[0]
35
+ end
36
+ getpixel(x, y)
37
+ end
38
+
39
+ def getpixel(x, y)
40
+ x = x.to_i
41
+ y = y.to_i
42
+ raise ArgumentError, "pixel out of bounds" unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
43
+
44
+ i = (y * @width + x) * 3
45
+ [@data.getbyte(i), @data.getbyte(i + 1), @data.getbyte(i + 2)]
46
+ end
47
+
48
+ def putpixel(x, y, rgb)
49
+ x = x.to_i
50
+ y = y.to_i
51
+ raise ArgumentError, "pixel out of bounds" unless x.between?(0, @width - 1) && y.between?(0, @height - 1)
52
+
53
+ i = (y * @width + x) * 3
54
+ @data.setbyte(i, rgb[0].to_i)
55
+ @data.setbyte(i + 1, rgb[1].to_i)
56
+ @data.setbyte(i + 2, rgb[2].to_i)
57
+ end
58
+
59
+ def grayscale
60
+ out = String.new(capacity: @width * @height * 3, encoding: Encoding::BINARY)
61
+ i = 0
62
+ while i < @data.bytesize
63
+ r = @data.getbyte(i)
64
+ g = @data.getbyte(i + 1)
65
+ b = @data.getbyte(i + 2)
66
+ y = ((r * 299) + (g * 587) + (b * 114)) / 1000
67
+ out << y.chr << y.chr << y.chr
68
+ i += 3
69
+ end
70
+ Image.new(@width, @height, out)
71
+ end
72
+
73
+ def crop(left, top, width, height)
74
+ left = left.to_i
75
+ top = top.to_i
76
+ width = width.to_i
77
+ height = height.to_i
78
+ raise ArgumentError, "invalid crop" if width <= 0 || height <= 0
79
+ raise ArgumentError, "crop out of bounds" unless left >= 0 && top >= 0 && left + width <= @width && top + height <= @height
80
+
81
+ out = String.new(capacity: width * height * 3, encoding: Encoding::BINARY)
82
+ height.times do |row|
83
+ src = ((top + row) * @width + left) * 3
84
+ out << @data.byteslice(src, width * 3)
85
+ end
86
+ Image.new(width, height, out)
87
+ end
88
+
89
+ def save(path)
90
+ path = path.to_s
91
+ ext = File.extname(path).downcase
92
+ File.binwrite(path, ext == ".bmp" ? to_bmp : to_png)
93
+ self
94
+ end
95
+
96
+ def to_png
97
+ raw = String.new(capacity: @height * (1 + @width * 3), encoding: Encoding::BINARY)
98
+ @height.times do |y|
99
+ raw << "\x00"
100
+ raw << @data.byteslice(y * @width * 3, @width * 3)
101
+ end
102
+ compressed = Zlib::Deflate.deflate(raw, Zlib::BEST_SPEED)
103
+ png = "\x89PNG\r\n\x1a\n".b
104
+ png << png_chunk("IHDR", [@width, @height, 8, 2, 0, 0, 0].pack("NNC5"))
105
+ png << png_chunk("IDAT", compressed)
106
+ png << png_chunk("IEND", "".b)
107
+ png
108
+ end
109
+
110
+ def to_bmp
111
+ row_stride = ((@width * 3 + 3) / 4) * 4
112
+ pixel_size = row_stride * @height
113
+ file_size = 54 + pixel_size
114
+ file_header = ["BM", file_size, 0, 0, 54].pack("a2VvvV")
115
+ info_header = [40, @width, @height, 1, 24, 0, pixel_size, 0, 0, 0, 0].pack("VllvvVVllVV")
116
+ pixels = String.new(capacity: pixel_size, encoding: Encoding::BINARY)
117
+ pad = "\x00".b * (row_stride - @width * 3)
118
+ (@height - 1).downto(0) do |y|
119
+ row = @data.byteslice(y * @width * 3, @width * 3).dup
120
+ i = 0
121
+ while i < row.bytesize
122
+ r = row.getbyte(i)
123
+ b = row.getbyte(i + 2)
124
+ row.setbyte(i, b)
125
+ row.setbyte(i + 2, r)
126
+ i += 3
127
+ end
128
+ pixels << row << pad
129
+ end
130
+ file_header + info_header + pixels
131
+ end
132
+
133
+ def self.open(path_or_image)
134
+ return path_or_image if path_or_image.is_a?(Image)
135
+
136
+ path = path_or_image.to_s
137
+ bytes = File.binread(path)
138
+ if bytes.start_with?("\x89PNG".b)
139
+ from_png(bytes)
140
+ elsif bytes.start_with?("BM")
141
+ from_bmp(bytes)
142
+ else
143
+ raise AutoGUIException, "unsupported image format: #{path} (use PNG or BMP)"
144
+ end
145
+ end
146
+
147
+ def self.from_png(bytes)
148
+ raise AutoGUIException, "invalid PNG signature" unless bytes.start_with?("\x89PNG\r\n\x1a\n".b)
149
+
150
+ offset = 8
151
+ width = height = nil
152
+ bit_depth = color_type = nil
153
+ idat = String.new(encoding: Encoding::BINARY)
154
+ palette = nil
155
+ until offset >= bytes.bytesize
156
+ length = bytes.byteslice(offset, 4).unpack1("N")
157
+ type = bytes.byteslice(offset + 4, 4)
158
+ data = bytes.byteslice(offset + 8, length)
159
+ offset += 12 + length
160
+ case type
161
+ when "IHDR"
162
+ width, height, bit_depth, color_type = data.unpack("NNC2")
163
+ when "PLTE"
164
+ palette = data
165
+ when "IDAT"
166
+ idat << data
167
+ when "IEND"
168
+ break
169
+ end
170
+ end
171
+ raise AutoGUIException, "PNG missing IHDR" if width.nil?
172
+ raise AutoGUIException, "only 8-bit PNG is supported" unless bit_depth == 8
173
+
174
+ inflated = Zlib::Inflate.inflate(idat)
175
+ channels =
176
+ case color_type
177
+ when 0 then 1
178
+ when 2 then 3
179
+ when 3 then 1
180
+ when 4 then 2
181
+ when 6 then 4
182
+ else
183
+ raise AutoGUIException, "unsupported PNG color type #{color_type}"
184
+ end
185
+ rgb = unfilter_png(inflated, width, height, channels, color_type, palette)
186
+ new(width, height, rgb)
187
+ end
188
+
189
+ def self.from_bmp(bytes)
190
+ _magic, _file_size, _res1, _res2, offset = bytes.unpack("a2VvvV")
191
+ header_size, width, height, _planes, bits, compression =
192
+ bytes.byteslice(14, 24).unpack("VllvvV")
193
+ raise AutoGUIException, "compressed BMP is not supported" unless compression.to_i.zero?
194
+ raise AutoGUIException, "unsupported BMP bit depth" unless [24, 32].include?(bits)
195
+
196
+ top_down = height.negative?
197
+ height = height.abs
198
+ bytes_pp = bits / 8
199
+ row_stride = ((width * bytes_pp + 3) / 4) * 4
200
+ rgb = String.new(capacity: width * height * 3, encoding: Encoding::BINARY)
201
+ height.times do |row|
202
+ src_row = top_down ? row : (height - 1 - row)
203
+ src = offset + src_row * row_stride
204
+ width.times do |x|
205
+ i = src + x * bytes_pp
206
+ b = bytes.getbyte(i)
207
+ g = bytes.getbyte(i + 1)
208
+ r = bytes.getbyte(i + 2)
209
+ rgb << r.chr << g.chr << b.chr
210
+ end
211
+ end
212
+ new(width, height, rgb)
213
+ end
214
+
215
+ def self.from_bgra(width, height, bgra, stride = nil)
216
+ stride ||= width * 4
217
+ rgb = String.new(capacity: width * height * 3, encoding: Encoding::BINARY)
218
+ height.times do |y|
219
+ row = y * stride
220
+ width.times do |x|
221
+ i = row + x * 4
222
+ rgb << bgra.getbyte(i + 2).chr << bgra.getbyte(i + 1).chr << bgra.getbyte(i).chr
223
+ end
224
+ end
225
+ new(width, height, rgb)
226
+ end
227
+
228
+ def self.from_pixels(width, height, pixels)
229
+ data = String.new(capacity: width * height * 3, encoding: Encoding::BINARY)
230
+ pixels.each do |r, g, b|
231
+ data << r.chr << g.chr << b.chr
232
+ end
233
+ new(width, height, data)
234
+ end
235
+
236
+ class << self
237
+ private
238
+
239
+ def png_chunk_crc(type, data)
240
+ Zlib.crc32(type + data)
241
+ end
242
+
243
+ def unfilter_png(inflated, width, height, channels, color_type, palette)
244
+ bpp = channels
245
+ stride = width * bpp
246
+ prev = "\x00".b * stride
247
+ rgb = String.new(capacity: width * height * 3, encoding: Encoding::BINARY)
248
+ pos = 0
249
+ height.times do
250
+ filter = inflated.getbyte(pos)
251
+ pos += 1
252
+ raw = inflated.byteslice(pos, stride).dup
253
+ pos += stride
254
+ recon = apply_filter(filter, raw, prev, bpp)
255
+ prev = recon
256
+ case color_type
257
+ when 2
258
+ rgb << recon
259
+ when 0
260
+ recon.bytesize.times { |i| rgb << recon[i] << recon[i] << recon[i] }
261
+ when 4
262
+ (width).times do |x|
263
+ g = recon.getbyte(x * 2)
264
+ a = recon.getbyte(x * 2 + 1)
265
+ v = composite_white(g, a)
266
+ rgb << v.chr << v.chr << v.chr
267
+ end
268
+ when 6
269
+ width.times do |x|
270
+ i = x * 4
271
+ a = recon.getbyte(i + 3)
272
+ rgb << composite_white(recon.getbyte(i), a).chr
273
+ rgb << composite_white(recon.getbyte(i + 1), a).chr
274
+ rgb << composite_white(recon.getbyte(i + 2), a).chr
275
+ end
276
+ when 3
277
+ raise AutoGUIException, "PNG palette missing" if palette.nil?
278
+
279
+ width.times do |x|
280
+ idx = recon.getbyte(x) * 3
281
+ rgb << palette.getbyte(idx).chr << palette.getbyte(idx + 1).chr << palette.getbyte(idx + 2).chr
282
+ end
283
+ end
284
+ end
285
+ rgb
286
+ end
287
+
288
+ def apply_filter(filter, raw, prev, bpp)
289
+ recon = raw.dup
290
+ case filter
291
+ when 0
292
+ recon
293
+ when 1
294
+ recon.bytesize.times do |i|
295
+ left = i >= bpp ? recon.getbyte(i - bpp) : 0
296
+ recon.setbyte(i, (raw.getbyte(i) + left) & 255)
297
+ end
298
+ recon
299
+ when 2
300
+ recon.bytesize.times do |i|
301
+ recon.setbyte(i, (raw.getbyte(i) + prev.getbyte(i)) & 255)
302
+ end
303
+ recon
304
+ when 3
305
+ recon.bytesize.times do |i|
306
+ left = i >= bpp ? recon.getbyte(i - bpp) : 0
307
+ up = prev.getbyte(i)
308
+ recon.setbyte(i, (raw.getbyte(i) + ((left + up) / 2)) & 255)
309
+ end
310
+ recon
311
+ when 4
312
+ recon.bytesize.times do |i|
313
+ a = i >= bpp ? recon.getbyte(i - bpp) : 0
314
+ b = prev.getbyte(i)
315
+ c = i >= bpp ? prev.getbyte(i - bpp) : 0
316
+ recon.setbyte(i, (raw.getbyte(i) + paeth(a, b, c)) & 255)
317
+ end
318
+ recon
319
+ else
320
+ raise AutoGUIException, "unsupported PNG filter #{filter}"
321
+ end
322
+ end
323
+
324
+ def paeth(a, b, c)
325
+ p = a + b - c
326
+ pa = (p - a).abs
327
+ pb = (p - b).abs
328
+ pc = (p - c).abs
329
+ return a if pa <= pb && pa <= pc
330
+ return b if pb <= pc
331
+
332
+ c
333
+ end
334
+
335
+ def composite_white(value, alpha)
336
+ ((value * alpha) + (255 * (255 - alpha))) / 255
337
+ end
338
+ end
339
+
340
+ def png_chunk(type, data)
341
+ [data.bytesize].pack("N") + type + data + [Zlib.crc32(type + data)].pack("N")
342
+ end
343
+ end
344
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AutoGUI
4
+ KEY_NAMES = [
5
+ "\t", "\n", "\r", " ", "!", "\"", "#", "$", "%", "&", "'", "(",
6
+ ")", "*", "+", ",", "-", ".", "/", "0", "1", "2", "3", "4", "5", "6", "7",
7
+ "8", "9", ":", ";", "<", "=", ">", "?", "@", "[", "\\", "]", "^", "_", "`",
8
+ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o",
9
+ "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "{", "|", "}", "~",
10
+ "accept", "add", "alt", "altleft", "altright", "apps", "backspace",
11
+ "browserback", "browserfavorites", "browserforward", "browserhome",
12
+ "browserrefresh", "browsersearch", "browserstop", "capslock", "clear",
13
+ "convert", "ctrl", "ctrlleft", "ctrlright", "decimal", "del", "delete",
14
+ "divide", "down", "end", "enter", "esc", "escape", "execute", "f1", "f10",
15
+ "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f19", "f2", "f20",
16
+ "f21", "f22", "f23", "f24", "f3", "f4", "f5", "f6", "f7", "f8", "f9",
17
+ "final", "fn", "hanguel", "hangul", "hanja", "help", "home", "insert",
18
+ "junja", "kana", "kanji", "launchapp1", "launchapp2", "launchmail",
19
+ "launchmediaselect", "left", "modechange", "multiply", "nexttrack",
20
+ "nonconvert", "num0", "num1", "num2", "num3", "num4", "num5", "num6",
21
+ "num7", "num8", "num9", "numlock", "pagedown", "pageup", "pause", "pgdn",
22
+ "pgup", "playpause", "prevtrack", "print", "printscreen", "prntscrn",
23
+ "prtsc", "prtscr", "return", "right", "scrolllock", "select", "separator",
24
+ "shift", "shiftleft", "shiftright", "sleep", "space", "stop", "subtract",
25
+ "tab", "up", "volumedown", "volumemute", "volumeup", "win", "winleft",
26
+ "winright", "yen", "command", "option", "optionleft", "optionright"
27
+ ].freeze
28
+
29
+ KEYBOARD_KEYS = KEY_NAMES
30
+
31
+ LEFT = "left"
32
+ MIDDLE = "middle"
33
+ RIGHT = "right"
34
+ PRIMARY = "primary"
35
+ SECONDARY = "secondary"
36
+
37
+ QWERTY = "`1234567890-=qwertyuiop[]\\asdfghjkl;'zxcvbnm,./~!@\#$%^&*()_+QWERTYUIOP{}|ASDFGHJKL:\"ZXCVBNM<>?".freeze
38
+ QWERTZ = "=1234567890/0qwertzuiop89-asdfghjkl,\\yxcvbnm,.7+!@\#$%^&*()?)QWERTZUIOP*(_ASDFGHJKL<|YXCVBNM<>&".freeze
39
+
40
+ SHIFT_CHARS = ['~', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '<', '>', '?'].freeze
41
+
42
+ def self.shift_character?(character)
43
+ return false if character.nil? || character.empty?
44
+
45
+ character != character.downcase || SHIFT_CHARS.include?(character)
46
+ end
47
+ class << self
48
+ alias isShiftCharacter shift_character?
49
+ end
50
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ module AutoGUI
7
+ module MessageBox
8
+ MB_OK = 0x00000000
9
+ MB_OKCANCEL = 0x00000001
10
+ MB_YESNOCANCEL = 0x00000003
11
+ MB_YESNO = 0x00000004
12
+ IDOK = 1
13
+ IDCANCEL = 2
14
+ IDYES = 6
15
+ IDNO = 7
16
+
17
+ module_function
18
+
19
+ def alert(text = "", title = "AutoGUI Alert", button = "OK")
20
+ if windows?
21
+ Platform.current.message_box(text.to_s, title.to_s, MB_OK)
22
+ button.to_s
23
+ else
24
+ dialog_alert(text, title, button)
25
+ button.to_s
26
+ end
27
+ end
28
+
29
+ def confirm(text = "", title = "AutoGUI Confirm", buttons = %w[OK Cancel])
30
+ buttons = Array(buttons)
31
+ if windows? && buttons.map(&:to_s) == %w[OK Cancel]
32
+ result = Platform.current.message_box(text.to_s, title.to_s, MB_OKCANCEL)
33
+ result == IDOK ? "OK" : "Cancel"
34
+ elsif windows? && buttons.map(&:to_s) == %w[Yes No]
35
+ result = Platform.current.message_box(text.to_s, title.to_s, MB_YESNO)
36
+ result == IDYES ? "Yes" : "No"
37
+ else
38
+ dialog_choice(text, title, buttons)
39
+ end
40
+ end
41
+
42
+ def prompt(text = "", title = "AutoGUI Prompt", default = "")
43
+ dialog_input(text, title, default, password: false)
44
+ end
45
+
46
+ def password(text = "", title = "AutoGUI Password", default = "", mask = "*")
47
+ dialog_input(text, title, default, password: true, mask: mask)
48
+ end
49
+
50
+ def windows?
51
+ RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/i
52
+ end
53
+
54
+ def dialog_alert(text, title, _button)
55
+ if windows?
56
+ Platform.current.message_box(text.to_s, title.to_s, MB_OK)
57
+ else
58
+ system("osascript", "-e", %(display dialog #{js(text)} with title #{js(title)} buttons {"OK"} default button 1)) ||
59
+ system("zenity", "--info", "--title=#{title}", "--text=#{text}")
60
+ end
61
+ end
62
+
63
+ def dialog_choice(text, title, buttons)
64
+ if windows?
65
+ vbs = <<~VBS
66
+ Set fso = CreateObject("Scripting.FileSystemObject")
67
+ Set stdout = fso.GetStandardStream(1)
68
+ r = MsgBox(#{vbs_str(text)}, vbYesNoCancel, #{vbs_str(title)})
69
+ If r = vbYes Then stdout.Write "Yes"
70
+ If r = vbNo Then stdout.Write "No"
71
+ If r = vbCancel Then stdout.Write "Cancel"
72
+ VBS
73
+ out = run_vbs(vbs)
74
+ mapped = { "Yes" => buttons[0], "No" => buttons[1], "Cancel" => buttons[2] || buttons[-1] }
75
+ mapped[out] || buttons[-1]
76
+ else
77
+ btns = buttons.map(&:to_s)
78
+ script = "set r to button returned of (display dialog #{js(text)} with title #{js(title)} buttons {#{btns.map { |b| js(b) }.join(',')}} default button 1)"
79
+ out, = Open3.capture2("osascript", "-e", script)
80
+ out.strip.empty? ? nil : out.strip
81
+ end
82
+ end
83
+
84
+ def dialog_input(text, title, default, password: false, mask: "*")
85
+ if windows?
86
+ vbs = <<~VBS
87
+ Set fso = CreateObject("Scripting.FileSystemObject")
88
+ Set stdout = fso.GetStandardStream(1)
89
+ r = InputBox(#{vbs_str(text)}, #{vbs_str(title)}, #{vbs_str(default)})
90
+ If IsEmpty(r) Then
91
+ stdout.Write Chr(0)
92
+ Else
93
+ stdout.Write r
94
+ End If
95
+ VBS
96
+ out = run_vbs(vbs)
97
+ return nil if out == "\u0000" || out.nil?
98
+
99
+ out
100
+ else
101
+ hidden = password ? " with hidden answer" : ""
102
+ script = "text returned of (display dialog #{js(text)} with title #{js(title)} default answer #{js(default)}#{hidden})"
103
+ out, status = Open3.capture2("osascript", "-e", script)
104
+ status.success? ? out.sub(/\r?\n\z/, "") : nil
105
+ end
106
+ end
107
+
108
+ def run_vbs(script)
109
+ file = Tempfile.new(["autogui", ".vbs"])
110
+ file.write(script)
111
+ file.close
112
+ out, = Open3.capture2("cscript", "//Nologo", file.path)
113
+ File.unlink(file.path) rescue nil
114
+ out
115
+ end
116
+
117
+ def vbs_str(s)
118
+ %("#{s.to_s.gsub('"', '""')}")
119
+ end
120
+
121
+ def js(s)
122
+ s.to_s.inspect
123
+ end
124
+ end
125
+ end