castanaut 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,24 +1,67 @@
1
1
  module Castanaut
2
- # The movie class is the containing context within which screenplays are
3
- # invoked. It provides a number of basic stage directions for your
2
+
3
+ # The movie class is the containing context within which screenplays are
4
+ # invoked. It provides a number of basic stage directions for your
4
5
  # screenplays, and can be extended with plugins.
6
+ #
7
+ # If you're working to make Castanaut compatible with your operating system,
8
+ # you must make sure that *all* methods in this class work correctly.
5
9
  class Movie
6
10
 
7
- # Runs the "screenplay", which is a file containing Castanaut instructions.
11
+ def self.register(name)
12
+ unless reg = Castanaut::Movie.instance_variable_get(:@movie_classes)
13
+ reg = Castanaut::Movie.instance_variable_set(:@movie_classes, {})
14
+ end
15
+ self.instance_variable_set(:@name, name)
16
+ reg.update(self => name)
17
+ end
18
+
19
+
20
+ def self.spawn(screenplay = nil, monitor = true)
21
+ reg = Castanaut::Movie.instance_variable_get(:@movie_classes)
22
+ klass = reg.keys.detect { |k| k.platform_supported? }
23
+ klass.new(screenplay, monitor)
24
+ end
25
+
26
+
27
+ # Creates the movie. If a screenplay is provided here, it will be run.
28
+ # If monitor is true, we'll monitor the kill file (FILE_RUNNING) -
29
+ # if it is deleted, we abort.
8
30
  #
9
- def initialize(screenplay)
10
- perms_test
31
+ def initialize(screenplay = nil, monitor = true)
32
+ if self.class == Castanaut::Movie
33
+ raise "#{self} is an abstract class. Try the spawn method."
34
+ end
11
35
 
12
- if !screenplay || !File.exists?(screenplay)
13
- raise Castanaut::Exceptions::ScreenplayNotFound
36
+ if screenplay
37
+ monitor ? _play_and_monitor(screenplay) : _play(screenplay)
38
+ end
39
+ end
40
+
41
+
42
+ # Simply plays the screenplay in the current thread.
43
+ def _play(screenplay)
44
+ unless File.exists?(@screenplay_path = screenplay)
45
+ raise Castanaut::Exceptions::ScreenplayNotFound
46
+ end
47
+ eval(IO.read(@screenplay_path), binding)
48
+ roll_credits
49
+ end
50
+
51
+
52
+ # Plays the screenplay in a separate thread, and monitors the killfile
53
+ # (which is at FILE_RUNNING) - if it is deleted, the screenplay will
54
+ # abort.
55
+ def _play_and_monitor(screenplay)
56
+ unless File.exists?(@screenplay_path = screenplay)
57
+ raise Castanaut::Exceptions::ScreenplayNotFound
14
58
  end
15
- @screenplay_path = screenplay
16
59
 
17
60
  File.open(FILE_RUNNING, 'w') {|f| f.write('')}
18
61
 
19
62
  begin
20
- # We run the movie in a separate thread; in the main thread we
21
- # continue to check the "running" file flag and kill the movie if
63
+ # We run the movie in a separate thread; in the main thread we
64
+ # continue to check the "running" file flag and kill the movie if
22
65
  # it is removed.
23
66
  movie = Thread.new do
24
67
  begin
@@ -49,123 +92,55 @@ module Castanaut
49
92
  end
50
93
  end
51
94
 
52
- # Launch the application matching the string given in the first argument.
53
- # (This resolution is handled by Applescript.)
54
- #
55
- # If the options hash is given, it should contain the co-ordinates for
56
- # the window (top, left, width, height). The to method will format these
57
- # co-ordinates appropriately.
58
- #
59
- def launch(app_name, *options)
60
- options = combine_options(*options)
61
-
62
- ensure_window = ""
63
- case app_name.downcase
64
- when "safari"
65
- ensure_window = "if (count(windows)) < 1 then make new document"
66
- end
67
95
 
68
- positioning = ""
69
- if options[:to]
70
- pos = "#{options[:to][:left]}, #{options[:to][:top]}"
71
- dims = "#{options[:to][:left] + options[:to][:width]}, " +
72
- "#{options[:to][:top] + options[:to][:height]}"
73
- if options[:to][:width]
74
- positioning = "set bounds of front window to {#{pos}, #{dims}}"
75
- else
76
- positioning = "set position of front window to {#{pos}}"
77
- end
78
- end
79
96
 
