sevgi-graphics 0.95.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +221 -2
  3. data/README.md +12 -9
  4. data/lib/sevgi/graphics/attribute.rb +166 -45
  5. data/lib/sevgi/graphics/auxiliary/canvas.rb +100 -43
  6. data/lib/sevgi/graphics/auxiliary/content.rb +56 -47
  7. data/lib/sevgi/graphics/auxiliary/margin.rb +19 -12
  8. data/lib/sevgi/graphics/auxiliary/paper.rb +74 -49
  9. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  10. data/lib/sevgi/graphics/auxiliary/scalar.rb +36 -7
  11. data/lib/sevgi/graphics/auxiliary.rb +1 -0
  12. data/lib/sevgi/graphics/document/base.rb +6 -2
  13. data/lib/sevgi/graphics/document/default.rb +1 -1
  14. data/lib/sevgi/graphics/document.rb +239 -117
  15. data/lib/sevgi/graphics/element.rb +132 -34
  16. data/lib/sevgi/graphics/mixtures/call.rb +234 -88
  17. data/lib/sevgi/graphics/mixtures/core.rb +67 -27
  18. data/lib/sevgi/graphics/mixtures/duplicate.rb +47 -25
  19. data/lib/sevgi/graphics/mixtures/export.rb +54 -12
  20. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  21. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  22. data/lib/sevgi/graphics/mixtures/include.rb +26 -8
  23. data/lib/sevgi/graphics/mixtures/inkscape.rb +214 -47
  24. data/lib/sevgi/graphics/mixtures/rdf.rb +59 -7
  25. data/lib/sevgi/graphics/mixtures/render.rb +60 -120
  26. data/lib/sevgi/graphics/mixtures/save.rb +79 -35
  27. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -12
  28. data/lib/sevgi/graphics/mixtures/tile.rb +85 -67
  29. data/lib/sevgi/graphics/mixtures/transform.rb +89 -30
  30. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  31. data/lib/sevgi/graphics/mixtures/validate.rb +2 -2
  32. data/lib/sevgi/graphics/mixtures/wrappers.rb +111 -23
  33. data/lib/sevgi/graphics/mixtures.rb +15 -13
  34. data/lib/sevgi/graphics/version.rb +1 -1
  35. data/lib/sevgi/graphics/xml.rb +4 -9
  36. data/lib/sevgi/graphics.rb +69 -18
  37. metadata +7 -6
@@ -5,51 +5,115 @@ module Sevgi
5
5
  # SVG element node used by the graphics DSL.
6
6
  #
7
7
  # @!method self.valid?(name)
8
- # Reports whether an SVG element name is known.
9
- # @param name [Symbol] SVG element name
8
+ # Reports whether a candidate can dispatch as an SVG element name.
9
+ # With Standard loaded, the name must be known. Standalone Graphics accepts any valid XML name.
10
+ # @param name [Object] candidate element name
10
11
  # @return [Boolean]
12
+ #
13
+ # Dynamic SVG element methods accept text, content objects, and any number of attribute Hashes in one call. Hashes
14
+ # are applied from left to right. Later values replace earlier values unless their names use the `+` update suffix.
15
+ # Ordinary `dup` and `clone` create independent subtrees, preserve IDs, and detach non-root copies from their parent.
16
+ # Document copies remain roots. Use `Duplicate` for DSL-specific ID remapping and attachment.
11
17
  class Element
12
18
  # Builds an element node.
13
19
  # @param name [Symbol, String] SVG element name
20
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels. Every
21
+ # Hash is applied through {Sevgi::Graphics::Attributes} from left to right
14
22
  # @param parent [Sevgi::Graphics::Element] parent element
15
23
  # @yield evaluates the drawing DSL in the new element
16
24
  # @yieldreturn [Object] ignored block result
17
25
  # @return [Sevgi::Graphics::Element]
18
26
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
27
+ # @raise [Sevgi::ArgumentError] when the parent has a different concrete element class
19
28
  # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
20
- def self.element(name, *, parent:, &block) = new(name, **Dispatch.parse(name, *), parent:, &block)
29
+ def self.element(name, *arguments, parent:, &block)
30
+ validate_parent_class(self, parent)
31
+ new(name, **Dispatch.parse(name, *arguments), parent:, &block)
32
+ end
21
33
 
22
34
  # Builds an SVG root element.
