pads 0.1.0 → 1.0.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.
@@ -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