motion-state-machine 0.8.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +19 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +145 -0
- data/Rakefile +31 -0
- data/lib/motion-state-machine.rb +9 -0
- data/lib/motion-state-machine/base.rb +223 -0
- data/lib/motion-state-machine/spec_app_delegate.rb +7 -0
- data/lib/motion-state-machine/state.rb +379 -0
- data/lib/motion-state-machine/transition.rb +407 -0
- data/lib/motion-state-machine/version.rb +3 -0
- data/motion-state-machine.gemspec +19 -0
- data/spec/motion-state-machine/base_spec.rb +118 -0
- data/spec/motion-state-machine/benchmark_spec.rb +74 -0
- data/spec/motion-state-machine/notification_transition_spec.rb +57 -0
- data/spec/motion-state-machine/send_event_transition_spec.rb +53 -0
- data/spec/motion-state-machine/state_spec.rb +219 -0
- data/spec/motion-state-machine/timed_transition_spec.rb +48 -0
- data/spec/motion-state-machine/transition_spec.rb +157 -0
- metadata +90 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Sebastian Burkhart
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
# motion-state-machine
|
2
|
+
|
3
|
+
Hey, this is `motion-state-machine` — a state machine gem designed for
|
4
|
+
[RubyMotion](http://rubymotion.com) for iOS.
|
5
|
+
|
6
|
+
## Motivation
|
7
|
+
|
8
|
+
Undefined states and visual glitches in applications with complex UIs can
|
9
|
+
be a hassle, especially when the UI is animated and the app has to handle
|
10
|
+
asynchronous data retrieved in the background.
|
11
|
+
|
12
|
+
Well-defined UI state machines avoid these problems while ensuring that
|
13
|
+
asynchronous event handling does not lead to undefined results (a.k.a. bugs).
|
14
|
+
|
15
|
+
MacRuby and Cocoa don't provide a simple library to address this —
|
16
|
+
motion-state-machine should fill the gap for RubyMotion developers.
|
17
|
+
|
18
|
+
motion-state-machine comes with a simple and nice-looking syntax to define
|
19
|
+
states and transitions:
|
20
|
+
|
21
|
+
fsm = StateMachine::Base.new start_state: :awake
|
22
|
+
|
23
|
+
fsm.when :awake do |state|
|
24
|
+
state.on_entry { puts "I'm awake, started and alive!" }
|
25
|
+
state.transition_to :sleeping, on: :finished_hard_work,
|
26
|
+
state.die on: :too_hard_work
|
27
|
+
end
|
28
|
+
|
29
|
+
It is [Grand Central Dispatch](https://developer.apple.com/library/mac/#documentation/Performance/Reference/GCD_libdispatch_Ref/Reference/reference.html)-aware
|
30
|
+
and uses GCD queues for synchronization.
|
31
|
+
|
32
|
+
## Installation
|
33
|
+
|
34
|
+
1. If not done yet, add `bundler` gem management to your RubyMotion app.
|
35
|
+
See <http://thunderboltlabs.com/posts/using-bundler-with-rubymotion> for
|
36
|
+
an explanation how.
|
37
|
+
|
38
|
+
2. Add this line to your application's Gemfile:
|
39
|
+
|
40
|
+
gem 'motion-state-machine',
|
41
|
+
git: "git://github.com/opyh/motion-state-machine.git"
|
42
|
+
|
43
|
+
3. Execute:
|
44
|
+
|
45
|
+
$ bundle
|
46
|
+
|
47
|
+
## Usage
|
48
|
+
|
49
|
+
The following example shows how to initialize and define a state machine:
|
50
|
+
|
51
|
+
fsm = StateMachine::Base.new start_state: :working, verbose: true
|
52
|
+
|
53
|
+
This initializes a state machine. Calling `fsm.start!` would start the
|
54
|
+
machine in the defined start state `:working`. Using `:verbose` activates
|
55
|
+
debug output on the console.
|
56
|
+
|
57
|
+
### Defining states and transitions
|
58
|
+
|
59
|
+
After initialization, you can define states and transitions:
|
60
|
+
|
61
|
+
fsm.when :working do |state|
|
62
|
+
|
63
|
+
state.on_entry { puts "I'm awake, started and alive!" }
|
64
|
+
state.on_exit { puts "Phew. That was enough work." }
|
65
|
+
|
66
|
+
state.transition_to :sleeping,
|
67
|
+
on: :finished_hard_work,
|
68
|
+
if: proc { really_worked_enough_for_now? },
|
69
|
+
action: proc { puts "Will go to sleep now." }
|
70
|
+
|
71
|
+
state.die on: :too_hard_work
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
This defines…
|
76
|
+
|
77
|
+
1. An entry and an exit action block, called when entering/exiting the state
|
78
|
+
:working.
|
79
|
+
|
80
|
+
2. a transition from state `:working` to `:sleeping`, happening when calling
|
81
|
+
`fsm.event(:finished_hard_work)`.
|
82
|
+
|
83
|
+
Before the transition is executed, the state machine asks the `:if` guard
|
84
|
+
block if the transition is allowed. Returning `false` in this block would
|
85
|
+
prevent the transition from happening.
|
86
|
+
|
87
|
+
If the transition is executed, the machine calls the given `:action` block.
|
88
|
+
|
89
|
+
3. another transition that terminates the state machine when calling
|
90
|
+
`fsm.event(:too_hard_work)`. When terminated, the state machine stops
|
91
|
+
responding to events.
|
92
|
+
|
93
|
+
Note that a transition from a state to itself can be _internal_: Entry/exit
|
94
|
+
actions are not called on execution in this case.
|
95
|
+
|
96
|
+
### Handling events, timeouts and NSNotifications
|
97
|
+
|
98
|
+
Transitions can be triggered…
|
99
|
+
|
100
|
+
- by calling the state machine's `#event` method (see above).
|
101
|
+
|
102
|
+
- automatically after a given timeout:
|
103
|
+
|
104
|
+
fsm.when :sleeping do |state|
|
105
|
+
state.transition_to :working, after: 20
|
106
|
+
end
|
107
|
+
|
108
|
+
(goes back to `:working` after 20 seconds in state `:sleeping`)
|
109
|
+
|
110
|
+
- when a `NSNotification` is posted:
|
111
|
+
|
112
|
+
fsm.when :awake do |state|
|
113
|
+
state.transition_to :in_background,
|
114
|
+
on_notification: UIApplicationDidEnterBackgroundNotification
|
115
|
+
end
|
116
|
+
|
117
|
+
### How fast is it?
|
118
|
+
|
119
|
+
The implementation is designed for general non-performance-intensive purposes
|
120
|
+
like managing UI state behavior. It may be too slow for parsing XML, realtime
|
121
|
+
signal processing with high sample rates and similar tasks.
|
122
|
+
|
123
|
+
Anyways, it should be able to handle several thousand events per second on
|
124
|
+
an iOS device.
|
125
|
+
|
126
|
+
## Contributing
|
127
|
+
|
128
|
+
Feel free to fork the project and send me a pull request if you would
|
129
|
+
like me to integrate your bugfix, enhancement, or feature.
|
130
|
+
|
131
|
+
You can easily add new triggering mechanisms — they can be
|
132
|
+
implemented in few lines by subclassing the `Transition` class (see
|
133
|
+
the implementation of `NotificationTransition` for an example).
|
134
|
+
|
135
|
+
I'm also open for suggestions regarding the interface design.
|
136
|
+
|
137
|
+
To contribute,
|
138
|
+
|
139
|
+
1. Fork it
|
140
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
141
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
142
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
143
|
+
5. Create new Pull Request
|
144
|
+
|
145
|
+
If the feature has specs, I will probably merge it :)
|
data/Rakefile
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
|
3
|
+
$:.unshift("/Library/RubyMotion/lib")
|
4
|
+
|
5
|
+
require 'rubygems'
|
6
|
+
require 'rake'
|
7
|
+
require 'motion/project'
|
8
|
+
require "bundler/gem_tasks"
|
9
|
+
|
10
|
+
Bundler.setup
|
11
|
+
Bundler.require
|
12
|
+
|
13
|
+
Motion::Project::App.setup do |app|
|
14
|
+
app.name = 'testSuite'
|
15
|
+
app.identifier = 'com.screenfashion.motion-state-machine.spec-app'
|
16
|
+
app.specs_dir = './spec/motion-state-machine'
|
17
|
+
app.development do
|
18
|
+
# TODO: How to use module namespacing here?
|
19
|
+
app.delegate_class = 'MotionStateMachineSpecAppDelegate'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
namespace :spec do
|
24
|
+
task :lib do
|
25
|
+
sh "bacon #{Dir.glob("spec/lib/**/*_spec.rb").join(' ')}"
|
26
|
+
end
|
27
|
+
|
28
|
+
task :motion => 'spec'
|
29
|
+
|
30
|
+
task :all => [:lib, :motion]
|
31
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
unless defined?(Motion::Project::Config)
|
2
|
+
raise "This file must be required within a RubyMotion project Rakefile."
|
3
|
+
end
|
4
|
+
|
5
|
+
Motion::Project::App.setup do |app|
|
6
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'motion-state-machine/*.rb')).each do |file|
|
7
|
+
app.files.unshift(file)
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,223 @@
|
|
1
|
+
# Hey, this is +motion-state-machine+, a state machine designed for
|
2
|
+
# RubyMotion.
|
3
|
+
#
|
4
|
+
# It comes with a simple syntax to define states and transitions (see
|
5
|
+
# {Base#when}). It is aware of Grand Central Dispatch queues and uses
|
6
|
+
# them for synchronization.
|
7
|
+
#
|
8
|
+
# Its home is {https://github.com/opyh/motion-state-machine}.
|
9
|
+
#
|
10
|
+
# See the {file:README.md} for an overview and introduction.
|
11
|
+
#
|
12
|
+
# You might also want to look at {Base#when} and {State::TransitionDefinitionDSL}.
|
13
|
+
|
14
|
+
module StateMachine
|
15
|
+
|
16
|
+
# Base class of a finite state machine (FSM). See {StateMachine} for
|
17
|
+
# an overview.
|
18
|
+
|
19
|
+
class Base
|
20
|
+
|
21
|
+
# @return [Dispatch::Queue] the GCD queue where the state
|
22
|
+
# machine was started (or +nil+ if the state machine has
|
23
|
+
# not been started yet)
|
24
|
+
attr_reader :initial_queue
|
25
|
+
|
26
|
+
# @return [String] Name of the state machine.
|
27
|
+
# Only used in debug output.
|
28
|
+
attr_reader :name
|
29
|
+
|
30
|
+
# @return [Boolean] Indicates if the machine logs debug output.
|
31
|
+
attr_reader :verbose
|
32
|
+
|
33
|
+
# @return [State] Current {State} (or +nil+ if not in any
|
34
|
+
# state, e.g. after exiting and before entering a new state)
|
35
|
+
attr_accessor :current_state
|
36
|
+
|
37
|
+
|
38
|
+
# Initializes a new StateMachine.
|
39
|
+
#
|
40
|
+
# @param options [Hash]
|
41
|
+
# Configuration options for the FSM.
|
42
|
+
#
|
43
|
+
# @option options [Symbol] :start_state
|
44
|
+
# First state after start
|
45
|
+
#
|
46
|
+
# @option options [String] :name ("State machine")
|
47
|
+
# Name used in debugging output (optional)
|
48
|
+
#
|
49
|
+
# @option options [Boolean] :verbose (false)
|
50
|
+
# Indicate if the machine should output log texts to console.
|
51
|
+
#
|
52
|
+
# @example
|
53
|
+
# fsm = StateMachine::Base.new start_state: :awake
|
54
|
+
#
|
55
|
+
# @return [StateMachine::Base] a new StateMachine object
|
56
|
+
|
57
|
+
def initialize(options)
|
58
|
+
super
|
59
|
+
@name = options[:name] || "State machine"
|
60
|
+
@verbose = !!options[:verbose]
|
61
|
+
@state_symbols_to_states = {}
|
62
|
+
|
63
|
+
waiting_for_start_state = state :waiting_for_start,
|
64
|
+
"waiting for start (internal state)"
|
65
|
+
start_state = options[:start_state].to_sym
|
66
|
+
if start_state.nil?
|
67
|
+
raise ArgumentError, "You have to supply a :start_state option."
|
68
|
+
end
|
69
|
+
state start_state, options[:start_state_name]
|
70
|
+
self.when :waiting_for_start, do |state|
|
71
|
+
state.transition_to start_state, on: :start
|
72
|
+
end
|
73
|
+
|
74
|
+
@current_state = waiting_for_start_state
|
75
|
+
@current_state.send :enter!
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
# Adds defined transitions to the state machine. States that
|
80
|
+
# you refer to with symbols are created automatically, on-the-fly,
|
81
|
+
# so you do not have to define them with an extra statement.
|
82
|
+
#
|
83
|
+
# @param source_state_symbol [Symbol]
|
84
|
+
# Identifier of the state from which the transitions begins.
|
85
|
+
#
|
86
|
+
# @example Define transitions from a state +:awake+ to other states:
|
87
|
+
# fsm.when :awake do |state|
|
88
|
+
# state.transition_to ...
|
89
|
+
# state.die :on => ...
|
90
|
+
# state.on_entry { ... }
|
91
|
+
# state.on_exit { ... }
|
92
|
+
# end
|
93
|
+
#
|
94
|
+
# @yieldparam [TransitionDefinitionDSL] Call configuration methods
|
95
|
+
# on this object to define transitions. See
|
96
|
+
# {TransitionDefinitionDSL} for a list of possible methods.
|
97
|
+
#
|
98
|
+
# @see State::TransitionDefinitionDSL
|
99
|
+
|
100
|
+
def when(source_state_symbol, &block)
|
101
|
+
raise_outside_initial_queue
|
102
|
+
source_state = state source_state_symbol
|
103
|
+
source_state.send :add_transition_map_defined_in, &block
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
# @return an array of registered {StateMachine::State} objects.
|
108
|
+
|
109
|
+
def states
|
110
|
+
@state_symbols_to_states.values
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
# Starts the finite state machine. The machine will be in its
|
115
|
+
# start state afterwards. For synchronization, it will remember
|
116
|
+
# from which queue/thread it was started.
|
117
|
+
|
118
|
+
def start!
|
119
|
+
@initial_queue = Dispatch::Queue.current
|
120
|
+
event :start
|
121
|
+
end
|
122
|
+
|
123
|
+
|
124
|
+
# Sends an event to the state machine. If a matching
|
125
|
+
# transition was defined, the transition will be executed. If
|
126
|
+
# no transition matches, the event will just be ignored.
|
127
|
+
#
|
128
|
+
# @note You should call this method from the same queue / thread
|
129
|
+
# where the state machine was started.
|
130
|
+
#
|
131
|
+
# @param event_symbol [Symbol] The event to trigger on the
|
132
|
+
# state machine.
|
133
|
+
#
|
134
|
+
# @example
|
135
|
+
# my_state_machine.event :some_event
|
136
|
+
|
137
|
+
def event(event_symbol)
|
138
|
+
transition = @events[event_symbol]
|
139
|
+
transition.send(:handle_in_source_state) unless transition.nil?
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
# @returns [Boolean] +true+ if the machine has been terminated,
|
144
|
+
# +false+ otherwise.
|
145
|
+
|
146
|
+
def terminated?
|
147
|
+
current_state.terminating?
|
148
|
+
end
|
149
|
+
|
150
|
+
# Should stop the machine and clean up memory.
|
151
|
+
# Should call exit actions on the current state, if defined.
|
152
|
+
#
|
153
|
+
# Not yet tested / really implemented yet, so use with care and
|
154
|
+
# make a pull request if you should implement it ;)
|
155
|
+
|
156
|
+
def stop_and_cleanup
|
157
|
+
raise_outside_initial_queue
|
158
|
+
@state_machine.log "Stopping #{self}..." if @verbose
|
159
|
+
@current_state.send :exit!
|
160
|
+
@current_state = nil
|
161
|
+
@state_symbols_to_states.values.each(&:cleanup)
|
162
|
+
end
|
163
|
+
|
164
|
+
|
165
|
+
def inspect
|
166
|
+
# Overridden to avoid debug output overhead
|
167
|
+
# (default output would include all attributes)
|
168
|
+
|
169
|
+
"#<#{self.class}:#{object_id.to_s(16)}>"
|
170
|
+
end
|
171
|
+
|
172
|
+
|
173
|
+
# @api private
|
174
|
+
# Returns a State object identified by the given symbol.
|
175
|
+
|
176
|
+
def state(symbol, name = nil)
|
177
|
+
unless symbol.is_a?(Symbol)
|
178
|
+
raise ArgumentError,
|
179
|
+
"You have to supply a symbol to #state. "\
|
180
|
+
"Maybe you wanted to call #current_state?"
|
181
|
+
end
|
182
|
+
raise_outside_initial_queue
|
183
|
+
name ||= symbol.to_s
|
184
|
+
@state_symbols_to_states[symbol] ||= State.new(self,
|
185
|
+
symbol: symbol,
|
186
|
+
name: name)
|
187
|
+
end
|
188
|
+
|
189
|
+
|
190
|
+
# @api private
|
191
|
+
#
|
192
|
+
# Registers a block that should be called when {#event} is called
|
193
|
+
# with the given symbol as parameter.
|
194
|
+
#
|
195
|
+
# @param event_symbol [Symbol]
|
196
|
+
# symbol that identifies the block
|
197
|
+
#
|
198
|
+
# @param transition [Transition]
|
199
|
+
# transition that should be executed when calling {#event} with
|
200
|
+
# +event_symbol+ as parameter
|
201
|
+
|
202
|
+
def register_event_handler(event_symbol, transition)
|
203
|
+
(@events ||= {})[event_symbol] = transition
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
# @api private
|
208
|
+
|
209
|
+
def raise_outside_initial_queue
|
210
|
+
outside = Dispatch::Queue.current.to_s != @initial_queue.to_s
|
211
|
+
if @initial_queue && outside
|
212
|
+
raise RuntimeError,
|
213
|
+
"Can't call this from outside #{@initial_queue} "\
|
214
|
+
"(called from #{Dispatch::Queue.current})."
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def log(text)
|
219
|
+
puts text if @verbose
|
220
|
+
end
|
221
|
+
|
222
|
+
end
|
223
|
+
end
|