sevgi-graphics 0.94.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +275 -2
  3. data/LICENSE +672 -3
  4. data/README.md +8 -8
  5. data/lib/sevgi/graphics/attribute.rb +271 -54
  6. data/lib/sevgi/graphics/auxiliary/canvas.rb +137 -58
  7. data/lib/sevgi/graphics/auxiliary/content.rb +200 -57
  8. data/lib/sevgi/graphics/auxiliary/margin.rb +33 -14
  9. data/lib/sevgi/graphics/auxiliary/paper.rb +118 -86
  10. data/lib/sevgi/graphics/auxiliary/path.rb +44 -0
  11. data/lib/sevgi/graphics/auxiliary/scalar.rb +69 -0
  12. data/lib/sevgi/graphics/auxiliary/sizes.rb +62 -0
  13. data/lib/sevgi/graphics/auxiliary.rb +3 -0
  14. data/lib/sevgi/graphics/document/base.rb +6 -2
  15. data/lib/sevgi/graphics/document/default.rb +1 -1
  16. data/lib/sevgi/graphics/document.rb +370 -124
  17. data/lib/sevgi/graphics/element.rb +143 -33
  18. data/lib/sevgi/graphics/mixtures/call.rb +249 -88
  19. data/lib/sevgi/graphics/mixtures/core.rb +143 -33
  20. data/lib/sevgi/graphics/mixtures/duplicate.rb +76 -22
  21. data/lib/sevgi/graphics/mixtures/export.rb +58 -12
  22. data/lib/sevgi/graphics/mixtures/hatch.rb +49 -7
  23. data/lib/sevgi/graphics/mixtures/identify.rb +30 -17
  24. data/lib/sevgi/graphics/mixtures/include.rb +40 -5
  25. data/lib/sevgi/graphics/mixtures/inkscape.rb +215 -46
  26. data/lib/sevgi/graphics/mixtures/rdf.rb +79 -7
  27. data/lib/sevgi/graphics/mixtures/render.rb +88 -19
  28. data/lib/sevgi/graphics/mixtures/save.rb +79 -26
  29. data/lib/sevgi/graphics/mixtures/symbols.rb +81 -9
  30. data/lib/sevgi/graphics/mixtures/tile.rb +88 -64
  31. data/lib/sevgi/graphics/mixtures/transform.rb +68 -28
  32. data/lib/sevgi/graphics/mixtures/underscore.rb +16 -7
  33. data/lib/sevgi/graphics/mixtures/validate.rb +14 -2
  34. data/lib/sevgi/graphics/mixtures/wrappers.rb +66 -24
  35. data/lib/sevgi/graphics/mixtures.rb +19 -13
  36. data/lib/sevgi/graphics/version.rb +1 -1
  37. data/lib/sevgi/graphics/xml.rb +127 -0
  38. data/lib/sevgi/graphics.rb +75 -28
  39. metadata +10 -6
@@ -3,43 +3,115 @@
3
3
  module Sevgi
4
4
  module Graphics
5
5
  # SVG element node used by the graphics DSL.
6
+ #
7
+ # @!method self.valid?(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
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.
6
15
  class Element
7
16
  # Builds an element node.
8
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
9
20
  # @param parent [Sevgi::Graphics::Element] parent element
21
+ # @yield evaluates the drawing DSL in the new element
22
+ # @yieldreturn [Object] ignored block result
10
23
  # @return [Sevgi::Graphics::Element]
11
24
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
12
- def self.element(name, *, parent:, &block) = new(name, **Dispatch.parse(name, *), parent:, &block)
25
+ # @raise [Sevgi::ArgumentError] when the parent has a different concrete element class
26
+ # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
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
13
31
 
14
32
  # Builds an SVG root element.
15
- # @param block [Proc, nil] DSL block evaluated in the root element
33
+ # @param arguments [Array<Hash, String, Sevgi::Graphics::Content>] ordered content and attribute channels
34
+ # @yield evaluates the drawing DSL in the root element
35
+ # @yieldreturn [Object] ignored block result
16
36
  # @return [Sevgi::Graphics::Element]
17
- 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)
18
40
 
19
41
  # Reports whether an element is the root element.
20
42
  # @param element [Sevgi::Graphics::Element] element to test
21
43
  # @return [Boolean]
22
- 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
23
79
 
24
80
  class << self
25
81
  require "sevgi/standard"
26
82
 
27
- # Reports whether an SVG element name is known.
28
- # @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
29
85
  # @return [Boolean]
30
- def valid?(name) = Standard.element?(name)
86
+ def valid?(name)
87
+ Standard.element?(name)
88
+ rescue Sevgi::ArgumentError
89
+ false
90
+ end
91
+
31
92
  rescue ::LoadError => e
32
93
  raise unless e.path == "sevgi/standard"
33
94
 
34
- # 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
35
97
  # @return [Boolean]
36
- 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
37
106
  end
38
107
 
39
108
  private_class_method :new
40
109
 
41
- # Sentinel parent used by root SVG elements.
110
+ # Sentinel parents used by root and detached elements.
42
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
43
115
 
44
116
  # SVG element method-name normalization.
45
117
  # @api private
@@ -51,54 +123,85 @@ module Sevgi
51
123
  end
52
124
 
53
125
  extend Ident
126
+ private_class_method :id
127
+ private_constant :Ident
128
+
129
+ # Returns the SVG element name.
130
+ # @return [Symbol]
131
+ attr_reader :name
54
132
 