35
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels
23
36
  # @yield evaluates the drawing DSL in the root element
24
37
  # @yieldreturn [Object] ignored block result
25
38
  # @return [Sevgi::Graphics::Element]
26
- # @raise [Sevgi::ArgumentError] when a root attribute is not valid XML
27
- def self.root(*, &block) = element(:svg, *, parent: RootParent, &block)
39
+ # @raise [Sevgi::ArgumentError] when an argument is not a Hash, String, or Content
40
+ # @raise [Sevgi::ArgumentError] when a root attribute or content value is not valid XML
41
+ def self.root(*arguments, &block) = element(:svg, *arguments, parent: RootParent, &block)
28
42
 
29
43
  # Reports whether an element is the root element.
30
44
  # @param element [Sevgi::Graphics::Element] element to test
31
45
  # @return [Boolean]
32
- def self.root?(element) = element.parent == RootParent
46
+ def self.root?(element) = Element.send(:tree_parent, element).equal?(RootParent)
47
+
48
+ def self.element_method?(name) = Dispatch.cached?(name)
49
+
50
+ private_class_method :element_method?
51
+
52
+ class << self
53
+ private
54
+
55
+ def attach(element, parent, index: nil)
56
+ children = tree_children(parent)
57
+ index ? children.insert(index, element) : children << element
58
+ element.instance_variable_set(:@parent, parent)
59
+ end
60
+
61
+ def detach(element)
62
+ parent = tree_parent(element)
63
+ tree_children(parent).delete(element) if parent.is_a?(element.class)
64
+ element.instance_variable_set(:@parent, DetachedParent)
65
+ end
66
+
67
+ def tree_children(element) = element.instance_variable_get(:@children)
68
+
69
+ def tree_parent(element) = element.instance_variable_get(:@parent)
70
+
71
+ def validate_parent(element, parent)
72
+ validate_parent_class(element.class, parent)
73
+ end
74
+
75
+ def validate_parent_class(element_class, parent)
76
+ return if parent.equal?(RootParent) || parent.instance_of?(element_class)
77
+
78
+ ArgumentError.("Element type does not match the parent type: #{element_class}")
79
+ end
80
+ end
33
81
 
34
82
  class << self
35
83
  require "sevgi/standard"
36
84
 
37
- # Reports whether an SVG element name is known.
38
- # @param name [Symbol] SVG element name
85
+ # Reports whether a candidate can dispatch as a known SVG element name.
86
+ # @param name [Object] candidate element name
39
87
  # @return [Boolean]
40
- def valid?(name) = Standard.element?(name)
88
+ def valid?(name)
89
+ Standard.element?(name)
90
+ rescue Sevgi::ArgumentError
91
+ false
92
+ end
93
+
41
94
  rescue ::LoadError => e
42
95
  raise unless e.path == "sevgi/standard"
43
96
 
44
- # Reports whether an SVG element name is known.
97
+ # Reports whether a candidate can dispatch as a valid XML element name.
98
+ # @param name [Object] candidate element name
45
99
  # @return [Boolean]
46
- def valid?(...) = true
100
+ def valid?(name)
101
+ return false unless name.is_a?(::String) || name.is_a?(::Symbol)
102
+
103
+ XML.name(name, context: "SVG element name")
104
+ true
105
+ rescue Sevgi::ArgumentError
106
+ false
107
+ end
47
108
  end
48
109
 
49
110
  private_class_method :new
50
111
 
51
- # Sentinel parent used by root SVG elements.
112
+ # Sentinel parents used by root and detached elements.
52
113
  RootParent = Object.new.tap { def it.inspect = "RootParent" }.freeze
114
+ DetachedParent = Object.new.tap { def it.inspect = "DetachedParent" }.freeze
115
+
116
+ private_constant :DetachedParent, :RootParent
53
117
 
54
118
  # SVG element method-name normalization.
55
119
  # @api private
@@ -61,6 +125,8 @@ module Sevgi
61
125
  end
62
126
 
63
127
  extend Ident
128
+ private_class_method :id
129
+ private_constant :Ident
64
130
 
65
131
  # Returns the SVG element name.
66
132
  # @return [Symbol]
@@ -70,17 +136,20 @@ module Sevgi
70
136
  # @return [Sevgi::Graphics::Attributes]
71
137
  attr_reader :attributes
72
138
 
