motion-state-machine 0.8.1
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.
- 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
|