debouncer 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5c5f11d271a659197a50ff8aeeec2ca4de582a46
4
- data.tar.gz: 2de745511d408a7a3885ab51127ffc972059aa06
3
+ metadata.gz: 48c4780e6aab992ab7ecf77f240897bd9e7474f4
4
+ data.tar.gz: 8e311281e918f6bf12e0f8051e30be08638d1424
5
5
  SHA512:
6
- metadata.gz: 7fb76db2e170d28b71e30515b91be732b3336d078333059565996a77873e5b83c08e856bf460e9ea0d61b99bdfa7adbfdc8795b3f9a8708b456b5541f7be0aa8
7
- data.tar.gz: 65d859b8392e1f3962b384ed29866a4e08c4f9ab0745c335449e92f7dd80c0eb5a6cb1aebc3b34040a596aace74c838ecaf1ccab4ba4654c2d41a66e0c4c4332
6
+ metadata.gz: 969290d4ad850a13892552d0ee8e66ad442a2bbfbf736220f1596a4d8e3cd4d28b28b3d01566d01fe31496b8c11ead1a8cb943a5444512f698e7cd9a1f451ed5
7
+ data.tar.gz: 84226ab4da39584b1c96a7e3c81b535514c7b5eaf7ca1c83bc23b016544c038169d16975732f1f72e6dfd3f85f6d670feb975c38c991b5e3758155484ae2373a
data/README.md CHANGED
@@ -20,7 +20,176 @@ Or install it yourself as:
20
20
 
21
21
  ## Usage
22
22
 