73
- # Returns child elements.
74
- # @return [Array<Sevgi::Graphics::Element>]
75
- attr_reader :children
139
+ # Returns a read-only snapshot of child elements in rendering order.
140
+ # @return [Array<Sevgi::Graphics::Element>] frozen child snapshot
141
+ def children = @children.dup.freeze
76
142
 
77
- # Returns element content objects.
78
- # @return [Array<Sevgi::Graphics::Content>]
79
- attr_reader :contents
143
+ # Returns a read-only snapshot of element content objects in rendering order.
144
+ # @return [Array<Sevgi::Graphics::Content>] frozen content snapshot
145
+ def contents = @contents.dup.freeze
80
146
 
81
- # Returns the parent element or root sentinel.
82
- # @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
83
- attr_reader :parent
147
+ # Returns the parent element.
148
+ # @return [Sevgi::Graphics::Element, nil] parent element, or nil for a root or detached element
149
+ # @note Use `Root?` to distinguish a document root from a detached subtree root.
150
+ def parent
151
+ @parent if @parent.is_a?(self.class)
152
+ end
84
153
 
85
154
  # Creates an element.
86
155
  # @param name [Symbol] SVG element name
@@ -90,8 +159,12 @@ module Sevgi
90
159
  # @yield evaluates the drawing DSL in the new element
91
160
  # @yieldreturn [Object] ignored block result
92
161
  # @return [void]
162
+ # @raise [Sevgi::ArgumentError] when the parent has a different concrete element class
93
163
  # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
164
+ # @api private
94
165
  def initialize(name, parent:, attributes: {}, contents: [], &block)
166
+ Element.send(:validate_parent, self, parent)
167
+
95
168
  unless name.is_a?(::String) || name.is_a?(::Symbol)
96
169
  ArgumentError.("XML element name must be a String or Symbol")
97
170
  end
@@ -99,23 +172,41 @@ module Sevgi
99
172
  @name = XML.name(name, context: "XML element name").to_sym
100
173
  @attributes = Attributes.new(attributes)
101
174
  @children = []
102
- @contents = contents
175
+ @contents = contents.dup
103
176
  @parent = parent
104
177
 
105
- parent.children << self unless self.class.root?(self)
178
+ Element.send(:attach, self, parent) unless Element.root?(self)
106
179
 
107
180
  instance_exec(&block) if block
108
181
  end
109
182
 
183
+ # Copies owned storage and reconnects child copies without attaching to the source tree.
184
+ # @param original [Sevgi::Graphics::Element] source node
185
+ # @return [void]
186
+ # @api private
187
+ def initialize_copy(original)
188
+ super
189
+ @parent = Element.root?(original) ? RootParent : DetachedParent
190
+ @attributes = original.attributes.dup
191
+ @contents = original.contents.map(&:dup)
192
+ @children = original.children.map do |child|
193
+ child.dup.tap { it.instance_variable_set(:@parent, self) }
194
+ end
195
+ end
196
+
197
+ private :initialize_copy
198
+
110
199
  # Dispatches SVG element DSL calls and caches valid element methods.
111
200
  # @param name [Symbol] missing method name
201
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels. Later
202
+ # Hashes replace or update attributes assigned by earlier Hashes
112
203
  # @yield evaluates the drawing DSL in the dispatched child element
113
204
  # @yieldreturn [Object] ignored block result
114
205
  # @return [Sevgi::Graphics::Element]
115
206
  # @raise [NameError] when the name is not a valid SVG element
116
207
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
117
- def method_missing(name, *, &block)
118
- Element.valid?(tag = Element.id(name)) ? Dispatch.(self, name, tag, *, &block) : super
208
+ def method_missing(name, *arguments, &block)
209
+ Element.valid?(tag = Element.send(:id, name)) ? Dispatch.(self, name, tag, *arguments, &block) : super
119
210
  end
120
211
 
121
212
  # Reports whether a missing method can dispatch to an SVG element.
@@ -124,9 +215,11 @@ module Sevgi
124
215
  # @return [Boolean]
125
216
  # @api private
126
217
  def respond_to_missing?(name, include_private = false)
127
- Element.valid?(Element.id(name)) || super
218
+ Element.valid?(Element.send(:id, name)) || super
128
219
  end
129
220
 
221
+ private :method_missing, :respond_to_missing?
222
+
130
223
  # Element method-missing parser and cache.
