sevgi-graphics 0.95.0 → 0.98.2

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