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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+
5
+ module Clogs
6
+ # A slot: the container drawable that does Shoes' layout.
7
+ #
8
+ # Shoes has exactly two layout modes and they are both simple:
9
+ #
10
+ # stack - children are placed one below another, each as wide as the slot
11
+ # flow - children are placed left to right and wrap when they run out of
12
+ # room, like words in a paragraph
13
+ #
14
+ # Anything with an explicit `left` and `top` is lifted out of that flow and
15
+ # placed at those coordinates relative to the slot, which is how Shoes
16
+ # programs do absolute positioning.
17
+ class Slot < Drawable
18
+ def flow?
19
+ false
20
+ end
21
+
22
+ def padding
23
+ @padding ||= Style.paddings(@styles)
24
+ end
25
+
26
+ # Backgrounds and borders are children in the Shoes tree but they are not
27
+ # laid out: they cover the whole slot.
28
+ def decorations
29
+ @children.select { |c| c.is_a?(Background) || c.is_a?(Border) }
30
+ end
31
+
32
+ def laid_out_children
33
+ @children.reject { |c| c.is_a?(Background) || c.is_a?(Border) || c.hidden? }
34
+ end
35
+
36
+ def measure(available_width)
37
+ ml, _mt, mr, _mb = margin
38
+ pl, pt, pr, pb = padding
39
+
40
+ outer_width = requested_width(available_width) || (available_width - ml - mr)
41
+ outer_width = 0 if outer_width.negative?
42
+ content_width = [outer_width - pl - pr, 0].max
43
+
44
+ flowed, positioned = laid_out_children.partition { |c| !c.positioned? }
45
+ content_height = flow? ? layout_flow(flowed, content_width) : layout_stack(flowed, content_width)
46
+
47
+ positioned.each do |child|
48
+ child.measure(content_width)
49
+ child.x = pl + Style.dimension(child.style(:left), content_width).to_i
50
+ child.y = pt + Style.dimension(child.style(:top), outer_width).to_i
51
+ end
52
+
53
+ @content_width = content_width
54
+ @width = outer_width
55
+ @height = requested_height(available_width) || (content_height + pt + pb)
56
+ decorations.each { |d| d.measure_for_slot(@width, @height) }
57
+ end
58
+
59
+ def layout_stack(kids, content_width)
60
+ _pl, pt, = padding
61
+ pl = padding[0]
62
+ y = pt
63
+ kids.each do |child|
64
+ cml, cmt, _cmr, cmb = child.margin
65
+ child.measure(content_width)
66
+ child.x = pl + cml
67
+ child.y = y + cmt
68
+ y += cmt + child.height + cmb
69
+ end
70
+ y - pt
71
+ end
72
+
73
+ def layout_flow(kids, content_width)
74
+ pl, pt, = padding
75
+ x = 0
76
+ y = 0
77
+ line_height = 0
78
+ kids.each do |child|
79
+ cml, cmt, cmr, cmb = child.margin
80
+ child.measure(content_width)
81
+ advance = cml + child.width + cmr
82
+ if x.positive? && x + advance > content_width
83
+ y += line_height
84
+ x = 0
85
+ line_height = 0
86
+ end
87
+ child.x = pl + x + cml
88
+ child.y = pt + y + cmt
89
+ x += advance
90
+ line_height = [line_height, cmt + child.height + cmb].max
91
+ end
92
+ y + line_height
93
+ end
94
+
95
+ def paint(painter, ox, oy)
96
+ @abs_x = ox + @x
97
+ @abs_y = oy + @y
98
+
99
+ decorations.grep(Background).each { |d| d.paint_for_slot(painter, @abs_x, @abs_y, @width, @height) }
100
+ @children.each do |child|
101
+ next if child.hidden? || child.is_a?(Background) || child.is_a?(Border)
102
+
103
+ child.paint(painter, @abs_x, @abs_y)
104
+ end
105
+ decorations.grep(Border).each { |d| d.paint_for_slot(painter, @abs_x, @abs_y, @width, @height) }
106
+ end
107
+ end
108
+
109
+ class Stack < Slot; end
110
+
111
+ class Flow < Slot
112
+ def flow?
113
+ true
114
+ end
115
+ end
116
+
117
+ # The invisible slot that holds an app's whole document. Shoes' top level
118
+ # behaves like a flow.
119
+ class DocumentRoot < Flow
120
+ def app=(app)
121
+ @app = app
122
+ end
123
+
124
+ def app
125
+ @app
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+ require_relative "../paragraph"
5
+
6
+ module Clogs
7
+ # The display side of Shoes' inline text drawables: strong, em, code, link,
8
+ # span and friends. These have no geometry of their own -- they contribute
9
+ # styled runs to whichever paragraph contains them.
10
+ class TextDrawable < Drawable
11
+ def emphasis_style(base)
12
+ style_for_tag(base)
13
+ end
14
+
15
+ # Overridden per tag.
16
+ def style_for_tag(base)
17
+ base
18
+ end
19
+
20
+ def own_style(base)
21
+ s = style_for_tag(base)
22
+ s = s.with(size: resolved_size(s.size)) if style(:size)
23
+ s = s.with(color: Style.color(style(:stroke), s.color)) if style(:stroke)
24
+ s = s.with(bg: Style.color(style(:fill), s.bg)) if style(:fill)
25
+ s = s.with(family: style(:family)) if style(:family)
26
+ s = s.with(underline: true) if truthy_underline?
27
+ s = s.with(italic: true) if style(:emphasis).to_s == "italic"
28
+ s.with(owner: self)
29
+ end
30
+
31
+ def truthy_underline?
32
+ u = style(:underline)
33
+ !u.nil? && u != "" && u != "none" && u != false
34
+ end
35
+
36
+ def resolved_size(default)
37
+ Paragraph::SIZES[style(:size).to_s.to_sym] || (style(:size).is_a?(Numeric) ? style(:size) : default)
38
+ end
39
+
40
+ # Contribute [text, style] pairs to the enclosing paragraph.
41
+ def runs(base_style)
42
+ mine = own_style(base_style)
43
+ Clogs.expand_text_items(style(:text_items), mine)
44
+ end
45
+ end
46
+
47
+ class Strong < TextDrawable
48
+ def style_for_tag(base) = base.with(bold: true)
49
+ end
50
+
51
+ class Em < TextDrawable
52
+ def style_for_tag(base) = base.with(italic: true)
53
+ end
54
+
55
+ class Code < TextDrawable
56
+ def style_for_tag(base) = base.with(family: Clogs.monospace_font_family)
57
+ end
58
+
59
+ class Ins < TextDrawable
60
+ def style_for_tag(base) = base.with(underline: true)
61
+ end
62
+
63
+ class Del < TextDrawable
64
+ # libui's attributed strings have no strikethrough attribute exposed by the
65
+ # Ruby binding, so del() renders as plain text. Documented in the coverage
66
+ # matrix rather than silently pretending.
67
+ def style_for_tag(base) = base
68
+ end
69
+
70
+ class Sub < TextDrawable
71
+ def style_for_tag(base) = base.with(size: (base.size * 0.7).round)
72
+ end
73
+
74
+ class Sup < TextDrawable
75
+ def style_for_tag(base) = base.with(size: (base.size * 0.7).round)
76
+ end
77
+
78
+ class Span < TextDrawable; end
79
+
80
+ class Bg < TextDrawable
81
+ def style_for_tag(base) = base.with(bg: Style.color(style(:fill), base.bg))
82
+ end
83
+
84
+ class Fg < TextDrawable
85
+ def style_for_tag(base) = base.with(color: Style.color(style(:stroke), base.color))
86
+ end
87
+
88
+ # A clickable run of text. Clicks are routed by the containing paragraph,
89
+ # which knows where the link's words ended up on screen.
90
+ class Link < TextDrawable
91
+ DEFAULT_COLOR = [0, 0, 238, 255].freeze
92
+
93
+ def style_for_tag(base)
94
+ base.with(color: DEFAULT_COLOR, underline: true)
95
+ end
96
+
97
+ def clickable?
98
+ true
99
+ end
100
+
101
+ def click!(x, y)
102
+ notify("click", 1, x, y)
103
+ end
104
+ end
105
+
106
+ # A paragraph: the only text drawable with real geometry.
107
+ class Para < Drawable
108
+ def default_size
109
+ Paragraph::SIZES[:para]
110
+ end
111
+
112
+ def base_style
113
+ size = style(:size)
114
+ resolved = Paragraph::SIZES[size.to_s.to_sym] || (size.is_a?(Numeric) ? size.to_i : default_size)
115
+ Paragraph::TextStyle.new(
116
+ family: style(:family) || Clogs.default_font_family,
117
+ size: resolved,
118
+ bold: style(:font_weight).to_s == "bold",
119
+ italic: %w[italic oblique].include?(style(:emphasis).to_s),
120
+ underline: false,
121
+ color: Style.color(style(:stroke), [0, 0, 0, 255]),
122
+ bg: Style.color(style(:fill), nil),
123
+ owner: self
124
+ )
125
+ end
126
+
127
+ def align
128
+ case style(:align).to_s
129
+ when "center" then :center
130
+ when "right" then :right
131
+ else :left
132
+ end
133
+ end
134
+
135
+ def measure(available_width)
136
+ ml, _mt, mr, = margin
137
+ avail = requested_width(available_width) || (available_width - ml - mr)
138
+ @paragraph = Paragraph.new(Clogs.expand_text_items(style(:text_items), base_style), avail, align: align)
139
+ @width = requested_width(available_width) || [@paragraph.width.ceil, avail].min
140
+ @height = requested_height(available_width) || @paragraph.height.ceil
141
+ end
142
+
143
+ def draw(painter, x, y)
144
+ return unless @paragraph
145
+
146
+ @paragraph.draw(painter, x, y)
147
+ draw_text_cursor(painter, x, y)
148
+ end
149
+
150
+ # Shoes' `para.cursor = n` puts a caret at character n. libui's text layouts
151
+ # do not expose caret geometry, but Clogs lays out word by word, so the
152
+ # position is a lookup plus one substring measurement.
153
+ def draw_text_cursor(painter, x, y)
154
+ position = style(:text_cursor)
155
+ return if position.nil? || ENV["CLOGS_NO_CARET"]
156
+
157
+ item = @paragraph.placed.find do |placed|
158
+ position >= placed.char_offset && position <= placed.char_offset + placed.text.length
159
+ end
160
+ item ||= @paragraph.placed.last
161
+ return unless item
162
+
163
+ prefix = item.text[0...(position - item.char_offset)].to_s
164
+ cx = x + item.x + Paragraph.measure(prefix, item.style)[0]
165
+ cy = y + item.y
166
+ painter.line(cx, cy, cx, cy + item.height, [0, 0, 0, 255], thickness: 1)
167
+ end
168
+
169
+ def clickable?
170
+ true
171
+ end
172
+
173
+ # Find the link (if any) under this point and fire it.
174
+ def on_click(x, y, button)
175
+ return unless @paragraph
176
+
177
+ lx = x - @abs_x
178
+ ly = y - @abs_y
179
+ link = link_at(lx, ly)
180
+ link&.click!(x, y)
181
+ _ = button
182
+ end
183
+
184
+ def link_at(lx, ly)
185
+ owners = @paragraph.placed.map(&:owner).uniq.select { |o| o.is_a?(Link) }
186
+ owners.find do |owner|
187
+ @paragraph.boxes_for(owner).any? do |bx, by, bw, bh|
188
+ lx >= bx && lx < bx + bw && ly >= by && ly < by + bh
189
+ end
190
+ end
191
+ end
192
+
193
+ def cursor_index_at(x, y)
194
+ @paragraph&.index_at(x - @abs_x, y - @abs_y) || 0
195
+ end
196
+ end
197
+
198
+ class << Clogs
199
+ # text_items is an array of Strings and linkable IDs of TextDrawables.
200
+ # Resolve it into a flat list of [text, style] pairs.
201
+ def expand_text_items(items, style)
202
+ return [] if items.nil?
203
+
204
+ # An item is either literal text or the linkable id of a nested text
205
+ # drawable such as strong() or link(). Ids are not always Strings, so
206
+ # ask the display service about anything that could be one.
207
+ Array(items).flat_map do |item|
208
+ peer = DisplayService.instance&.query_display_drawable_for(item, nil_ok: true)
209
+ if peer.is_a?(TextDrawable)
210
+ peer.runs(style)
211
+ else
212
+ [[item.to_s, style]]
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Small pieces of the Shoes API that Lacci 0.5.0 does not implement but that
4
+ # real Shoes programs use constantly. Each is defined only if the installed
5
+ # Lacci lacks it, so newer versions win.
6
+
7
+ class Shoes::App
8
+ # `app.clear { ... }` empties the top-level slot and optionally refills it.
9
+ # Animated Shoes programs redraw themselves this way on every frame.
10
+ unless method_defined?(:clear)
11
+ def clear(&block)
12
+ root = instance_variable_get(:@document_root)
13
+ root&.clear(&block)
14
+ end
15
+ end
16
+
17
+ # `self.mouse` returns [button, x, y]. Clogs tracks the pointer as libui
18
+ # reports it.
19
+ unless method_defined?(:mouse)
20
+ def mouse
21
+ Clogs::App.instance&.mouse_state || [0, 0, 0]
22
+ end
23
+ end
24
+
25
+ # Shoes 3's `visit` opened a URL; without a browser widget the best we can do
26
+ # is hand it to the desktop.
27
+ unless method_defined?(:visit)
28
+ def visit(url)
29
+ opener = case RUBY_PLATFORM
30
+ when /darwin/ then "open"
31
+ when /mingw|mswin/ then "start"
32
+ else "xdg-open"
33
+ end
34
+ system(opener, url.to_s, out: File::NULL, err: File::NULL)
35
+ end
36
+ end
37
+
38
+ # Shoes 3 read and wrote the system clipboard through the app.
39
+ unless method_defined?(:clipboard)
40
+ def clipboard
41
+ Clogs::Clipboard.read
42
+ end
43
+
44
+ def clipboard=(text)
45
+ Clogs::Clipboard.write(text.to_s)
46
+ end
47
+ end
48
+ end
data/lib/clogs/log.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clogs
4
+ # Lacci insists on a logging implementation being plugged in before any Shoes
5
+ # object exists. Scarpe ships two (one backed by the `logging` gem, one
6
+ # simple); Clogs has its own so that a packaged app needs no logging
7
+ # dependency at all.
8
+ #
9
+ # Configure with SCARPE_LOG_LEVEL (debug/info/warn/error/fatal) or
10
+ # SCARPE_DEBUG=1.
11
+ class Log
12
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
13
+
14
+ def initialize(out = $stderr)
15
+ @out = out
16
+ @level = :warn
17
+ end
18
+
19
+ def configure_logger(config)
20
+ level = if ENV["SCARPE_LOG_LEVEL"]
21
+ ENV["SCARPE_LOG_LEVEL"].downcase.to_sym
22
+ elsif config.is_a?(Hash)
23
+ (config["default"] || config[:default] || "warn").to_s.to_sym
24
+ else
25
+ :warn
26
+ end
27
+ @level = LEVELS.key?(level) ? level : :warn
28
+ end
29
+
30
+ def logger_for_component(component)
31
+ Logger.new(self, component.to_s)
32
+ end
33
+
34
+ def emit(component, level, message)
35
+ return if LEVELS[level] < LEVELS[@level]
36
+
37
+ @out.puts("[#{level.to_s.upcase}] #{component}: #{message}")
38
+ end
39
+
40
+ # One of these per component, matching the interface Lacci expects.
41
+ class Logger
42
+ def initialize(sink, component)
43
+ @sink = sink
44
+ @component = component
45
+ end
46
+
47
+ LEVELS.each_key do |level|
48
+ define_method(level) do |message = nil, &block|
49
+ @sink.emit(@component, level, message || block&.call)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ui"
4
+
5
+ module Clogs
6
+ # A path under construction. libui aborts the process if a path is ended
7
+ # twice or drawn before being ended, so the state is tracked here rather
8
+ # than left to callers.
9
+ class Path
10
+ attr_reader :ptr
11
+
12
+ def initialize(fill_mode)
13
+ @ptr = UI::L.draw_new_path(fill_mode)
14
+ @ended = false
15
+ end
16
+
17
+ def move_to(x, y)
18
+ UI::L.draw_path_new_figure(@ptr, x, y)
19
+ self
20
+ end
21
+
22
+ def line_to(x, y)
23
+ UI::L.draw_path_line_to(@ptr, x, y)
24
+ self
25
+ end
26
+
27
+ def curve_to(c1x, c1y, c2x, c2y, x, y)
28
+ UI::L.draw_path_bezier_to(@ptr, c1x, c1y, c2x, c2y, x, y)
29
+ self
30
+ end
31
+
32
+ def arc_to(cx, cy, radius, start_angle, sweep, negative = false)
33
+ UI::L.draw_path_arc_to(@ptr, cx, cy, radius, start_angle, sweep, negative ? 1 : 0)
34
+ self
35
+ end
36
+
37
+ def arc_figure(cx, cy, radius, start_angle, sweep, negative = false)
38
+ UI::L.draw_path_new_figure_with_arc(@ptr, cx, cy, radius, start_angle, sweep, negative ? 1 : 0)
39
+ self
40
+ end
41
+
42
+ def rect(x, y, w, h)
43
+ UI::L.draw_path_add_rectangle(@ptr, x, y, w, h)
44
+ self
45
+ end
46
+
47
+ # Four beziers approximate an ellipse closely enough for UI work and avoid
48
+ # needing a scale matrix per oval (libui arcs are always circular).
49
+ K = 0.5522847498307936
50
+ def oval(x, y, w, h)
51
+ rx = w / 2.0
52
+ ry = h / 2.0
53
+ cx = x + rx
54
+ cy = y + ry
55
+ move_to(cx + rx, cy)
56
+ curve_to(cx + rx, cy + ry * K, cx + rx * K, cy + ry, cx, cy + ry)
57
+ curve_to(cx - rx * K, cy + ry, cx - rx, cy + ry * K, cx - rx, cy)
58
+ curve_to(cx - rx, cy - ry * K, cx - rx * K, cy - ry, cx, cy - ry)
59
+ curve_to(cx + rx * K, cy - ry, cx + rx, cy - ry * K, cx + rx, cy)
60
+ close
61
+ end
62
+
63
+ def close
64
+ UI::L.draw_path_close_figure(@ptr)
65
+ self
66
+ end
67
+
68
+ def end!
69
+ return self if @ended
70
+
71
+ @ended = true
72
+ UI::L.draw_path_end(@ptr)
73
+ self
74
+ end
75
+
76
+ def free
77
+ UI::L.draw_free_path(@ptr) if @ptr
78
+ @ptr = nil
79
+ end
80
+ end
81
+
82
+ # An immediate-mode drawing surface over a uiDrawContext.
83
+ #
84
+ # Shoes is a canvas library: everything from a `rect` to a `button` is
85
+ # ultimately painted. libui's draw API is closely modelled on Cairo (paths,
86
+ # brushes, matrices, clipping, save/restore), so the mapping is direct.
87
+ class Painter
88
+ WINDING = UI::WINDING
89
+ ALTERNATE = UI::ALTERNATE
90
+
91
+ attr_reader :context, :width, :height
92
+
93
+ def initialize(context, width, height)
94
+ @context = context
95
+ @width = width
96
+ @height = height
97
+ end
98
+
99
+ # libui has no "unclip" and no way to read back the current transform, so
100
+ # every clip or transform must be scoped by save/restore.
101
+ def save
102
+ UI::L.draw_save(@context)
103
+ yield self
104
+ ensure
105
+ UI::L.draw_restore(@context)
106
+ end
107
+
108
+ def translate(x, y)
109
+ return if x.zero? && y.zero?
110
+
111
+ apply_matrix { |m| UI::L.draw_matrix_translate(m, x, y) }
112
+ end
113
+
114
+ def rotate(degrees, cx = 0, cy = 0)
115
+ apply_matrix { |m| UI::L.draw_matrix_rotate(m, cx, cy, degrees * Math::PI / 180.0) }
116
+ end
117
+
118
+ def scale(sx, sy, cx = 0, cy = 0)
119
+ apply_matrix { |m| UI::L.draw_matrix_scale(m, cx, cy, sx, sy) }
120
+ end
121
+
122
+ def skew(ax, ay, cx = 0, cy = 0)
123
+ apply_matrix { |m| UI::L.draw_matrix_skew(m, cx, cy, ax * Math::PI / 180.0, ay * Math::PI / 180.0) }
124
+ end
125
+
126
+ def clip_rect(x, y, w, h)
127
+ p = Path.new(WINDING)
128
+ p.rect(x, y, w, h).end!
129
+ UI::L.draw_clip(@context, p.ptr)
130
+ ensure
131
+ p&.free
132
+ end
133
+
134
+ def fill_rect(x, y, w, h, paint)
135
+ return if w <= 0 || h <= 0
136
+
137
+ draw(fill: paint) { |p| p.rect(x, y, w, h) }
138
+ end
139
+
140
+ def stroke_rect(x, y, w, h, paint, thickness: 1)
141
+ draw(stroke: paint, thickness: thickness) { |p| p.rect(x, y, w, h) }
142
+ end
143
+
144
+ def fill_oval(x, y, w, h, paint)
145
+ draw(fill: paint) { |p| p.oval(x, y, w, h) }
146
+ end
147
+
148
+ def stroke_oval(x, y, w, h, paint, thickness: 1)
149
+ draw(stroke: paint, thickness: thickness) { |p| p.oval(x, y, w, h) }
150
+ end
151
+
152
+ def line(x1, y1, x2, y2, paint, thickness: 1, cap: UI::CAP_FLAT)
153
+ draw(stroke: paint, thickness: thickness, cap: cap) do |p|
154
+ p.move_to(x1, y1).line_to(x2, y2)
155
+ end
156
+ end
157
+
158
+ # The workhorse: build a path in the block, then fill and/or stroke it.
159
+ def draw(fill: nil, stroke: nil, thickness: 1, cap: UI::CAP_FLAT, join: UI::JOIN_MITER,
160
+ dashes: nil, fill_mode: WINDING)
161
+ p = Path.new(fill_mode)
162
+ yield p
163
+ p.end!
164
+ UI::L.draw_fill(@context, p.ptr, brush_for(fill).to_ptr) if fill
165
+ if stroke
166
+ sp = UI.stroke_params(thickness, cap: cap, join: join, dashes: dashes)
167
+ UI::L.draw_stroke(@context, p.ptr, brush_for(stroke).to_ptr, sp.to_ptr)
168
+ end
169
+ ensure
170
+ p&.free
171
+ end
172
+
173
+ def draw_text(layout, x, y)
174
+ UI::L.draw_text(@context, layout, x, y)
175
+ end
176
+
177
+ private
178
+
179
+ def brush_for(paint)
180
+ return paint if paint.respond_to?(:Type)
181
+
182
+ UI.solid_brush(paint)
183
+ end
184
+
185
+ def apply_matrix
186
+ m = UI.malloc(UI::L::FFI::DrawMatrix)
187
+ UI::L.draw_matrix_set_identity(m)
188
+ yield m
189
+ UI::L.draw_transform(@context, m)
190
+ end
191
+ end
192
+ end