131
224
  # @api private
132
225
  module Dispatch
@@ -142,41 +235,46 @@ module Sevgi
142
235
  Element.class_exec do
143
236
  define_method(method) { |*args, &block| self.class.element(tag, *args, parent: self, &block) }
144
237
  end
238
+
239
+ (@methods ||= {})[method] = Element.instance_method(method)
145
240
  end
146
241
 
147
242
  element.public_send(method, *, &)
148
243
  end
149
244
 
245
+ def cached?(method)
246
+ cached = @methods&.[](method)
247
+
248
+ cached && Element.method_defined?(method) && Element.instance_method(method) == cached
249
+ end
250
+
150
251
  # Parses element DSL arguments.
151
252
  # @param name [Symbol] SVG element name
152
- # @param args [Array<Object>] positional DSL arguments
253
+ # @param args [Array<Object>] positional DSL arguments. Hashes are imported from left to right
153
254
  # @return [Hash] parsed :attributes and :contents
154
255
  # @raise [Sevgi::ArgumentError] when an argument is not a Hash, String, or Content
155
256
  def parse(name, *args)
156
- attributes, contents = {}, []
257
+ attributes, contents = Attributes.new, []
157
258
 
158
259
  args.each do |arg|
159
260
  case arg
160
261
  when ::Hash
161
- attributes = arg
262
+ attributes.merge!(arg)
162
263
  when ::String
163
264
  contents << Content.encoded(arg)
164
265
  when Content
165
266
  contents << arg
166
267
  else
167
- ArgumentError.("Argument of element '#{name}' must be a Hash or String: #{arg}")
268
+ ArgumentError.("Argument of element '#{name}' must be a Hash, String, or Content: #{arg}")
168
269
  end
169
270
  end
170
271
 
171
- {attributes:, contents:}
272
+ {attributes: attributes.to_h, contents:}
172
273
  end
173
274
  end
174
275
 
175
276
  private_constant :Dispatch
176
277
 
177
- protected
178
-
179
- attr_writer :attributes, :children, :contents, :parent
180
278
  end
181
279
  end
182
280
  end
@@ -3,95 +3,266 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # Callable drawing module support.
6
- # Extend a plain Ruby module with this API to make its public instance methods callable drawing steps.
6
+ # Extend a plain Ruby module with this API to make its public instance methods callable drawing steps. Name the
7
+ # method `call` when the module has a single drawing step. Use descriptive method names for multiple steps.
8
+ # Base blocks add argument-independent shared SVG content once per invocation before the public drawing methods.
9
+ # Invocation does not change the configured module, so it can be frozen after its drawing steps are defined. A
10
+ # duplicate or clone owns an independent configuration snapshot. Freezing a callable module prevents later base
11
+ # registration while leaving invocation available.
7
12
  # @example Define and call a drawing module
8
13
  # Widget = Module.new do
9
14
  # extend Sevgi::Graphics::Module
10
15
  #
11
- # def item(id)
12
- # rect(id:)
16
+ # base { css ".widget" => { fill: "red" } }
17
+ #
18
+ # def call(id)
19
+ # draw id
13
20
  # end
21
+ #
22
+ # private
23
+ #
24
+ # def draw(id) = rect id:, class: "widget"
14
25
  # end
15
26
  #
16
- # SVG { Call(Widget, "box") }
27
+ # Sevgi::Graphics.SVG { Call Widget, "box" }
17
28
  module Module
18
- # Tracks newly defined methods as callable drawing candidates.
19
- # Invocation runs unique methods that are still public, preserving tracked definition order.
20
- # @param method [Symbol] method name Ruby reports as added
21
- # @return [Array<Symbol>, nil]
22
- def method_added(method)
23
- super
29
+ # Ephemeral callable receiver that preserves a module's normal method lookup while forwarding drawing operations
30
+ # to the current element.
31
+ # @api private
32
+ class Context < ::BasicObject
33
+ # Creates a delegated callable receiver.
34
+ # @param callable [Module] callable module
35
+ # @param receiver [Sevgi::Graphics::Element] current drawing element
36
+ # @return [void]
37
+ def initialize(callable, receiver)
38
+ @callable = callable
39
+ @receiver = receiver
40
+ end
24
41
 