80
- execute_applescript(%Q`
81
- tell application "#{app_name}"
82
- activate
83
- #{ensure_window}
84
- #{positioning}
85
- end tell
86
- `)
87
- end
97
+ #--------------------------------------------------------------------------
98
+ # IMPLEMENTED DIRECTIONS
99
+ #
100
+ # You can override these in subclasses, but you'd probably want to have
101
+ # a very good reason.
102
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
88
103
 
89
- # Move the mouse cursor to the specified co-ordinates.
104
+ # Don't do anything for the specified number of seconds (can be portions
105
+ # of a second).
90
106
  #
91
- def cursor(*options)
92
- options = combine_options(*options)
93
- apply_offset(options)
94
- @cursor_loc ||= {}
95
- @cursor_loc[:x] = options[:to][:left]
96
- @cursor_loc[:y] = options[:to][:top]
97
- automatically "mousemove #{@cursor_loc[:x]} #{@cursor_loc[:y]}"
107
+ def pause(seconds)
108
+ sleep seconds
98
109
  end
99
110
 
100
- alias :move :cursor
101
111
 
102
- # Send a mouse-click at the current mouse location.
112
+ # Groups directions into labelled blocks. This lets you skip (see below)
113
+ # to the end of the block if you need to.
103
114
  #
104
- def click(btn = 'left')
105
- automatically "mouseclick #{mouse_button_translate(btn)}"
106
- end
107
-
108
- # Send a double-click at the current mouse location.
115
+ # perform "Build CouchDB from source" do
116
+ # launch "Terminal"
117
+ # type "./configure"
118
+ # hit Enter
119
+ # ...
120
+ # end
109
121
  #
110
- def doubleclick(btn = 'left')
111
- automatically "mousedoubleclick #{mouse_button_translate(btn)}"
112
- end
113
-
114
- # Send a triple-click at the current mouse location.
115
- #
116
- def tripleclick(btn = 'left')
117
- automatically "mousetripleclick #{mouse_button_translate(btn)}"
122
+ def perform(label)
123
+ yield
124
+ rescue Castanaut::Exceptions::SkipError => e
125
+ puts "Skipping remaining directions in '#{label}'"
118
126
  end
119
127
 
120
- # Press the button down at the current mouse location. Does not
121
- # release the button until the mouseup method is invoked.
122
- #
123
- def mousedown(btn = 'left')
124
- automatically "mousedown #{mouse_button_translate(btn)}"
125
- end
126
128
 
127
- # Releases the mouse button pressed by a previous mousedown.
129
+ # Lets you skip out of a perform block if you need to. Usually raised
130
+ # when some condition fails. For example:
128
131
  #
129
- def mouseup(btn = 'left')
130
- automatically "mouseup #{mouse_button_translate(btn)}"
131
- end
132
-
133
- # "Drags" the mouse by (effectively) issuing a mousedown at the current
134
- # mouse location, then moving the mouse to the specified coordinates, then
135
- # issuing a mouseup.
132
+ # perform "Point to heading" do
136
133
  #
137
- def drag(*options)
138
- options = combine_options(*options)
139
- apply_offset(options)
140
- automatically "mousedrag #{options[:to][:left]} #{options[:to][:top]}"
141
- end
142
-
143
- # Sends the characters into the active control in the active window.
134
+ # move to_element('h2') rescue skip
135
+ # say "This is the heading."
144
136
  #
145
- def type(str)
146
- automatically "type #{str}"
147
- end
148
-
149
- # Sends the keycode (a hex value) to the active control in the active
150
- # window. For more about keycode values, see Mac Developer documentation.
137
+ # end
151
138
  #
152
- def hit(key)
153
- automatically "hit #{key}"
139
+ def skip
140
+ raise Castanaut::Exceptions::SkipError
154
141
  end
155
142
 
156
- # Don't do anything for the specified number of seconds (can be portions
157
- # of a second).
158
- #
159
- def pause(seconds)
160
- sleep seconds
161
- end
162
143
 
163
- # Use Leopard's native text-to-speech functionality to emulate a human
164
- # voice saying the narrative text.
165
- #
166
- def say(narrative)
167
- run(%Q`say "#{escape_dq(narrative)}"`)
168
- end
169
144
 
