castanaut 1.0.0 → 1.1.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.
@@ -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