25
- _callables << method if public_method_defined?(method)
42
+ # Reports methods available through either callable composition or the drawing element.
43
+ # @param name [Symbol, String] method name
44
+ # @param include_private [Boolean] whether private methods count
45
+ # @return [Boolean]
46
+ def respond_to?(name, include_private = false) = respond_to_missing?(name, include_private)
47
+
48
+ private
49
+
50
+ # Reports methods available through callable composition or receiver delegation.
51
+ # @param name [Symbol, String] method name
52
+ # @param include_private [Boolean] whether private methods count
53
+ # @return [Boolean]
54
+ def respond_to_missing?(name, include_private = false)
55
+ @callable.public_method_defined?(name) ||
56
+ (include_private &&
57
+ (@callable.protected_method_defined?(name) || @callable.private_method_defined?(name))) ||
58
+ @receiver.respond_to?(name, include_private)
59
+ end
60
+
61
+ # Forwards operations outside the callable module's lookup chain to the drawing element.
62
+ # @param name [Symbol] missing method name
63
+ # @param arguments [Array<Object>] positional arguments
64
+ # @param keywords [Hash] keyword arguments
65
+ # @param block [Proc, nil] forwarded block
66
+ # @return [Object] delegated result
67
+ def method_missing(name, *arguments, **keywords, &block)
68
+ @receiver.__send__(name, *arguments, **keywords, &block)
69
+ end
26
70
  end
27
71
 
28
- # Class-level DSL for callable drawing modules.
29
- # @api private
30
- module DSL
31
- # Registers a before or after hook.
32
- # @param after [Boolean] true to register an after hook
33
- # @return [Array<Proc>] hook list
34
- def call(after = false, &block) = ((after ? _afters : _befores) << block)
72
+ private_constant :Context
73
+
74
+ # Registers argument-independent shared drawing steps. Every invocation runs inherited base blocks parent-first,
75
+ # then locally registered base blocks in registration order, before the module's public drawing methods. The block
76
+ # runs once on the callable receiver, which delegates drawing words to the current SVG element. It receives no
77
+ # invocation arguments. Callable methods receive those arguments and the caller's block unchanged.
78
+ # @yield evaluates shared drawing steps on the callable receiver
79
+ # @yieldreturn [Object] ignored block result
80
+ # @return [nil]
81
+ # @raise [Sevgi::ArgumentError] when no block is given
82
+ # @raise [FrozenError] when the callable module is frozen
83
+ def base(&block)
84
+ raise ::FrozenError, "can't modify frozen callable module" if frozen?
85
+
86
+ ArgumentError.("Block required") unless block
87
+
88
+ own_configuration
89
+ @sevgi_bases << block
90
+ nil
35
91
  end
36
92
 
37
- private_constant :DSL
93
+ class << self
94
+ private
95
+
96
+ # Initializes callable module state.
97
+ # @param base [Module] extended module
98
+ # @return [void]
99
+ def extended(base)
100
+ base.instance_variable_set(:@sevgi_bases, [])
101
+ base.instance_variable_set(:@sevgi_callables, base.public_instance_methods(false))
102
+ base.instance_variable_set(:@sevgi_configuration_owner, base.object_id)
103
+ end
38
104
 
39
- # Initializes callable module state.
40
- # @param base [Module] extended module
41
- # @return [void]
42
- def self.extended(base)
43
- base.instance_exec do
44
- @_callables = []
45
- @_befores = []
46
- @_afters = []
47
-
48
- class << self
49
- attr_reader :_callables, :_befores, :_afters
105
+ # Returns an owned snapshot of inherited and local base blocks in execution order.
106
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
107
+ # @return [Array<Proc>] parent-first base blocks followed by local base blocks
108
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module
109
+ def bases(mod)
110
+ validate(mod)
111
+
112
+ mod
113
+ .ancestors
114
+ .reverse_each
115
+ .filter_map do |ancestor|
116
+ ancestor.instance_variable_get(:@sevgi_bases) if ancestor.instance_variable_defined?(:@sevgi_bases)
117
+ end
118
+ .flatten
119
+ end
120
+
121
+ # @overload call(mod, receiver, *args, **kwargs)
122
+ # Runs module bases and callables against a receiver.
123
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
124
+ # @param receiver [Sevgi::Graphics::Element] receiver element
125
+ # @param args [Array<Object>] callable arguments
126
+ # @param kwargs [Hash] callable keyword arguments
127
+ # @return [Object, nil] last callable return value
128
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module
129
+ def call(mod, receiver, ...)
130
+ methods = callables(mod)
131
+ context = context(mod, receiver)
132
+ bases(mod).each { context.instance_exec(&it) }
133
+
134
+ invoke(context, receiver, methods, ...)
135
+ end
136
+
137
+ # Returns the executable drawing methods for a callable module.
138
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
139
+ # @return [Array<UnboundMethod>]
140
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module
141
+ def callables(mod)
142
+ validate(mod)
143
+
144
+ callable_names(mod).uniq.filter_map do |name|
145
+ mod.instance_method(name) if mod.public_method_defined?(name)
50
146
  end