170
145
  # Starts saying the narrative text, and simultaneously begins executing
171
146
  # the given block. Waits until both are finished.
@@ -180,6 +155,7 @@ module Castanaut
180
155
  end
181
156
  end
182
157
 
158
+
183
159
  # Get a hash representing specific screen co-ordinates. Use in combination
184
160
  # with cursor, drag, launch, and similar methods.
185
161
  #
@@ -197,18 +173,17 @@ module Castanaut
197
173
 
198
174
  alias :at :to
199
175
 
176
+
200
177
  # Get a hash representing specific screen co-ordinates *relative to the
201
178
  # current mouse location.
202
179
  #
203
180
  def by(x, y)
204
- unless @cursor_loc
205
- @cursor_loc = automatically("mouselocation").strip.split(' ')
206
- @cursor_loc = {:x => @cursor_loc[0].to_i, :y => @cursor_loc[1].to_i}
207
- end
181
+ @cursor_loc ||= cursor_location
208
182
  to(@cursor_loc[:x] + x, @cursor_loc[:y] + y)
209
183
  end
210
184
 
211
- # The result of this method can be added +to+ a co-ordinates hash,
185
+
186
+ # The result of this method can be added +to+ a co-ordinates hash,
212
187
  # offsetting the top and left values by the given margins.
213
188
  #
214
189
  def offset(x, y)
@@ -216,41 +191,15 @@ module Castanaut
216
191
  end
217
192
 
218
193
 
219
- # Returns a region hash describing the entire screen area. (May be wonky
220
- # for multi-monitor set-ups.)
221
- #
222
- def screen_size
223
- coords = execute_applescript(%Q`
224
- tell application "Finder"
225
- get bounds of window of desktop
226
- end tell
227
- `)
228
- coords = coords.split(", ").collect {|c| c.to_i}
229
- to(*coords)
230
- end
231
-
232
- # Runs a shell command, performing fairly naive (but effective!) exit
194
+ # Runs a shell command, performing fairly naive (but effective!) exit
233
195
  # status handling. Returns the stdout result of the command.
234
196
  #
235
197
  def run(cmd)
236
- #puts("Executing: #{cmd}")
237
198
  result = `#{cmd}`
238
199
  raise Castanaut::Exceptions::ExternalActionError if $?.exitstatus > 0
239
200
  result
240
201
  end
241
-
242
- # Adds custom methods to this movie instance, allowing you to perform
243
- # additional actions. See the README.txt for more information.
244
- #
245
- def plugin(str)
246
- str.downcase!
247
- begin
248
- require File.join(File.dirname(@screenplay_path),"plugins","#{str}.rb")
249
- rescue LoadError
250
- require File.join(LIBPATH, "plugins", "#{str}.rb")
251
- end
252
- extend eval("Castanaut::Plugin::#{str.capitalize}")
253
- end
202
+
254
203
 
255
204
  # Loads a script from a file into a string, looking first in the
256
205
  # scripts directory beneath the path where Castanaut was executed,
@@ -259,8 +208,7 @@ module Castanaut
259
208
  def script(filename)
260
209
  @cached_scripts ||= {}
261
210
  unless @cached_scripts[filename]
262
- fpath = File.join(File.dirname(@screenplay_path), "scripts", filename)
263
- scpt = nil
211
+ fpath = contextual_path("scripts", filename)
264
212
  if File.exists?(fpath)
265
213
  scpt = IO.read(fpath)
266
214
  else
@@ -272,6 +220,34 @@ module Castanaut
272
220
  @cached_scripts[filename]
273
221
  end
274
222
 
223
+
224
+ # Adds custom methods to this movie instance, allowing you to perform
225
+ # additional actions. The str can be either the file name
226
+ # (e.g. 'snapz_pro') or the class name (e.g. 'SnapzPro').
227
+ # See the README.txt for more information.
228
+ #
229
+ # FIXME: sort out this underscore/camelize mess.
230
+ #
231
+ def plugin(str)
232
+ # copied stright from the Rails underscore helper
233
+ str = str.to_s
234
+ str.gsub!(/::/, '/')
235
+ str.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
236
+ str.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
237
+ str.tr!("-", "_")
238
+ str.downcase!
239
+ fpath =
240
+ begin
241
+ require contextual_path("plugins", "#{str}.rb")
242
+ rescue LoadError
243
+ require File.join(LIBPATH, "plugins", "#{str}.rb")
244
+ end
245
+ # copied stright from the Rails camelize helper
246
+ str = str.to_s.gsub(/\/(.?)/) { "::" + $1.upcase }.gsub(/(^|_)(.)/) { $2.upcase }
247
+ extend eval("Castanaut::Plugin::#{str}")
248
+ end
249
+
250
+
275
251
  # This stage direction is slightly different to the other ones. It collects
