pads 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/pad_state'
4
+
5
+ module Pads
6
+ # Mutable value object for pad state.
7
+ class MutablePadState < PadState
8
+ attr_writer :title, :subtitle, :activity, :clickable, :buttons, :is_drop_target
9
+
10
+ def initialize(attrs = {})
11
+ super()
12
+ merge! DEFAULTS.merge(attrs)
13
+ end
14
+
15
+ def merge!(attrs)
16
+ attrs.each do |key, value|
17
+ __send__ :"#{key}=", value unless __send__(key) == value
18
+ end
19
+ self
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/mutable_pad_state'
4
+ require 'observer'
5
+
6
+ module Pads
7
+ # A mutable pad state that can be observed.
8
+ class ObservablePadState < MutablePadState
9
+ include Observable
10
+
11
+ DEFAULTS.each_key do |key|
12
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
13
+ def #{key}=(*) # def title=(*)
14
+ compare { super } # compare { super }
15
+ end # end
16
+ RUBY
17
+ end
18
+
19
+ def merge!(*)
20
+ compare { super }
21
+ end
22
+
23
+ private
24
+
25
+ def compare
26
+ return yield if @is_comparing
27
+
28
+ @is_comparing = true
29
+ begin
30
+ old = to_h
31
+ result = yield
32
+ changed to_h != old
33
+ ensure
34
+ @is_comparing = false
35
+ end
36
+ notify_observers self
37
+ result
38
+ end
39
+ end
40
+ end
data/lib/pads/pad.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/pad_or_group'
4
+ require 'pads/pad_view'
5
+ require 'observer'
6
+
7
+ module Pads
8
+ # Controls a single pad.
9
+ class Pad
10
+ include PadOrGroup
11
+
12
+ attr_reader :events
13
+
14
+ def initialize(attrs = {})
15
+ @view_stack = []
16
+ @events = Events::Dispatcher.new
17
+
18
+ push_view PadView.new(attrs)
19
+
20
+ yield self if block_given?
21
+ end
22
+
23
+ def view
24
+ @view_stack.first
25
+ end
26
+
27
+ def push_view(new_view = PadView.new)
28
+ new_view = PadView[new_view]
29
+ change_view { @view_stack.unshift new_view }
30
+ yield new_view if block_given?
31
+ new_view
32
+ end
33
+
34
+ def pop_view
35
+ raise 'You cannot pop the top level view' if @view_stack.one?
36
+
37
+ change_view { @view_stack.shift }
38
+ end
39
+
40
+ def with_view(new_view = PadView.new)
41
+ new_view = PadView[new_view]
42
+ push_view new_view
43
+ yield new_view
44
+ ensure
45
+ pop_view
46
+ end
47
+
48
+ def bind_events(source_dispatcher)
49
+ @subscriptions = source_dispatcher.publishers.keys.map do |event_name|
50
+ source_dispatcher.on(event_name) { |*args| view.events.dispatch event_name, *args }
51
+ end.freeze
52
+ end
53
+
54
+ def unbind_events
55
+ @subscriptions.each(&:unsubscribe)
56
+ @subscriptions = nil
57
+ end
58
+
59
+ def view_changed(*)
60
+ changed
61
+ notify_observers self
62
+ end
63
+
64
+ private
65
+
66
+ def change_view
67
+ view&.delete_observer self
68
+ yield
69
+ view.add_observer self, :view_changed
70
+ view_changed
71
+ self
72
+ end
73
+
74
+ def compare
75
+ old = view
76
+ result = yield
77
+ changed old != view
78
+ notify_observers self
79
+ result
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'observer'
4
+ require 'pads/pad'
5
+ require 'pads/pad_or_group'
6
+
7
+ module Pads
8
+ # Represents a group/sequence of pads.
9
+ class PadGroup
10
+ include PadOrGroup
11
+
12
+ def initialize(members = [])
13
+ @members = []
14
+ @mutex = Mutex.new
15
+
16
+ batch { members.each(&method(:push)) }
17
+ end
18
+
19
+ def insert(index, pad_or_group)
20
+ raise ArgumentError, 'Index out of range' unless (0..@members.length).cover? index
21
+
22
+ pad_or_group = PadOrGroup.coerce(pad_or_group)
23
+ pad_or_group.parent = self
24
+ batch { @members.insert index, pad_or_group }
25
+ end
26
+
27
+ def delete_at(index)
28
+ batch do
29
+ @members.delete_at(index).tap { |p| p.parent = nil } or
30
+ raise ArgumentError, 'Nothing to delete at the given index'
31
+ end
32
+ end
33
+
34
+ def swap(index_a, index_b)
35
+ raise ArgumentError, 'Index out of range' unless [index_a, index_b].all?(&(0..@members.length).method(:cover?))
36
+ raise ArgumentError, 'Indexes are identical' if index_a == index_b
37
+
38
+ batch do
39
+ @members[index_a], @members[index_b] = @members.values_at(index_b, index_a)
40
+ end
41
+ end
42
+
43
+ def batch
44
+ return yield if @mutex.owned?
45
+
46
+ result = @mutex.synchronize do
47
+ previous = @members.dup
48
+ yield.tap do
49
+ changed previous != @members
50
+ end
51
+ end
52
+ notify_observers self
53
+ result
54
+ end
55
+
56
+ def push(*args, &block)
57
+ return push Mapper.map(*args, &block) if block
58
+
59
+ batch do
60
+ args.each do |arg|
61
+ insert @members.length, arg
62
+ end
63
+ end
64
+ end
65
+
66
+ # @return [Array<Pad, PadGroup>]
67
+ def members
68
+ @members.dup
69
+ end
70
+
71
+ def relay_notification(*args)
72
+ changed
73
+ notify_observers(*args)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pads
4
+ # Common functionality for Pads and PadGroups
5
+ module PadOrGroup
6
+ include Observable
7
+
8
+ def self.coerce(object)
9
+ case object
10
+ when Pad, PadGroup
11
+ object
12
+ when Hash
13
+ Pad.new object
14
+ when Array
15
+ PadGroup.new object.map(&method(:coerce))
16
+ when nil
17
+ PadGroup.new
18
+ else
19
+ raise ArgumentError, "Cannot coerce #{object.inspect} to a pad or group of pads"
20
+ end
21
+ end
22
+
23
+ alias == equal?
24
+
25
+ attr_reader :parent
26
+
27
+ def parent=(new_parent)
28
+ return if parent == new_parent
29
+
30
+ delete_observer parent if parent
31
+ @parent = new_parent
32
+ add_observer parent, :relay_notification if parent
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pads
4
+ # Immutable value object for pad state.
5
+ class PadState
6
+ DEFAULTS = {
7
+ title: '',
8
+ subtitle: '',
9
+ activity: false,
10
+ clickable: false,
11
+ buttons: [],
12
+ is_drop_target: false
13
+ }.freeze
14
+
15
+ attr_reader :title, :subtitle, :activity, :clickable, :buttons, :is_drop_target
16
+
17
+ def initialize(attrs = {})
18
+ DEFAULTS.merge(attrs).each do |key, value|
19
+ raise ArgumentError, 'Unknown key' unless DEFAULTS.key?(key.to_sym)
20
+
21
+ instance_variable_set :"@#{key}", value
22
+ end
23
+ end
24
+
25
+ def to_h
26
+ DEFAULTS.keys.to_h do |key|
27
+ [key, instance_variable_get(:"@#{key}")]
28
+ end
29
+ end
30
+
31
+ def merge(attrs)
32
+ self.class.new to_h.merge(attrs)
33
+ end
34
+
35
+ def ==(other)
36
+ return false unless other.is_a? PadState
37
+
38
+ to_h == other.to_h
39
+ end
40
+
41
+ def self.[](obj)
42
+ obj.is_a?(PadState) ? obj : PadState.new(obj)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/observable_pad_state'
4
+
5
+ module Pads
6
+ # Represents a stackable view of a pad
7
+ class PadView < ObservablePadState
8
+ attr_reader :events
9
+
10
+ def self.[](obj)
11
+ obj.is_a?(PadView) ? obj : new(obj)
12
+ end
13
+
14
+ def initialize(attrs = {})
15
+ super
16
+
17
+ @events = Events::Dispatcher.new
18
+ @button_handlers = []
19
+
20
+ @events.on :button_clicked do |event|
21
+ @button_handlers[event.index]&.call
22
+ end
23
+ end
24
+
25
+ def on_click(&handler)
26
+ @events.on :pad_clicked, &handler
27
+ self.clickable = true
28
+ end
29
+
30
+ def on_drop(path_pattern = nil, &handler)
31
+ @events.on :files_dropped do |file_dropped|
32
+ file_dropped.files.each do |file|
33
+ handler.call(file.path) if path_pattern.nil? || file.path.match?(path_pattern)
34
+ end
35
+ end
36
+ self.is_drop_target = true
37
+ end
38
+
39
+ def button(label, &handler)
40
+ @button_handlers << handler
41
+ self.buttons += [label]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pads
4
+ class Provider
5
+ # Represents a server binding for a pad group. Just an array, but uses object IDs for comparisons, so that e.g.
6
+ # two empty groups are not considered equal.
7
+ class GroupBinding < Array
8
+ alias == equal?
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/provider/group_binding'
4
+
5
+ module Pads
6
+ class Provider
7
+ # Supplemental methods for the Provider class
8
+ module Tracking
9
+ protected
10
+
11
+ def pad?(pad_or_group)
12
+ case pad_or_group
13
+ when Pad, Client::Pad
14
+ true
15
+ when PadOrGroup, GroupBinding
16
+ false
17
+ else
18
+ raise ArgumentError, 'Expected a pad or group'
19
+ end
20
+ end
21
+
22
+ def pad_range_for_binding(binding)
23
+ return [binding, binding] if pad? binding
24
+
25
+ flat = binding.flatten
26
+ flat.empty? ? nil : [flat.first, flat.last]
27
+ end
28
+
29
+ def subtract_pad_range(partial_group_binding, range_to_remove)
30
+ indexes_to_remove = range_to_remove.map(&partial_group_binding.method(:index))
31
+ remaining = partial_group_binding[
32
+ indexes_to_remove.first.zero? ? (indexes_to_remove.last + 1).. : 0...indexes_to_remove.first
33
+ ].flatten
34
+ remaining.empty? ? nil : [remaining.first, remaining.last]
35
+ end
36
+
37
+ def walk(pad_group_binding, skip_group_bindings: true, &block)
38
+ yield pad_group_binding unless skip_group_bindings
39
+
40
+ pad_group_binding.each do |binding|
41
+ if pad? binding
42
+ block.call binding
43
+ else
44
+ walk binding, skip_group_bindings: skip_group_bindings, &block
45
+ end
46
+ end
47
+
48
+ yield nil unless skip_group_bindings
49
+ end
50
+
51
+ def create_options(root_binding, pad_group_binding) # rubocop:disable Metrics/MethodLength
52
+ create_options = {}
53
+ phase = 1 # 1 = before this group, 2 = in this group, 3 = after this group
54
+
55
+ walk root_binding, skip_group_bindings: false do |binding|
56
+ if binding == pad_group_binding
57
+ break if create_options[:after]
58
+
59
+ phase = 2
60
+ elsif binding.nil?
61
+ phase = 3
62
+ elsif binding.is_a? GroupBinding
63
+ next
64
+ elsif phase == 1
65
+ create_options[:after] = binding
66
+ elsif phase == 3
67
+ create_options[:before] = binding
68
+ break
69
+ end
70
+ end
71
+
72
+ create_options
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pads/client'
4
+ require 'pads/pad_group'
5
+ require 'pads/provider/tracking'
6
+
7
+ module Pads
8
+ # The base class for a long-running process that provides pads to the Pads app.
9
+ class Provider
10
+ include Tracking
11
+
12
+ def initialize(client = Client.new)
13
+ @client = client
14
+ @root = PadGroup.new
15
+ @mutex = Mutex.new
16
+
17
+ @bindings = { @root => GroupBinding.new }.compare_by_identity
18
+ @bindings_inverse = @bindings.invert.compare_by_identity
19
+
20
+ @root.add_observer self, :pad_or_group_update
21
+ end
22
+
23
+ def push(*args, &block)
24
+ @root.push(*args, &block)
25
+ end
26
+
27
+ def wait
28
+ @client.wait
29
+ end
30
+
31
+ def pad_or_group_update(pad_or_group)
32
+ return @mutex.synchronize { pad_or_group_update pad_or_group } unless @mutex.owned?
33
+
34
+ if pad? pad_or_group
35
+ pad_update pad_or_group
36
+ else
37
+ pad_group_update pad_or_group
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # @param [Pads::Pad] pad
44
+ def pad_update(pad)
45
+ binding_for(pad).merge! pad.view.to_h
46
+ end
47
+
48
+ def pad_group_update(pad_group)
49
+ pad_group_binding = binding_for(pad_group)
50
+ remove_old_members pad_group, pad_group_binding
51
+ sort_members pad_group, pad_group_binding
52
+ add_new_members pad_group, pad_group_binding
53
+ end
54
+
55
+ def remove_old_members(pad_group, pad_group_binding)
56
+ expected = Set.new(pad_group.members)
57
+ pad_group_binding.each.with_index.reverse_each do |member_binding, index|
58
+ member = pad_or_group_for(member_binding)
59
+ next if expected.include? member
60
+
61
+ destroy member
62
+ pad_group_binding.delete_at index
63
+ end
64
+ end
65
+
66
+ def sort_members(pad_group, pad_group_binding)
67
+ return unless pad_group_binding.length > 1
68
+
69
+ expected = pad_group.members.map(&@bindings.method(:[])) & pad_group_binding
70
+
71
+ (0..(pad_group_binding.length - 2)).each do |i|
72
+ a = expected[i]
73
+ b = pad_group_binding[i]
74
+
75
+ swap pad_group_binding, i, pad_group_binding.index(a) unless b == a
76
+ end
77
+ end
78
+
79
+ def add_new_members(pad_group, pad_group_binding)
80
+ create_options = self.create_options(binding_for(@root), pad_group_binding)
81
+
82
+ pad_group.members.each.with_index do |member, index|
83
+ binding = @bindings[member] ||
84
+ create_binding(member, create_options).tap { |b| pad_group_binding.insert index, b }
85
+
86
+ create_options = { after: binding } if binding.is_a? Client::Pad
87
+ end
88
+ end
89
+
90
+ def create_binding(member, create_options)
91
+ is_pad = pad?(member)
92
+ binding = is_pad ? @client.create_pad(**create_options) : GroupBinding.new
93
+
94
+ @bindings[member] = binding
95
+ @bindings_inverse[binding] = member
96
+
97
+ member.bind_events(binding.events) if is_pad
98
+
99
+ pad_or_group_update(member)
100
+
101
+ binding
102
+ end
103
+
104
+ def swap(pad_group_binding, index_a, index_b) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
105
+ bindings = [index_a, index_b].map(&pad_group_binding.method(:fetch))
106
+
107
+ pad_group_binding[index_b], pad_group_binding[index_a] = bindings
108
+
109
+ ranges = bindings.map(&method(:pad_range_for_binding)).sort_by { |range| range.nil? ? 1 : 0 }
110
+ return if ranges.all?(&:nil?)
111
+
112
+ ranges[1] ||= subtract_pad_range(pad_group_binding[index_a..index_b], ranges[0])
113
+ return if ranges[1].nil?
114
+
115
+ @client.swap_pads(*ranges)
116
+ end
117
+
118
+ def binding_for(pad_or_group)
119
+ @bindings.fetch(pad_or_group) { raise "No server binding for #{pad_or_group}" }
120
+ end
121
+
122
+ def pad_or_group_for(binding)
123
+ @bindings_inverse.fetch(binding)
124
+ end
125
+
126
+ def destroy(pad_or_group)
127
+ binding = binding_for(pad_or_group)
128
+
129
+ if pad? pad_or_group
130
+ pad_or_group.unbind_events
131
+ binding.destroy
132
+ else
133
+ binding.each(&method(:destroy))
134
+ end
135
+
136
+ @bindings.delete pad_or_group
137
+ @bindings_inverse.delete binding
138
+ end
139
+ end
140
+ end
data/lib/pads/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pads
4
- VERSION = "0.1.0"
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/pads.rb CHANGED
@@ -1,8 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "pads/version"
3
+ require 'pads/version'
4
+ require 'pads/provider'
5
+ require 'pads/mapper'
6
+ require 'pads/live_array'
4
7
 