147
+ end
51
148
 
52
- extend(DSL)
149
+ def callable_names(mod)
150
+ mod.ancestors.reverse_each.flat_map do |ancestor|
151
+ if ancestor.instance_variable_defined?(:@sevgi_callables)
152
+ ancestor.instance_variable_get(:@sevgi_callables) + ancestor.public_instance_methods(false)
153
+ else
154
+ ancestor.public_instance_methods(false)
155
+ end
156
+ end
157
+ end
158
+
159
+ def context(mod, receiver)
160
+ ::Class.new(Context) { include(mod) }.new(mod, receiver)
161
+ end
162
+
163
+ def invoke(context, receiver, methods, ...)
164
+ result = methods.map { context.__send__(it.name, ...) }.last
165
+ result.equal?(context) ? receiver : result
53
166
  end
54
- end
55
167
 
56
- # @overload call(mod, receiver, *args, **kwargs)
57
- # Runs module hooks and callables against a receiver.
58
- # @param mod [Module] callable module
59
- # @param receiver [Sevgi::Graphics::Element] receiver element
60
- # @param args [Array<Object>] callable arguments
61
- # @param kwargs [Hash] callable keyword arguments
62
- # @return [Object, nil] last callable return value
63
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
64
- def self.call(mod, receiver, ...)
65
- mod._befores.each { receiver.Within(receiver, &it) } if mod.respond_to?(:_befores) && mod._befores
66
- # return last callable return value
67
- callables(mod).map { it.bind(receiver).call(...) }.last.tap do
68
- mod._afters.each { receiver.Within(receiver, &it) } if mod.respond_to?(:_afters) && mod._afters
168
+ def validate(mod)
169
+ return mod if mod.instance_of?(::Module) && mod.is_a?(Graphics::Module)
170
+
171
+ ArgumentError.("Callable drawing module must extend Sevgi::Graphics::Module: #{mod}")
69
172
  end
70
173
  end
71
174
 
72
- # Returns the methods that should be executed for a callable module.
73
- # @param mod [Module] callable module
74
- # @return [Array<UnboundMethod>]
75
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
76
- def self.callables(mod)
77
- ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
175
+ private
176
+
177
+ # Gives a copied callable module independent configuration containers.
178
+ # @param original [Module] source callable module
179
+ # @return [void]
180
+ # @api private
181
+ def initialize_copy(original)
182
+ super
183
+ @sevgi_bases = original.instance_variable_get(:@sevgi_bases).dup
184
+ @sevgi_callables = original.instance_variable_get(:@sevgi_callables).dup
185
+ @sevgi_configuration_owner = object_id
186
+ end
187
+
188
+ def own_configuration
189
+ return if @sevgi_configuration_owner == object_id
190
+
191
+ @sevgi_bases = @sevgi_bases.dup
192
+ @sevgi_callables = @sevgi_callables.dup
193
+ @sevgi_configuration_owner = object_id
194
+ end
195
+
196
+ # Tracks newly defined methods as callable drawing candidates.
197
+ # Invocation runs unique methods that are still public, preserving tracked definition order.
198
+ # @param method [Symbol] method name Ruby reports as added
199
+ # @return [Array<Symbol>, nil]
200
+ def method_added(method)
201
+ super
202
+
203
+ own_configuration
204
+ @sevgi_callables << method
205
+ end
206
+ end
207
+
208
+ # Recursive callable drawing module support. Extend a module with this API when it and the modules defined beneath
209
+ # it form a callable drawing namespace. Existing and subsequently defined descendants receive the same recursive
210
+ # contract. Classes, autoloads, and modules aliased from another namespace are left unchanged.
211
+ # @example Define a callable drawing namespace
212
+ # module Icons
213
+ # extend Sevgi::Graphics::Modules
214
+ #
215
+ # module Alert
216
+ # base { css ".alert" => { fill: "red" } }
217
+ #
218
+ # def call(id)
219
+ # circle id:, class: "alert", r: 5
220
+ # end
221
+ # end
222
+ # end
223
+ #
224
+ # Sevgi::Graphics.SVG { Call Icons::Alert, "warning" }
225
+ # @see Sevgi::Graphics::Module
226
+ module Modules
227
+ # Propagates the recursive contract when a module constant is added.
228
+ # @api private
229
+ module Propagation
230
+ private
78
231
 
