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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/examples/kitchen_sink.rb +65 -0
- data/lib/clogs/app.rb +398 -0
- data/lib/clogs/canvas.rb +129 -0
- data/lib/clogs/clipboard.rb +56 -0
- data/lib/clogs/dialogs.rb +136 -0
- data/lib/clogs/display_service.rb +109 -0
- data/lib/clogs/drawable.rb +182 -0
- data/lib/clogs/drawables/controls.rb +568 -0
- data/lib/clogs/drawables/image.rb +198 -0
- data/lib/clogs/drawables/misc.rb +125 -0
- data/lib/clogs/drawables/shapes.rb +302 -0
- data/lib/clogs/drawables/slot.rb +128 -0
- data/lib/clogs/drawables/text.rb +217 -0
- data/lib/clogs/lacci_compat.rb +48 -0
- data/lib/clogs/log.rb +54 -0
- data/lib/clogs/painter.rb +192 -0
- data/lib/clogs/paragraph.rb +218 -0
- data/lib/clogs/style.rb +124 -0
- data/lib/clogs/text.rb +132 -0
- data/lib/clogs/ui.rb +157 -0
- data/lib/clogs/version.rb +5 -0
- data/lib/clogs.rb +92 -0
- metadata +109 -0
|
@@ -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
|
data/lib/clogs/style.rb
ADDED
|
@@ -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
|