55
- # @!attribute [r] name
56
- # @return [Symbol] SVG element name
57
- # @!attribute [r] attributes
58
- # @return [Sevgi::Graphics::Attributes] attribute store
59
- # @!attribute [r] children
60
- # @return [Array<Sevgi::Graphics::Element>] child elements
61
- # @!attribute [r] contents
62
- # @return [Array<Sevgi::Graphics::Content>] element content objects
63
- # @!attribute [r] parent
64
- # @return [Sevgi::Graphics::Element, Object] parent element or root sentinel
65
- attr_reader :name, :attributes, :children, :contents, :parent
133
+ # Returns the attribute store.
134
+ # @return [Sevgi::Graphics::Attributes]
135
+ attr_reader :attributes
136
+
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
140
+
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
144
+
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
66
151
 
67
152
  # Creates an element.
68
153
  # @param name [Symbol] SVG element name
69
154
  # @param parent [Sevgi::Graphics::Element, Object] parent element or root sentinel
70
155
  # @param attributes [Hash] SVG attributes
71
156
  # @param contents [Array<Sevgi::Graphics::Content>] content objects
157
+ # @yield evaluates the drawing DSL in the new element
158
+ # @yieldreturn [Object] ignored block result
72
159
  # @return [void]
160
+ # @raise [Sevgi::ArgumentError] when the parent has a different concrete element class
161
+ # @raise [Sevgi::ArgumentError] when the element name or an attribute is not valid XML
162
+ # @api private
73
163
  def initialize(name, parent:, attributes: {}, contents: [], &block)
74
- @name = name
164
+ Element.send(:validate_parent, self, parent)
165
+
166
+ unless name.is_a?(::String) || name.is_a?(::Symbol)
167
+ ArgumentError.("XML element name must be a String or Symbol")
168
+ end
169
+
170
+ @name = XML.name(name, context: "XML element name").to_sym
75
171
  @attributes = Attributes.new(attributes)
76
172
  @children = []
77
- @contents = contents
173
+ @contents = contents.dup
78
174
  @parent = parent
79
175
 
80
- parent.children << self unless self.class.root?(self)
176
+ Element.send(:attach, self, parent) unless Element.root?(self)
81
177
 
82
178
  instance_exec(&block) if block
83
179
  end
84
180
 
85
181
  # Dispatches SVG element DSL calls and caches valid element methods.
86
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
185
+ # @yield evaluates the drawing DSL in the dispatched child element
186
+ # @yieldreturn [Object] ignored block result
87
187
  # @return [Sevgi::Graphics::Element]
88
188
  # @raise [NameError] when the name is not a valid SVG element
89
189
  # @raise [Sevgi::ArgumentError] when an argument cannot be parsed as attributes or content
90
- def method_missing(name, *, &block)
91
- 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
92
192
  end
93
193
 
94
194
  # Reports whether a missing method can dispatch to an SVG element.
95
195
  # @param name [Symbol] queried method name
96
196
  # @param include_private [Boolean] standard Ruby respond_to? flag
97
197
  # @return [Boolean]
198
+ # @api private
98
199
  def respond_to_missing?(name, include_private = false)
99
- Element.valid?(Element.id(name)) || super
200
+ Element.valid?(Element.send(:id, name)) || super
100
201
  end
101
202
 
203
+ private :method_missing, :respond_to_missing?
204
+
102
205
  # Element method-missing parser and cache.
103
206
  # @api private
104
207
  module Dispatch
@@ -110,38 +213,45 @@ module Sevgi
110
213
  # @param tag [Symbol] SVG element name
111
214
  # @return [Sevgi::Graphics::Element]
112
215
  def call(element, method, tag, *, &)
113
- # Low-hanging fruit optimization: define missing method to avoid dispatching cost
114
216
  unless Element.method_defined?(method)
115
217
  Element.class_exec do
116
218
  define_method(method) { |*args, &block| self.class.element(tag, *args, parent: self, &block) }
117
219
  end
220
+
221
+ (@methods ||= {})[method] = Element.instance_method(method)
118
222
  end
119
223
 
120
224
  element.public_send(method, *, &)
121
225
  end
122
226
 
227
+ def cached?(method)
228
+ cached = @methods&.[](method)
229
+
230
+ cached && Element.method_defined?(method) && Element.instance_method(method) == cached
231
+ end
232
+
123
233
  # Parses element DSL arguments.
124
234
  # @param name [Symbol] SVG element name
125
- # @param args [Array<Object>] positional DSL arguments
235
+ # @param args [Array<Object>] positional DSL arguments; Hashes are imported from left to right
126
236
  # @return [Hash] parsed :attributes and :contents
127
237
  # @raise [Sevgi::ArgumentError] when an argument is not a Hash, String, or Content
128
238
  def parse(name, *args)
129
- attributes, contents = {}, []
239
+ attributes, contents = Attributes.new, []
130
240
 
131
241
  args.each do |arg|
132
242
  case arg
133
243
  when ::Hash
134
- attributes = arg
244
+ attributes.merge!(arg)
135
245
  when ::String
136
246
  contents << Content.encoded(arg)
137
247
  when Content
138
248
  contents << arg
139
249
  else
140
- 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}")
141
251
  end
142
252
  end
143
253
 
144
- {attributes:, contents:}
254
+ {attributes: attributes.to_h, contents:}
145
255
  end
146
256
  end
147
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