8
+ # Provide pads to the Pads macOS app.
5
9
  module Pads
6
- class Error < StandardError; end
7
- # Your code goes here...
10
+ def self.provide(*args)
11
+ provider = Provider.new(*args)
12
+ yield provider
13
+ provider.wait
14
+ end
15
+
16
+ def self.pad(*args, &block)
17
+ Pad.new *args, &block
18
+ end
8
19
  end
data/pads.gemspec CHANGED
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lib/pads/version"
3
+ require_relative 'lib/pads/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "pads"
6
+ spec.name = 'pads'
7
7
  spec.version = Pads::VERSION
8
- spec.authors = ["Neil E. Pearson"]
9
- spec.email = ["neil@pearson.sydney"]
8
+ spec.authors = ['Neil E. Pearson']
9
+ spec.email = ['neil@pearson.sydney']
10
10
 
11
- spec.summary = "Ruby client for the Pads macOS app"
12
- spec.description = "Ruby client for the Pads macOS app"
13
- spec.homepage = "https://github.com/hx/pads"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 2.6.0"
11
+ spec.summary = 'Ruby client for the Pads macOS app'
12
+ spec.description = 'Ruby client for the Pads macOS app'
13
+ spec.homepage = 'https://github.com/hx/pads'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 2.6.3'
16
16
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
- spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
19
 
20
20
  # Specify which files should be added to the gem when it is released.
21
21
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -24,13 +24,15 @@ Gem::Specification.new do |spec|
24
24
  (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
25
25
  end
26
26
  end
27
- spec.bindir = "exe"
27
+ spec.bindir = 'exe'
28
28
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
- spec.require_paths = ["lib"]
29
+ spec.require_paths = ['lib']
30
30
 
31
31
  # Uncomment to register a new dependency of your gem
32
32
  # spec.add_dependency "example-gem", "~> 1.0"
33
+ spec.add_dependency 'interop', '~> 0.3.1'
33
34
 
34
35
  # For more information and examples about making a new gem, check out our
35
36
  # guide at: https://bundler.io/guides/creating_gem.html
37
+ spec.metadata['rubygems_mfa_required'] = 'true'
36
38
  end