276
252
  # a set of directions to be executed when the movie ends, or when it is
277
253
  # aborted by the user. Mostly, it's used for cleaning up stuff. Here's
@@ -293,45 +269,202 @@ module Castanaut
293
269
  @end_credits << blk
294
270
  end
295
271
 
272
+
273
+ #--------------------------------------------------------------------------
274
+ # KEYBOARD INPUT DIRECTIONS
275
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
276
+
277
+ # Sends the characters into the active control in the active window.
278
+ #
279
+ # Options are:
280
+ #
281
+ # * <tt>:speed</tt> - approximate umber of characters per second
282
+ # A speed of 0 types as quickly as possible. (default - 50)
283
+ #
284
+ def type(str, opts = {})
285
+ not_supported('type')
286
+ end
287
+
288
+
289
+ # Hit a single key on the keyboard (with optional modifiers).
290
+ #
291
+ # Valid keys include any single character or any of the constants in keys.rb
292
+ #
293
+ # Valid modifiers include one or more of the following:
294
+ # Command
295
+ # Ctrl
296
+ # Alt
297
+ # Shift
298
+ #
299
+ # Examples:
300
+ # hit Castanaut::Tab
301
+ # hit 'a', Castanaut::Command
302
+ #
303
+ def hit(key, *modifiers)
304
+ not_supported('hit')
305
+ end
306
+
307
+
308
+ #---------------------------------------------------------------------------
309
+ # MOUSE INPUT DIRECTIONS
310
+ #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
311
+
312
+ # Move the mouse cursor to the specified co-ordinates.
313
+ # Example:
314
+ #
315
+ # cursor to(20, 20)
316
+ #
317
+ def cursor(*options)
318
+ not_supported('cursor')
319
+ end
320
+
321
+ alias :move :cursor
322
+
323
+
324
+ # Get a hash representing the current mouse cursor co-ordinates.
325
+ #
326
+ # Should return a hash with :x & :y keys.
327
+ #
328
+ def cursor_location
329
+ not_supported('cursor_location')
330
+ end
331
+
332
+
333
+ # Send a mouse-click at the current mouse location.
334
+ #
335
+ def click(btn = 'left')
336
+ not_supported('click')
337
+ end
338
+
339
+
340
+ # Send a double-click at the current mouse location.
341
+ #
342
+ def doubleclick(btn = 'left')
343
+ not_supported('doubleclick')
344
+ end
345
+
346
+
347
+ # Send a triple-click at the current mouse location.
348
+ #
349
+ def tripleclick(btn = 'left')
350
+ not_supported('tripleclick')
351
+ end
352
+
353
+
354
+ # Press the button down at the current mouse location. Does not
355
+ # release the button until the mouseup method is invoked.
356
+ #
357
+ def mousedown(btn = 'left')
358
+ not_supported('mousedown')
359
+ end
360
+
361
+
362
+ # Releases the mouse button pressed by a previous mousedown.
363
+ #
364
+ def mouseup(btn = 'left')
365
+ not_supported('mouseup')
366
+ end
367
+
368
+
369
+ # "Drags" the mouse by (effectively) issuing a mousedown at the current
370
+ # mouse location, then moving the mouse to the specified coordinates, then
371
+ # issuing a mouseup.
372
+ #
373
+ def drag(*options)
374
+ not_supported('drag')
375
+ end
376
+
377
+
378
+ #--------------------------------------------------------------------------
379
+ # WINDOWS AND APPLICATIONS DIRECTIONS
380
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
381
+
382
+ # Launch the application matching the string given in the first argument.
383
+ # If the options hash is given, it should contain the co-ordinates for
384
+ # the window.
385
+ #
386
+ # Example:
387
+ #
388
+ # launch "Firefox", at(10, 10, 800, 600)
389
+ #
390
+ def launch(app_name, *options)
391
+ not_supported('launch')
392
+ end
393
+
394
+ alias :activate :launch
395
+
396
+
397
+ # Returns a region hash describing the entire screen area.
398
+ #
399
+ # Should return a hash with :width & :height keys.
400
+ #
401
+ def screen_size
402
+ not_supported('screen_size')
403
+ end
404
+
405
+
406
+ #--------------------------------------------------------------------------
407
+ # USEFUL UTILITIES
408
+ #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
409
+
410
+ # Use text-to-speech functionality to emulate a human
411
+ # voice saying the narrative text.
412
+ #
413
+ def say(narrative)
414
+ not_supported('say')
415
+ end
416
+
417
+
296
418
  protected