23
- TODO: Write usage instructions here
23
+ ### The easy way
24
+
25
+ Generally all you want to do is debounce an instance or class method. The `Debounceable` module gives you everything you need to get your bouncing methods debounced.
26
+
27
+ ```ruby
28
+ require 'debouncer/debounceable'
29
+
30
+ class DangerZone
31
+ extend Debouncer::Debounceable
32
+
33
+ def send_warning
34
+ system "echo We are about to get crazy! | wall"
35
+ end
36
+
37
+ debounce :send_warning, 2
38
+ end
39
+ ```
40
+
41
+ Each call to `send_warning` will be delayed on a background thread for 2 seconds before firing. If, during those two seconds, it's called again, the count-down will restart, and only one warning will actually be sent.
42
+
43
+ If you want to send your warning immediately, your original method has been given a suffix so you can still access it:
44
+
45
+ ```ruby
46
+ DangerZone.new.send_warning_immediately
47
+ ```
48
+
49
+ A couple of methods have also been added to help work with background threads:
50
+
51
+ ```ruby
52
+ dz = DangerZone.new
53
+
54
+ dz.join_send_warning # Wait for all warnings to be sent
55
+ dz.flush_send_warning # Send warnings immediately if any are waiting
56
+ dz.cancel_send_warning # Cancel all waiting warnings
57
+ ```
58
+
59
+ You can also debounce your calls in groups. By setting the `grouped: true` option, the first argument passed to the debounced method will be used to create a separate debouncer thread.
60
+
61
+ ```ruby
62
+ class DangerZone
63
+ extend Debouncer::Debounceable
64
+
65
+ def send_warning(message)
66
+ system "echo #{message.shellescape} | wall"
67
+ end
68
+
69
+ debounce :send_warning, 2, grouped: true
70
+ end
71
+ ```
72
+
73
+ Now, each unique message will have its own separate timeout. You can also pass group identifiers to `join_`, `flush_`, and `cancel_` methods to affect only those groups.
74
+
75
+ Arguments are always passed to your original method intact, and by default, the last set of arguments in a group wins. If the example above didn't use grouping:
76
+
77
+ ```ruby
78
+ d = DangerZone.new
79
+ d.send_warning "We're going down!"
80
+ d.send_warning "Spiders are attacking!"
81
+ ```
82
+
83
+ The first warning would be replaced by the second when the method is eventually run.
84
+
85
+ You can combine arguments to produce an end result however you like, using a reducer:
86
+
87
+ ```ruby
88
+ class DangerZone
89
+ extend Debouncer::Debounceable
90
+
91
+ def send_warning(*messages)
92
+ system "echo #{messages.map(&:shellescape).join ';'} | wall"
93
+ end
94
+
95
+ debounce :send_warning, 2, reduce_with: :combine_messages
96
+
97
+ def combine_messages(memo, messages)
98
+ memo + messages
99
+ end
100
+ end
101
+ ```
102
+
103
+ The `combine_messages` method will be called whenever `send_warning` is called. The first argument is the last value it returned, or an empty array if this is the first call for the thread. The second argument is an array of the arguments supplied to `send_warning`. It should return an array of arguments that will ultimately be passed to the original method.
104
+
105
+ A reducer method is a good place to call `flush_*` if you hit some sort of limit or threshold. Just remember to still return the array of arguments you want to call.
106
+
107
+ ```ruby
108
+ def combine_messages(memo, messages)
109
+ result = memo + messages
110
+ flush_send_warning if result.length >= 5
111
+ result
112
+ end
113
+ ```
114
+
115
+ Finally, you can also debounce class/module methods using the `mdebounce` method. If you want to combine calls on various instances of a class, a sound pattern is to debounce a class method and have instances call it. For example, consider broadcasting data changes to browsers, where you want to group changes to the same model together into single broadcasts:
116
+
117
+ ```ruby
118
+ class Record
119
+ extend Debouncer::Debounceable
120
+
121
+ def self.broadcast(record_id)
122
+ look_up(record_id).broadcast
123
+ end
124
+
125
+ mdebounce :broadcast, 0.5, grouped: true
126
+
127
+ def save
128
+ write_to_database
129
+ Record.broadcast id
130
+ end
131
+ end
132
+ ```
133
+
134
+ In a web application, it's common for several instances of a model representing the same data record to existing during the course of a request. Debouncing the `broadcast` method on the instance wouldn't be effective, since each instance would have its own debouncer. By using a debounced class method, records are grouped by their ID instead of the Ruby objects that represent them.
135
+
136
+ ### The clean(er) way
137
+
138
+ Under the hood, the `Debounceable` module uses a `Debouncer` instance as a thread controller. If you prefer not to have any extra methods defined on your class, you can use a `Debouncer` instance to achieve the same results.
139
+
140
+ ```ruby
141
+ require 'debouncer'
142
+
143
+ d = Debouncer.new(2) { |message| puts message }
144
+ d.call 'I have arrived'
145
+ ```
146
+
147
+ This will print the message "I have arrived!" after 2 seconds.
148
+
149
+ Grouping is simple with a debouncer:
150
+
151
+ ```ruby
152
+ d.group(:warnings).call 'I am about to arrive...'
153
+ d.group(:alerts).call 'I have arrived!'
154
+ d.group(:alerts).flush
155
+ ```
156
+
157
+ When adding an argument reducer, you can also specify an initial value:
158
+
159
+ ```ruby
160
+ d = Debouncer.new(2) { |*messages| puts messages }
161
+ d.reducer('Here are some messages:', '') { |memo, messages| memo + messages }
162
+ d.call "Evented Ruby isn't so bad"
163
+ d.call "And it's threaded, too!"
164
+ ```
165
+
166
+ After 2 seconds, the above code will print:
167
+
168
+ ```text
169
+ Here are some messages:
170
+
171
+ Evented Ruby isn't so bad
172
+ And it's threaded, too!
173
+ ```
174
+
175
+ If your reducer simply adds or OR's two arrays together, you can use a symbol instead:
176
+
177
+ ```ruby
178
+ d.reducer 'Here are some messages:', '', :+
179
+ ```
180
+
181
+ This will have the same result as the block form. If you use `:|` instead of `:+`, it will be like `memo | messages`, so messages won't be repeated between calls.
182
+
183
+ Methods like `flush`, `kill`, and `join` are available too, and to exactly what you'd expect. You can call them directly on your debouncer, or after a `group(id)` call if you want to target a specific group.
184
+
185
+ ```ruby
186
+ # These lines are equivalent:
187
+ d.join :messages
188
+ d.group(:messages).join
189
+
190
+ # This will join all threads:
191
+ d.join
192
+ ```
24
193
 
25
194
  ## Development
26
195
 
@@ -30,7 +199,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
30
199
 
31
200
  ## Contributing
32
201
 
33
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/debouncer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
202
+ Bug reports and pull requests are welcome on GitHub at https://github.com/hx/debouncer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
34
203
 
35
204
 
36
205
  ## License
@@ -1,23 +1,37 @@
1
1
  require 'debouncer/version'
