clogs 0.1.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,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text"
4
+
5
+ module Clogs
6
+ # Word-level text layout.
7
+ #
8
+ # libui will happily wrap a whole attributed string for us, but it will not
9
+ # tell us *where* it put anything -- there is no hit-testing or caret API on
10
+ # uiDrawTextLayout. Shoes needs that geometry: links have to be clickable,
11
+ # `para#hit` has to map a pixel to a character, and the editor has to draw a
12
+ # caret.
13
+ #
14
+ # So Clogs does its own line breaking. Each run is split into words, each
15
+ # word is measured once and cached, and lines are filled greedily. Drawing
16
+ # then batches adjacent words that share a style back into a single layout,
17
+ # so the common case still costs one libui text layout per line per style.
18
+ class Paragraph
19
+ # Shoes' named sizes, matching Scarpe's.
20
+ SIZES = {
21
+ inscription: 10, ins: 10, para: 12, caption: 14,
22
+ tagline: 18, subtitle: 26, title: 34, banner: 48
23
+ }.freeze
24
+
25
+ Placed = Struct.new(:text, :style, :x, :y, :width, :height, :owner, :char_offset)
26
+
27
+ attr_reader :width, :height, :lines, :placed
28
+
29
+ class << self
30
+ # Cache of [style_key, text] => [width, height]. Text-heavy apps repeat
31
+ # the same words constantly, and each miss costs an attributed string,
32
+ # a text layout and two FFI calls.
33
+ def measure_cache
34
+ @measure_cache ||= {}
35
+ end
36
+
37
+ def measure(text, style)
38
+ return [0.0, line_height(style)] if text.empty?
39
+
40
+ key = [style.cache_key, text].freeze
41
+ measure_cache[key] ||= begin
42
+ block = TextBlock.new(
43
+ [Run.new(text: text, size: style.size, family: style.family,
44
+ bold: style.bold, italic: style.italic, color: style.color)],
45
+ -1,
46
+ default_family: style.family,
47
+ default_size: style.size
48
+ )
49
+ dims = [block.width, block.height]
50
+ block.free
51
+ dims
52
+ end
53
+ end
54
+
55
+ def line_height(style)
56
+ key = [style.cache_key, :__line_height].freeze
57
+ measure_cache[key] ||= begin
58
+ w, h = measure("Hg", style)
59
+ _ = w
60
+ [0.0, h]
61
+ end
62
+ measure_cache[key][1]
63
+ end
64
+ end
65
+
66
+ # An immutable text style. Runs with equal styles can be drawn together.
67
+ TextStyle = Struct.new(:family, :size, :bold, :italic, :underline, :color, :bg, :owner, keyword_init: true) do
68
+ def cache_key
69
+ @cache_key ||= [family, size, bold, italic].freeze
70
+ end
71
+
72
+ def with(**changes)
73
+ TextStyle.new(to_h.merge(changes))
74
+ end
75
+
76
+ def same_run_as?(other)
77
+ family == other.family && size == other.size && bold == other.bold &&
78
+ italic == other.italic && underline == other.underline && color == other.color
79
+ end
80
+ end
81
+
82
+ # @param runs [Array<Array(String, TextStyle)>] text and its style
83
+ # @param wrap_width [Numeric] width to wrap at
84
+ def initialize(runs, wrap_width, align: :left)
85
+ @runs = runs
86
+ @wrap_width = wrap_width.to_f
87
+ @align = align
88
+ layout
89
+ end
90
+
91
+ # Split into words while keeping the whitespace attached to the preceding
92
+ # word, so that trailing spaces do not start a new line on their own.
93
+ def self.tokenize(text)
94
+ text.scan(/\n|[^\s]+[ \t]*|[ \t]+/)
95
+ end
96
+
97
+ def layout
98
+ @placed = []
99
+ x = 0.0
100
+ y = 0.0
101
+ line_start = 0
102
+ line_height = 0.0
103
+ char_offset = 0
104
+ max_x = 0.0
105
+
106
+ flush_line = lambda do
107
+ align_line(line_start, x, line_height)
108
+ max_x = [max_x, x].max
109
+ y += line_height.positive? ? line_height : 0
110
+ x = 0.0
111
+ line_height = 0.0
112
+ line_start = @placed.size
113
+ end
114
+
115
+ @runs.each do |text, style|
116
+ self.class.tokenize(text.to_s).each do |token|
117
+ if token == "\n"
118
+ line_height = self.class.line_height(style) if line_height.zero?
119
+ char_offset += 1
120
+ flush_line.call
121
+ next
122
+ end
123
+
124
+ w, h = self.class.measure(token, style)
125
+ # Trailing spaces should not force a wrap.
126
+ stripped = token.rstrip
127
+ effective_w = stripped == token ? w : self.class.measure(stripped, style)[0]
128
+
129
+ if x.positive? && @wrap_width.positive? && x + effective_w > @wrap_width
130
+ flush_line.call
131
+ end
132
+
133
+ @placed << Placed.new(token, style, x, y, w, h, style.owner, char_offset)
134
+ char_offset += token.length
135
+ x += w
136
+ line_height = [line_height, h].max
137
+ end
138
+ end
139
+ flush_line.call
140
+
141
+ @width = max_x
142
+ @height = y
143
+ end
144
+
145
+ def align_line(from, line_width, _line_height)
146
+ return if @align == :left || @wrap_width <= 0
147
+
148
+ shift = case @align
149
+ when :center then (@wrap_width - line_width) / 2.0
150
+ when :right then @wrap_width - line_width
151
+ else 0.0
152
+ end
153
+ return if shift <= 0
154
+
155
+ (from...@placed.size).each { |i| @placed[i].x += shift }
156
+ end
157
+
158
+ # Draw, batching runs that share a line and a style.
159
+ def draw(painter, ox, oy)
160
+ batch = []
161
+ flush = lambda do
162
+ next if batch.empty?
163
+
164
+ first = batch.first
165
+ text = batch.map(&:text).join
166
+ style = first.style
167
+ block = TextBlock.new(
168
+ [Run.new(text: text, size: style.size, family: style.family, bold: style.bold,
169
+ italic: style.italic, underline: style.underline, color: style.color)],
170
+ -1,
171
+ default_family: style.family,
172
+ default_size: style.size
173
+ )
174
+ if style.bg
175
+ h = batch.map(&:height).max
176
+ w = batch.sum(&:width)
177
+ painter.fill_rect(ox + first.x, oy + first.y, w, h, style.bg)
178
+ end
179
+ block.draw(painter, ox + first.x, oy + first.y)
180
+ block.free
181
+ batch = []
182
+ end
183
+
184
+ @placed.each do |item|
185
+ prev = batch.last
186
+ if prev && (prev.y != item.y || !prev.style.same_run_as?(item.style) ||
187
+ prev.style.bg != item.style.bg)
188
+ flush.call
189
+ end
190
+ batch << item
191
+ end
192
+ flush.call
193
+ end
194
+
195
+ # Bounding boxes for everything contributed by `owner`, used to make links
196
+ # clickable. One box per line the owner appears on.
197
+ def boxes_for(owner)
198
+ items = @placed.select { |i| i.owner.equal?(owner) }
199
+ items.group_by(&:y).map do |y, line_items|
200
+ x0 = line_items.map(&:x).min
201
+ x1 = line_items.map { |i| i.x + i.width }.max
202
+ h = line_items.map(&:height).max
203
+ [x0, y, x1 - x0, h]
204
+ end
205
+ end
206
+
207
+ # Character index nearest to a point, for text selection.
208
+ def index_at(px, py)
209
+ line = @placed.select { |i| py >= i.y && py < i.y + i.height }
210
+ line = @placed if line.empty?
211
+ return 0 if line.empty?
212
+
213
+ item = line.find { |i| px < i.x + i.width } || line.last
214
+ frac = item.width.zero? ? 0 : (px - item.x) / item.width
215
+ item.char_offset + (frac * item.text.length).round.clamp(0, item.text.length)
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clogs
4
+ # Coercion of Shoes style values into the numbers and colours the painter
5
+ # wants. Shoes is famously relaxed about types: a width can be `100`, `0.5`,
6
+ # `"50%"` or `-20`, and a colour can be a symbol, a string, an array or a
7
+ # Shoes::Color.
8
+ module Style
9
+ module_function
10
+
11
+ # Resolve a Shoes dimension against the space available.
12
+ #
13
+ # 100 => 100 pixels
14
+ # 0.5 => half of `available`
15
+ # "50%" => half of `available`
16
+ # -20 => `available` minus 20
17
+ # nil => nil (meaning "size yourself")
18
+ def dimension(value, available)
19
+ case value
20
+ when nil then nil
21
+ when Integer
22
+ value.negative? ? [available + value, 0].max : value
23
+ when Float
24
+ value <= 1.0 && value >= -1.0 ? (available * value).round : value.round
25
+ when String
26
+ if value.end_with?("%")
27
+ (available * value.to_f / 100.0).round
28
+ else
29
+ value.to_i
30
+ end
31
+ else
32
+ value.respond_to?(:to_i) ? value.to_i : nil
33
+ end
34
+ end
35
+
36
+ # Shoes colours reach the display service as [r, g, b, a] arrays, but user
37
+ # code can also set a bare symbol or "#ff0000" that never went through
38
+ # Shoes::Colors.
39
+ def color(value, default = nil)
40
+ case value
41
+ when nil then default
42
+ when Array
43
+ # Shoes accepts both 0-255 integers and 0.0-1.0 floats, and mixes them
44
+ # freely: `black(0.6)` is [0, 0, 0, 0.6].
45
+ r, g, b, a = value
46
+ [channel(r), channel(g), channel(b), a.nil? ? 255 : channel(a)]
47
+ when String
48
+ if value.start_with?("#")
49
+ hex(value)
50
+ else
51
+ named(value) || default
52
+ end
53
+ when Symbol
54
+ named(value.to_s) || default
55
+ else
56
+ if value.respond_to?(:to_a)
57
+ color(value.to_a, default)
58
+ else
59
+ default
60
+ end
61
+ end
62
+ end
63
+
64
+ # One colour channel, from either convention.
65
+ def channel(value)
66
+ return 0 if value.nil?
67
+ return (value.clamp(0.0, 1.0) * 255).round if value.is_a?(Float) && value <= 1.0
68
+
69
+ value.to_i.clamp(0, 255)
70
+ end
71
+
72
+ def hex(str)
73
+ s = str.delete_prefix("#")
74
+ s = s.chars.map { |c| c * 2 }.join if s.length == 3
75
+ alpha = s[6, 2]
76
+ [s[0, 2].to_i(16), s[2, 2].to_i(16), s[4, 2].to_i(16),
77
+ alpha.nil? || alpha.empty? ? 255 : alpha.to_i(16)]
78
+ end
79
+
80
+ def named(name)
81
+ require "shoes/colors" if !defined?(Shoes::Colors)
82
+ rgb = Shoes::Colors.to_rgb(name.to_sym)
83
+ rgb.is_a?(Array) ? color(rgb) : nil
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ # Shoes lets a margin be one number or [left, top, right, bottom].
89
+ def margins(styles)
90
+ m = styles["margin"] || styles[:margin]
91
+ base = case m
92
+ when nil then [0, 0, 0, 0]
93
+ when Array then m.map(&:to_i)
94
+ when Numeric then [m.to_i] * 4
95
+ when String then [m.to_i] * 4
96
+ else [0, 0, 0, 0]
97
+ end
98
+ left, top, right, bottom = base
99
+ [
100
+ (styles["margin_left"] || left).to_i,
101
+ (styles["margin_top"] || top).to_i,
102
+ (styles["margin_right"] || right).to_i,
103
+ (styles["margin_bottom"] || bottom).to_i
104
+ ]
105
+ end
106
+
107
+ def paddings(styles)
108
+ p = styles["padding"] || styles[:padding]
109
+ base = case p
110
+ when nil then [0, 0, 0, 0]
111
+ when Array then p.map(&:to_i)
112
+ when Numeric then [p.to_i] * 4
113
+ else [0, 0, 0, 0]
114
+ end
115
+ left, top, right, bottom = base
116
+ [
117
+ (styles["padding_left"] || left).to_i,
118
+ (styles["padding_top"] || top).to_i,
119
+ (styles["padding_right"] || right).to_i,
120
+ (styles["padding_bottom"] || bottom).to_i
121
+ ]
122
+ end
123
+ end
124
+ end
data/lib/clogs/text.rb ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui"
4
+
5
+ module Clogs
6
+ # One styled run of text inside a paragraph.
7
+ Run = Struct.new(:text, :size, :color, :family, :bold, :italic, :underline, :owner, keyword_init: true) do
8
+ def style_key
9
+ [size, color, family, bold, italic, underline]
10
+ end
11
+ end
12
+
13
+ # A laid-out paragraph.
14
+ #
15
+ # libui gives us Pango/DirectWrite/Core Text via uiDrawTextLayout: an
16
+ # attributed string plus a wrap width, and it reports the resulting extents.
17
+ # That covers Shoes' text model (a paragraph of differently-styled spans that
18
+ # wraps) without us writing a line breaker.
19
+ #
20
+ # What it does *not* give us is per-character hit testing or caret geometry,
21
+ # which is why Clogs' own text editing (edit_line / edit_box) measures
22
+ # substrings instead of asking the layout where a click landed.
23
+ class TextBlock
24
+ ALIGN_LEFT = 0
25
+ ALIGN_CENTER = 1
26
+ ALIGN_RIGHT = 2
27
+
28
+ attr_reader :width, :height, :runs
29
+
30
+ def initialize(runs, wrap_width, align: ALIGN_LEFT, default_family: nil, default_size: nil)
31
+ @runs = runs
32
+ @wrap_width = wrap_width.to_f
33
+ @align = align
34
+ @default_family = default_family || Clogs.default_font_family
35
+ @default_size = default_size || Clogs.default_font_size
36
+ build
37
+ end
38
+
39
+ def draw(painter, x, y)
40
+ painter.draw_text(@layout, x, y)
41
+ end
42
+
43
+ # Width of the first `n` characters, used to place text carets.
44
+ def prefix_width(n)
45
+ return 0.0 if n <= 0
46
+
47
+ text = plain_text
48
+ n = text.length if n > text.length
49
+ @prefix_cache ||= {}
50
+ @prefix_cache[n] ||= begin
51
+ sub = TextBlock.new(
52
+ [Run.new(text: text[0, n], **first_style)],
53
+ -1,
54
+ default_family: @default_family,
55
+ default_size: @default_size
56
+ )
57
+ w = sub.width
58
+ sub.free
59
+ w
60
+ end
61
+ end
62
+
63
+ def plain_text
64
+ @plain_text ||= @runs.map { |r| r.text.to_s }.join
65
+ end
66
+
67
+ def free
68
+ UI::L.draw_free_text_layout(@layout) if @layout
69
+ UI::L.free_attributed_string(@astr) if @astr
70
+ @layout = nil
71
+ @astr = nil
72
+ end
73
+
74
+ private
75
+
76
+ def first_style
77
+ r = @runs.first
78
+ return { size: @default_size, color: [0, 0, 0, 255] } unless r
79
+
80
+ { size: r.size, color: r.color, family: r.family, bold: r.bold, italic: r.italic }
81
+ end
82
+
83
+ def build
84
+ @astr = UI::L.new_attributed_string("")
85
+ @runs.each do |run|
86
+ text = run.text.to_s
87
+ next if text.empty?
88
+
89
+ start = UI::L.attributed_string_len(@astr)
90
+ UI::L.attributed_string_append_unattributed(@astr, text)
91
+ finish = start + text.bytesize
92
+
93
+ if run.size
94
+ UI::L.attributed_string_set_attribute(@astr, UI::L.new_size_attribute(run.size.to_f), start, finish)
95
+ end
96
+ if run.color
97
+ r, g, b, a = run.color
98
+ UI::L.attributed_string_set_attribute(
99
+ @astr,
100
+ UI::L.new_color_attribute(r / 255.0, g / 255.0, b / 255.0, (a || 255) / 255.0),
101
+ start, finish
102
+ )
103
+ end
104
+ if run.family
105
+ UI::L.attributed_string_set_attribute(@astr, UI::L.new_family_attribute(run.family), start, finish)
106
+ end
107
+ if run.bold
108
+ UI::L.attributed_string_set_attribute(@astr, UI::L.new_weight_attribute(UI::WEIGHT_BOLD), start, finish)
109
+ end
110
+ if run.italic
111
+ UI::L.attributed_string_set_attribute(@astr, UI::L.new_italic_attribute(UI::ITALIC_ITALIC), start, finish)
112
+ end
113
+ if run.underline
114
+ UI::L.attributed_string_set_attribute(@astr, UI::L.new_underline_attribute(1), start, finish)
115
+ end
116
+ end
117
+
118
+ font = UI.font_descriptor(@default_family, @default_size)
119
+ params = UI.malloc(UI::L::FFI::DrawTextLayoutParams)
120
+ params.String = @astr
121
+ params.DefaultFont = font.to_ptr
122
+ # A negative width means "do not wrap"; libui wants a very large number.
123
+ params.Width = @wrap_width.negative? ? 1_000_000.0 : @wrap_width
124
+ params.Align = @align
125
+ # Keep the descriptor alive for as long as the layout uses it.
126
+ @font = font
127
+
128
+ @layout = UI::L.draw_new_text_layout(params)
129
+ @width, @height = UI.text_extents(@layout)
130
+ end
131
+ end
132
+ end
data/lib/clogs/ui.rb ADDED
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "libui"
4
+
5
+ module Clogs
6
+ # Thin, hand-rolled conveniences over the raw libui FFI bindings.
7
+ #
8
+ # Two things make raw libui painful from Ruby and both are handled here:
9
+ #
10
+ # 1. Every callback handed to C must be a Fiddle::Closure that outlives the
11
+ # C-side registration. Ruby will happily collect one that is still
12
+ # installed, and libui will then jump into freed memory. {UI.callback}
13
+ # keeps a permanent reference.
14
+ # 2. Structs must be malloc'd and marked with a free function, and several of
15
+ # them (brushes, stroke params, font descriptors) are needed on every
16
+ # single paint. Allocating them per-paint is measurable, so the hot ones
17
+ # are pooled.
18
+ module UI
19
+ L = ::LibUI
20
+
21
+ # Fill modes
22
+ WINDING = 0
23
+ ALTERNATE = 1
24
+
25
+ # Line caps / joins
26
+ CAP_FLAT = 0
27
+ CAP_ROUND = 1
28
+ CAP_SQUARE = 2
29
+ JOIN_MITER = 0
30
+ JOIN_ROUND = 1
31
+ JOIN_BEVEL = 2
32
+
33
+ BRUSH_SOLID = 0
34
+ BRUSH_LINEAR_GRADIENT = 1
35
+ BRUSH_RADIAL_GRADIENT = 2
36
+
37
+ # Text weights/italics/stretches we actually use
38
+ WEIGHT_NORMAL = 400
39
+ WEIGHT_BOLD = 700
40
+ ITALIC_NORMAL = 0
41
+ ITALIC_ITALIC = 1
42
+ STRETCH_NORMAL = 4
43
+
44
+ # uiExtKey values, from ui.h. libui reports these separately from `Key`.
45
+ EXT_KEYS = {
46
+ 1 => :escape, 2 => :insert, 3 => :delete, 4 => :home, 5 => :end,
47
+ 6 => :page_up, 7 => :page_down, 8 => :up, 9 => :down, 10 => :left,
48
+ 11 => :right, 12 => :f1, 13 => :f2, 14 => :f3, 15 => :f4, 16 => :f5,
49
+ 17 => :f6, 18 => :f7, 19 => :f8, 20 => :f9, 21 => :f10, 22 => :f11,
50
+ 23 => :f12
51
+ }.freeze
52
+
53
+ MOD_CTRL = 1 << 0
54
+ MOD_ALT = 1 << 1
55
+ MOD_SHIFT = 1 << 2
56
+ MOD_SUPER = 1 << 3
57
+
58
+ class << self
59
+ # Closures handed to C live forever. There are a bounded number of them
60
+ # (a handful per window plus one per timer) so this does not grow without
61
+ # bound in practice.
62
+ def callback(return_type, arg_types, &block)
63
+ closure = Fiddle::Closure::BlockCaller.new(return_type, arg_types, &block)
64
+ (@callbacks ||= []) << closure
65
+ closure
66
+ end
67
+
68
+ def malloc(struct_class)
69
+ s = struct_class.malloc
70
+ s.to_ptr.free = Fiddle::RUBY_FREE
71
+ s
72
+ end
73
+
74
+ # A solid uiDrawBrush. Colors arrive as Shoes [r, g, b, a] 0-255 arrays.
75
+ def solid_brush(color)
76
+ b = malloc(L::FFI::DrawBrush)
77
+ b.Type = BRUSH_SOLID
78
+ r, g, bl, a = color
79
+ b.R = r / 255.0
80
+ b.G = g / 255.0
81
+ b.B = bl / 255.0
82
+ b.A = (a || 255) / 255.0
83
+ b
84
+ end
85
+
86
+ # A linear or radial gradient brush. `stops` is [[pos, [r,g,b,a]], ...].
87
+ #
88
+ # The Stops array must stay alive as long as the brush does, so it is
89
+ # stashed on the brush object; Ruby's GC then keeps them together.
90
+ def gradient_brush(type, from, to, stops, outer_radius: 0)
91
+ b = malloc(L::FFI::DrawBrush)
92
+ b.Type = type
93
+ b.X0, b.Y0 = from
94
+ b.X1, b.Y1 = to
95
+ b.OuterRadius = outer_radius
96
+
97
+ buf = Fiddle::Pointer.malloc(stops.size * 40, Fiddle::RUBY_FREE)
98
+ stops.each_with_index do |(pos, color), i|
99
+ r, g, bl, a = color
100
+ buf[i * 40, 40] = [pos, r / 255.0, g / 255.0, bl / 255.0, (a || 255) / 255.0].pack("d5")
101
+ end
102
+ b.Stops = buf
103
+ b.NumStops = stops.size
104
+ b.instance_variable_set(:@clogs_stops, buf)
105
+ b
106
+ end
107
+
108
+ def stroke_params(thickness, cap: CAP_FLAT, join: JOIN_MITER, dashes: nil)
109
+ sp = malloc(L::FFI::DrawStrokeParams)
110
+ sp.Cap = cap
111
+ sp.Join = join
112
+ sp.Thickness = thickness.to_f
113
+ sp.MiterLimit = 10.0
114
+ if dashes && !dashes.empty?
115
+ buf = Fiddle::Pointer.malloc(dashes.size * 8, Fiddle::RUBY_FREE)
116
+ buf[0, dashes.size * 8] = dashes.map(&:to_f).pack("d*")
117
+ sp.Dashes = buf
118
+ sp.NumDashes = dashes.size
119
+ sp.instance_variable_set(:@clogs_dashes, buf)
120
+ else
121
+ sp.NumDashes = 0
122
+ end
123
+ sp.DashPhase = 0.0
124
+ sp
125
+ end
126
+
127
+ def font_descriptor(family, size, weight: WEIGHT_NORMAL, italic: ITALIC_NORMAL)
128
+ fd = malloc(L::FFI::FontDescriptor)
129
+ fd.Family = family
130
+ fd.Size = size.to_f
131
+ fd.Weight = weight
132
+ fd.Italic = italic
133
+ fd.Stretch = STRETCH_NORMAL
134
+ # Fiddle copies the string into the struct but does not own it; keep the
135
+ # Ruby string alive so the char* stays valid.
136
+ fd.instance_variable_set(:@clogs_family, family)
137
+ fd
138
+ end
139
+
140
+ # uiDrawTextLayoutExtents writes through two double pointers.
141
+ def text_extents(layout)
142
+ w = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
143
+ h = Fiddle::Pointer.malloc(8, Fiddle::RUBY_FREE)
144
+ L.draw_text_layout_extents(layout, w, h)
145
+ [w[0, 8].unpack1("d"), h[0, 8].unpack1("d")]
146
+ end
147
+
148
+ # True if this libui build can blit a uiImage into a draw context.
149
+ # Stock libui-ng cannot; see docs/libui_shoes_coverage.md.
150
+ def draw_image?
151
+ return @draw_image unless @draw_image.nil?
152
+
153
+ @draw_image = L.respond_to?(:draw_image)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clogs
4
+ VERSION = "0.1.0"
5
+ end