297
- def execute_applescript(scpt)
298
- File.open(FILE_APPLESCRIPT, 'w') {|f| f.write(scpt)}
299
- result = run("osascript #{FILE_APPLESCRIPT}")
300
- File.unlink(FILE_APPLESCRIPT)
301
- result
419
+
420
+ # Find a file relative to the movie execution context -- that is,
421
+ # either the location of the screenplay file, or the present working
422
+ # directory if there is no screenplay file.
423
+ #
424
+ def contextual_path(*args)
425
+ if @screenplay_path
426
+ File.join(*([File.dirname(@screenplay_path)] + args))
427
+ else
428
+ File.join(*args)
429
+ end
302
430
  end
303
431
 
304
- def automatically(cmd)
305
- run("#{osxautomation_path} \"#{cmd}\"")
432
+
433
+ # A method used by the compatibility layer to raise a NotSupportedError
434
+ # explaining which requested options are not supported by the current
435
+ # operating system.
436
+ #
437
+ # Example:
438
+ # # On a Mac OS 10.5 (Leopard) machine
439
+ # hit 'a', Castanaut::Command
440
+ # => "Mac OS 10.5 (Leopard) does not support modifier keys for
441
+ # the 'hit' method."
442
+ #
443
+ def not_supported(message)
444
+ message.gsub!(/\.$/, '')
445
+ raise Castanaut::Exceptions::NotSupportedError.new(
446
+ "#{self.class.to_s} does not support #{message}."
447
+ )
306
448
  end
307
449
 
450
+
451
+ # Escapes double quotes.
452
+ #
308
453
  def escape_dq(str)
309
454
  str.gsub(/\\/,'\\\\\\').gsub(/"/, '\"')
310
455
  end
311
456
 
312
- def combine_options(*args)
313
- options = args.inject({}) { |result, option| result.update(option) }
314
- end
315
457
 
316
- private
317
- def osxautomation_path
318
- File.join(PATH, "cbin", "osxautomation")
458
+ # Combines a list of hashes into one hash.
459
+ # Example:
460
+ #
461
+ # combine_options({:x=>10}, {:y=>20})
462
+ # # => {:y=>20, :x=>10}
463
+ #
464
+ def combine_options(*args)
465
+ args.inject({}) { |result, option| result.update(option) }
319
466
  end
320
467
 
321
- def perms_test
322
- return if File.executable?(osxautomation_path)
323
- puts "IMPORTANT: Castanaut has recently been installed or updated. " +
324
- "You need to give it the right to control mouse and keyboard " +
325
- "input during screenplays."
326
-
327
- run("sudo chmod a+x #{osxautomation_path}")
328
-
329
- if File.executable?(osxautomation_path)
330
- puts "Permission granted. Thanks."
331
- else
332
- raise Castanaut::Exceptions::OSXAutomationPermissionError
333
- end
334
- end
335
468
 
336
469
  def apply_offset(options)
337
470
  return unless options[:to] && options[:offset]
@@ -339,15 +472,12 @@ module Castanaut
339
472
  options[:to][:top] += options[:offset][:y] || 0
340
473
  end
341
474
 
342
- def mouse_button_translate(btn)
343
- return btn if btn.is_a?(Integer)
344
- {"left" => 1, "right" => 2, "middle" => 3}[btn]
345
- end
346
475
 
347
476
  def roll_credits
348
477
  return unless @end_credits && @end_credits.any?
349
478
  @end_credits.each {|credit| credit.call}
350
479
  end
351
-
480
+
352
481
  end
482
+
353
483
  end