2
+ require 'debouncer/inspection'
3
+ require 'debouncer/group'
4
+ require 'debouncer/debounceable'
2
5
 
3
6
  class Debouncer
7
+ include Inspection
8
+
9
+ DEFAULT_GROUP = Object.new
10
+ EMPTY = Object.new
11
+
12
+ attr_reader :delay
13
+
4
14
  def initialize(delay, &block)
5
- raise ArgumentError, 'Expected a number' unless delay.is_a? Numeric
6
- @delay = delay
15
+ self.delay = delay
16
+ raise ArgumentError, 'Expected a block' unless block
7
17
  @timeouts = {}
8
18
  @threads = []
9
- @lock = Mutex.new
10
19
  @rescuers = {}
11
- block.arity.zero? ? instance_exec(&block) : yield(self) if block
20
+ @block = block
21
+ @lock = Mutex.new
12
22
  end
13
23
 
14
- def reducer(*initial, &block)
15
- @reducer = [initial, block]
16
- self
24
+ def delay=(delay)
25
+ raise ArgumentError, "Expected Numeric, but got #{delay.class.name}" unless delay.is_a? Numeric
26
+ @delay = delay
17
27
  end
18
28
 
19
- def limiter(&block)
20
- @limiter = block
29
+ def arity
30
+ @block.arity
31
+ end
32
+
33
+ def reducer(*initial, &block)
34
+ @reducer = [initial, block || initial.pop]
21
35
  self
22
36
  end
23
37
 
@@ -26,76 +40,111 @@ class Debouncer
26
40
  self
27
41
  end
28
42
 
29
- def debounce(id = nil, *args, &block)
30
- raise ArgumentError, 'Expected a block' unless block
43
+ def group(id)
44
+ Group.new self, id
45
+ end
46
+
47
+ def call(*args, &block)
48
+ call_with_id DEFAULT_GROUP, *args, &block
49
+ end
50
+ alias_method :[], :call
51
+
52
+ def call_with_id(id, *args, &block)
53
+ args << block if block
54
+ thread = nil
31
55
  exclusively do
32
- thread = @timeouts[id] ||= new_thread { begin_delay id, &block }
33
- @flush = [id]
34
- args = reduce_args(thread, args, id)
35
- if (@limiter && !@limiter[*args]) || @flush == true
56
+ thread = @timeouts[id] ||= new_thread { begin_delay id, args }
57
+ @flush = [id]
58
+ old_args = thread[:args]
59
+ thread[:args] =
60
+ if @reducer
61
+ initial, reducer = @reducer
62
+ old_args ||= initial || []
63
+ if reducer.is_a? Symbol
64
+ old_args.__send__ reducer, args
65
+ elsif reducer.respond_to? :call
66
+ reducer.call old_args, args, id
67
+ end
68
+ else
69
+ args.empty? ? old_args : args
70
+ end
71
+ if @flush == true
36
72
  thread.kill
37
73
  @timeouts.delete id
38
74
  @threads.delete thread
39
75
  @flush = false
40
76
  else
41
- thread[:args] = args
42
77
  thread[:run_at] = Time.now + @delay
43
78
  end
44
79
  end or
45
- yield *args
80
+ run_block thread
46
81
  self
47
82
  end
48
83
 
49
- def flush(*args)
50
- if args.empty?
51
- if @lock.owned?
52
- @flush = true
53
- else
54
- flush @timeouts.keys.first while @timeouts.any?
55
- end
84
+ def flush(id = EMPTY)
85
+ if @lock.owned?
86
+ raise ArgumentError, 'You cannot flush other groups from inside a reducer' unless id == EMPTY || [id] == @flush
87
+ @flush = true
88
+ elsif id == EMPTY
89
+ flush @timeouts.keys.first while @timeouts.any?
56
90
  else
57
- id = args.first
58
- if @lock.owned? && @flush == [id]
59
- @flush = true
60
- else
61
- dead = exclusively do
62
- if (thread = @timeouts.delete(id))
63
- thread.kill
64
- @threads.delete thread
65
- end
91
+ dead = exclusively do
92
+ if (thread = @timeouts.delete(id))
93
+ thread.kill
94
+ @threads.delete thread
66
95
  end
67
- dead[:block].call *dead[:args] if dead
68
96
  end
97
+ run_block dead if dead
69
98
  end
70
99
  self
71
100
  end
72
101
 