79
- callable_names(mod).uniq.filter_map do |name|
80
- mod.instance_method(name) if mod.public_method_defined?(name)
232
+ def const_added(name)
233
+ super
234
+ Graphics::Modules.__send__(:adopt, self, name)
81
235
  end
82
236
  end
83
237
 
84
- def self.callable_names(mod)
85
- tracked = mod.ancestors.reverse_each.filter_map do |ancestor|
86
- ancestor._callables if ancestor.respond_to?(:_callables)
238
+ private_constant :Propagation
239
+
240
+ class << self
241
+ private
242
+
243
+ # Applies the local callable contract and adopts owned descendants.
244
+ # @param base [Module] extended module
245
+ # @return [void]
246
+ def extended(base)
247
+ base.extend(Graphics::Module)
248
+ base.singleton_class.prepend(Propagation) unless base.singleton_class < Propagation
249
+ base.constants(false).each { adopt(base, it) }
87
250
  end
88
251
 
89
- return tracked.flatten unless tracked.empty?
252
+ # Applies this contract to an owned module constant without resolving autoloads or following aliases.
253
+ # @param owner [Module] constant owner
254
+ # @param name [Symbol] constant name
255
+ # @return [Module, nil] adopted module, or nil when the constant is outside the contract
256
+ def adopt(owner, name)
257
+ return if owner.autoload?(name, false)
90
258
 
91
- mod.public_instance_methods
92
- end
259
+ child = owner.const_get(name, false)
260
+ return unless child.instance_of?(::Module)
261
+ return unless child.name == "#{owner}::#{name}"
93
262
 
94
- private_class_method :callable_names
263
+ child.extend(self) unless child.is_a?(self)
264
+ end
265
+ end
95
266
  end
96
267
 
97
268
  module Mixtures
@@ -99,40 +270,15 @@ module Sevgi
99
270
  module Call
100
271
  # @overload Call(mod, *args, **kwargs)
101
272
  # Runs a callable drawing module in the current element context.
102
- # @param mod [Module] callable module
273
+ # @param mod [Module] module extended with {Sevgi::Graphics::Module}
103
274
  # @param args [Array<Object>] callable arguments
104
275
  # @param kwargs [Hash] callable keyword arguments
105
276
  # @return [Object, nil] last callable return value
106
- # @raise [Sevgi::ArgumentError] when mod is not a plain module
277
+ # @raise [Sevgi::ArgumentError] when mod is not a callable drawing module
107
278
  def Call(mod, ...)
108
- Graphics::Module.call(mod, self, ...)
279
+ Graphics::Module.__send__(:call, mod, self, ...)
109
280
  end
110
281
 
111
- private
112
-
113
- # rubocop:disable Metrics/MethodLength
114
- def CallWithin(mod, container, element, *args, **kwargs, &block)
115
- ArgumentError.("Must be a module: #{mod}") unless mod.instance_of?(::Module)
116
-
117
- kwargs = kwargs.merge(id: F.demodulize(mod).to_sym) unless kwargs.key?(:id)
118
-
119
- mod._befores.each { Within(self, &it) } if mod.respond_to?(:_befores) && mod._befores
120
-
121
- public_send(container, **kwargs) do
122
- Graphics::Module.callables(mod).each do |method|
123
- public_send(element) do
124
- Within(self, method.name, self, &block)
125
-
126
- method.bind(self).call(*args)
127
- end
128
- end
129
- end
130
-
131
- mod._afters.each { Within(self, &it) } if mod.respond_to?(:_afters) && mod._afters
132
-
133
- self
134
- end
135
- # rubocop:enable Metrics/MethodLength
136
282
  end
137
283
  end
138
284
  end