73
- def join(kill_first = false)
74
- while (thread = exclusively { @threads.find &:alive? })
102
+ def join(id = EMPTY, kill_first: false)
103
+ if id == EMPTY
104
+ while (thread = exclusively { @threads.find &:alive? })
105
+ thread.kill if kill_first
106
+ thread.join
107
+ end
108
+ exclusively { [@threads, @timeouts].each &:clear } if kill_first
109
+ elsif (thread = exclusively { @timeouts.delete id })
110
+ @threads.delete thread
75
111
  thread.kill if kill_first
76
112
  thread.join
77
113
  end
78
- exclusively { [@threads, @timeouts].each &:clear } if kill_first
79
114
  self
80
115
  end
81
116
 
82
- def kill
83
- join true
117
+ def kill(id = EMPTY)
118
+ join id, kill_first: true
84
119
  end
85
120
 
86
- def inspect
87
- "#<#{self.class}:0x#{'%014x' % (object_id << 1)} delay: #{@delay} timeouts: #{@timeouts.count} threads: #{@threads.count}>"
121
+ def inspect_params
122
+ {delay: @delay, timeouts: @timeouts.count, threads: @threads.count}
123
+ end
124
+
125
+ def to_proc
126
+ method(:call).to_proc
127
+ end
128
+
129
+ def sleeping?
130
+ @timeouts.length.nonzero?
131
+ end
132
+
133
+ def runs_at(id = DEFAULT_GROUP)
134
+ thread = @timeouts[id]
135
+ thread && thread[:run_at]
88
136
  end
89
137
 
90
138
  private
91
139
 
92
- def begin_delay(id, &block)
93
- thread[:block] = block
140
+ def begin_delay(id, args)
141
+ thread[:run_at] = Time.now + @delay
142
+ thread[:args] ||= args
94
143
  sleep @delay
95
144
  until exclusively { (thread[:run_at] <= Time.now).tap { |ready| @timeouts.delete id if ready } }
96
145
  sleep [thread[:run_at] - Time.now, 0].max
97
146
  end
98
- yield *thread[:args]
147
+ run_block thread
99
148
  rescue => ex
100
149
  @timeouts.reject! { |_, v| v == thread }
101
150
  (rescuer = @rescuers.find { |klass, _| ex.is_a? klass }) && rescuer.last[ex]
@@ -103,14 +152,8 @@ class Debouncer
103
152
  exclusively { @threads.delete thread }
104
153
  end
105
154
 
106
- def reduce_args(thread, new_args, id)
107
- old_args = thread[:args]
108
- if @reducer
109
- initial, reducer = @reducer
110
- reducer[old_args || initial || [], new_args, id]
111
- else
112
- new_args.empty? ? old_args : new_args
113
- end
155
+ def run_block(thread)
156
+ @block.call *thread[:args]
114
157
  end
115
158
 
116
159
  def new_thread(*args, &block)
@@ -1,74 +1,50 @@
1
- require 'debouncer'
2
-
3
1
  class Debouncer
4
2
  module Debounceable
5
- def debounce(name, delay, rescue_with: nil, group_by: :object_id, reduce_with: nil)
3
+ SUFFIXES = {
4
+ '?' => '_predicate',
5
+ '!' => '_dangerous',
6
+ '=' => '_assignment'
7
+ }
8
+ def debounce(name, delay, rescue_with: nil, grouped: false, reduce_with: nil, class_method: false)
6
9
  name =~ /^(\w+)([?!=]?)$/ or
7
10
  raise ArgumentError, 'Invalid method name'
8
11
 
9
12
  base_name = $1
10
13
  suffix = $2
11
14
  immediate = "#{base_name}_immediately#{suffix}"
12
-
13
- debouncer_for_method name, delay do |d|
14
- d.rescuer do |ex|
15
- case rescue_with
16
- when Symbol
17
- __send__ rescue_with, ex
18
- when Proc
19
- rescue_with[ex]
20
- else
21
- # Silent failure
22
- end
23
- end if rescue_with
24
-
25
- d.reducer do |old, args, id|
26
- case reduce_with
27
- when Symbol
28
- debouncing_instance(name, id).__send__ reduce_with, old, args
29
- when Proc
30
- reduce_with[old, args, id]
31
- else
32
- raise ArgumentError
33
- end
34
- end if reduce_with
35
- end
15
+ debouncer = "@#{base_name}#{SUFFIXES[suffix]}_debouncer"
16
+ extras = ''
17
+ extras << ".reducer { |old, new| self.#{reduce_with} old, new }" if reduce_with
18
+ extras << ".rescuer { |ex| self.#{rescue_with} ex }" if rescue_with
36
19
 
37
20
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
21
+ #{'class << self' if class_method}
22
+
38
23
  alias_method :#{immediate}, :#{name}
39
24
 
40
- def #{name}(*args)
41
- id = #{group_by}
42
- #{self.name}.debouncing_instance :#{name}, id, self
43
- #{self.name}.debouncer_for_method(:#{name}).debounce(id, *args) { |*args| #{immediate} *args }
25
+ def #{name}(*args, &block)
26
+ #{debouncer} ||= ::Debouncer.new(#{delay}) { |*args| self.#{immediate} *args }#{extras}
27
+ #{debouncer}#{'.group(args.first)' if grouped}.call *args, &block
44
28
  end
45
29
 
46
- def flush_#{name}
47
- #{self.name}.debouncer_for_method(:#{name}).flush #{group_by}
30
+ def flush_#{name}(*args)
31
+ #{debouncer}.flush *args if #{debouncer}
48
32
  end
49
33
 
50
- def self.join_#{name}
51
- debouncer_for_method(:#{name}).join
34
+ def join_#{name}(*args)
35
+ #{debouncer}.join *args if #{debouncer}
52
36
  end
53
37
 
54
- def self.cancel_#{name}
55
- debouncer_for_method(:#{name}).kill
38
+ def cancel_#{name}(*args)
39
+ #{debouncer}.kill *args if #{debouncer}
56
40
  end
57
- RUBY
58
- end
59
41
 
60
- def debouncer_for_method(name, delay = 0, &block)
61
- @method_debouncers ||= {}
62
- @method_debouncers[name] ||= Debouncer.new(delay, &block)
42
+ #{'end' if class_method}
43
+ RUBY
63
44
  end
64
45
 
65
- def debouncing_instance(method, id, instance = nil)
66
- hash = (@debouncing_instances ||= {})[method] ||= {}
67
- if instance
68
- hash[id] = instance
69
- else
70
- hash[id]
71
- end
46
+ def mdebounce(name, delay, **opts)
47
+ debounce name, delay, class_method: true, **opts
72
48
  end
73
49
  end
74
50
  end
@@ -0,0 +1,36 @@
1
+ class Debouncer
2
+ class Group
3
+ include Inspection
4
+
5
+ attr_reader :id, :debouncer
6
+
7
+ def initialize(debouncer, id)
8
+ @debouncer = debouncer
9
+ @id = id
10
+ end
11
+
12
+ def call(*args, &block)
13
+ @debouncer.call_with_id @id, *args, &block
14
+ end
15
+
16
+ def to_proc
17
+ method(:call).to_proc
18
+ end
19
+
20
+ def flush
21
+ @debouncer.flush @id
22
+ end
23
+
24
+ def join
25
+ @debouncer.join @id
26
+ end
27
+
28
+ def kill
29
+ @debouncer.kill @id
30
+ end
31
+
32
+ def inspect_params
33
+ {delay: @debouncer.delay, scheduled: @debouncer.runs_at(@id) || 'idle'}
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ module Inspection
2
+ def inspect
3
+ '#<%s:0x%014x%s>' % [
4
+ self.class.name,
5
+ object_id << 1,
6
+ inspect_params.map { |k, v| " #{k}: #{v}" }.join
7
+ ]
8
+ end
9
+
10
+ def inspect_params
11
+ {}
12
+ end
13
+ end
@@ -1,3 +1,3 @@
1
1
  class Debouncer
2
- VERSION = '0.1.0'
2
+ VERSION = '0.2.0'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debouncer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Neil E. Pearson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-02-24 00:00:00.000000000 Z
11
+ date: 2017-02-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -73,6 +73,8 @@ files:
73
73
  - debouncer.gemspec
74
74
  - lib/debouncer.rb
75
75
  - lib/debouncer/debounceable.rb
76
+ - lib/debouncer/group.rb
77
+ - lib/debouncer/inspection.rb
76
78
  - lib/debouncer/version.rb
77
79
  homepage: https://github.com/hx/debouncer
78
80
  licenses: