asobi 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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Colours are plain values: a CSS colour name as a Symbol (:crimson), or a
5
+ # String such as "#ff0066", "hsl(200, 70%, 50%)" or "rgb(10, 20, 30)".
6
+ # This module checks them, turns them into SVG paint, and mixes them.
7
+ module Color
8
+ # The CSS named colours (CSS Color Module Level 4), name => [r, g, b].
9
+ NAMES = {
10
+ "aliceblue" => [240, 248, 255], "antiquewhite" => [250, 235, 215], "aqua" => [0, 255, 255],
11
+ "aquamarine" => [127, 255, 212], "azure" => [240, 255, 255], "beige" => [245, 245, 220],
12
+ "bisque" => [255, 228, 196], "black" => [0, 0, 0], "blanchedalmond" => [255, 235, 205],
13
+ "blue" => [0, 0, 255], "blueviolet" => [138, 43, 226], "brown" => [165, 42, 42],
14
+ "burlywood" => [222, 184, 135], "cadetblue" => [95, 158, 160], "chartreuse" => [127, 255, 0],
15
+ "chocolate" => [210, 105, 30], "coral" => [255, 127, 80], "cornflowerblue" => [100, 149, 237],
16
+ "cornsilk" => [255, 248, 220], "crimson" => [220, 20, 60], "cyan" => [0, 255, 255],
17
+ "darkblue" => [0, 0, 139], "darkcyan" => [0, 139, 139], "darkgoldenrod" => [184, 134, 11],
18
+ "darkgray" => [169, 169, 169], "darkgreen" => [0, 100, 0], "darkgrey" => [169, 169, 169],
19
+ "darkkhaki" => [189, 183, 107], "darkmagenta" => [139, 0, 139], "darkolivegreen" => [85, 107, 47],
20
+ "darkorange" => [255, 140, 0], "darkorchid" => [153, 50, 204], "darkred" => [139, 0, 0],
21
+ "darksalmon" => [233, 150, 122], "darkseagreen" => [143, 188, 143], "darkslateblue" => [72, 61, 139],
22
+ "darkslategray" => [47, 79, 79], "darkslategrey" => [47, 79, 79], "darkturquoise" => [0, 206, 209],
23
+ "darkviolet" => [148, 0, 211], "deeppink" => [255, 20, 147], "deepskyblue" => [0, 191, 255],
24
+ "dimgray" => [105, 105, 105], "dimgrey" => [105, 105, 105], "dodgerblue" => [30, 144, 255],
25
+ "firebrick" => [178, 34, 34], "floralwhite" => [255, 250, 240], "forestgreen" => [34, 139, 34],
26
+ "fuchsia" => [255, 0, 255], "gainsboro" => [220, 220, 220], "ghostwhite" => [248, 248, 255],
27
+ "gold" => [255, 215, 0], "goldenrod" => [218, 165, 32], "gray" => [128, 128, 128],
28
+ "green" => [0, 128, 0], "greenyellow" => [173, 255, 47], "grey" => [128, 128, 128],
29
+ "honeydew" => [240, 255, 240], "hotpink" => [255, 105, 180], "indianred" => [205, 92, 92],
30
+ "indigo" => [75, 0, 130], "ivory" => [255, 255, 240], "khaki" => [240, 230, 140],
31
+ "lavender" => [230, 230, 250], "lavenderblush" => [255, 240, 245], "lawngreen" => [124, 252, 0],
32
+ "lemonchiffon" => [255, 250, 205], "lightblue" => [173, 216, 230], "lightcoral" => [240, 128, 128],
33
+ "lightcyan" => [224, 255, 255], "lightgoldenrodyellow" => [250, 250, 210], "lightgray" => [211, 211, 211],
34
+ "lightgreen" => [144, 238, 144], "lightgrey" => [211, 211, 211], "lightpink" => [255, 182, 193],
35
+ "lightsalmon" => [255, 160, 122], "lightseagreen" => [32, 178, 170], "lightskyblue" => [135, 206, 250],
36
+ "lightslategray" => [119, 136, 153], "lightslategrey" => [119, 136, 153], "lightsteelblue" => [176, 196, 222],
37
+ "lightyellow" => [255, 255, 224], "lime" => [0, 255, 0], "limegreen" => [50, 205, 50],
38
+ "linen" => [250, 240, 230], "magenta" => [255, 0, 255], "maroon" => [128, 0, 0],
39
+ "mediumaquamarine" => [102, 205, 170], "mediumblue" => [0, 0, 205], "mediumorchid" => [186, 85, 211],
40
+ "mediumpurple" => [147, 112, 219], "mediumseagreen" => [60, 179, 113], "mediumslateblue" => [123, 104, 238],
41
+ "mediumspringgreen" => [0, 250, 154], "mediumturquoise" => [72, 209, 204], "mediumvioletred" => [199, 21, 133],
42
+ "midnightblue" => [25, 25, 112], "mintcream" => [245, 255, 250], "mistyrose" => [255, 228, 225],
43
+ "moccasin" => [255, 228, 181], "navajowhite" => [255, 222, 173], "navy" => [0, 0, 128],
44
+ "oldlace" => [253, 245, 230], "olive" => [128, 128, 0], "olivedrab" => [107, 142, 35],
45
+ "orange" => [255, 165, 0], "orangered" => [255, 69, 0], "orchid" => [218, 112, 214],
46
+ "palegoldenrod" => [238, 232, 170], "palegreen" => [152, 251, 152], "paleturquoise" => [175, 238, 238],
47
+ "palevioletred" => [219, 112, 147], "papayawhip" => [255, 239, 213], "peachpuff" => [255, 218, 185],
48
+ "peru" => [205, 133, 63], "pink" => [255, 192, 203], "plum" => [221, 160, 221],
49
+ "powderblue" => [176, 224, 230], "purple" => [128, 0, 128], "rebeccapurple" => [102, 51, 153],
50
+ "red" => [255, 0, 0], "rosybrown" => [188, 143, 143], "royalblue" => [65, 105, 225],
51
+ "saddlebrown" => [139, 69, 19], "salmon" => [250, 128, 114], "sandybrown" => [244, 164, 96],
52
+ "seagreen" => [46, 139, 87], "seashell" => [255, 245, 238], "sienna" => [160, 82, 45],
53
+ "silver" => [192, 192, 192], "skyblue" => [135, 206, 235], "slateblue" => [106, 90, 205],
54
+ "slategray" => [112, 128, 144], "slategrey" => [112, 128, 144], "snow" => [255, 250, 250],
55
+ "springgreen" => [0, 255, 127], "steelblue" => [70, 130, 180], "tan" => [210, 180, 140],
56
+ "teal" => [0, 128, 128], "thistle" => [216, 191, 216], "tomato" => [255, 99, 71],
57
+ "turquoise" => [64, 224, 208], "violet" => [238, 130, 238], "wheat" => [245, 222, 179],
58
+ "white" => [255, 255, 255], "whitesmoke" => [245, 245, 245], "yellow" => [255, 255, 0],
59
+ "yellowgreen" => [154, 205, 50]
60
+ }.freeze
61
+
62
+ NUMBER = /\s*(-?\d+(?:\.\d+)?|-?\.\d+)\s*/
63
+ HEX = /\A#(\h{3}|\h{4}|\h{6}|\h{8})\z/
64
+ HSL = /\Ahsla?\(#{NUMBER}(?:deg)?,#{NUMBER}%,#{NUMBER}%(?:,#{NUMBER}(%?))?\)\z/i
65
+ RGB = /\Argba?\(#{NUMBER}(%?),#{NUMBER}(%?),#{NUMBER}(%?)(?:,#{NUMBER}(%?))?\)\z/i
66
+
67
+ module_function
68
+
69
+ # SVG paint for a colour value: "crimson", "#ff0066", "none" ...
70
+ # Unknown colours raise a teaching error.
71
+ def css(value)
72
+ return "none" if value.nil? || value == false
73
+
74
+ name = value.to_s.strip
75
+ key = name.downcase.delete("_ ")
76
+ return key if NAMES.key?(key) || %w[none transparent currentcolor].include?(key)
77
+ return name if value.is_a?(String) && rgba(name)
78
+
79
+ hint = suggestion(key)
80
+ raise Messages.error(ArgumentError, :bad_color, got: value.inspect, hint:)
81
+ end
82
+
83
+ # [r, g, b, alpha] with r, g, b in 0..255 and alpha in 0..1, or nil.
84
+ def rgba(value)
85
+ text = value.to_s.strip
86
+ key = text.downcase.delete("_ ")
87
+ return [*NAMES[key], 1.0] if NAMES.key?(key)
88
+ return [0, 0, 0, 0.0] if key == "transparent"
89
+
90
+ if (m = HEX.match(text))
91
+ digits = m[1]
92
+ digits = digits.chars.map { _1 * 2 }.join if digits.size <= 4
93
+ values = digits.scan(/../).map { _1.to_i(16) }
94
+ return [*values[0, 3], values.size == 4 ? values[3] / 255.0 : 1.0]
95
+ end
96
+ if (m = HSL.match(text))
97
+ r, g, b = hsl_to_rgb(m[1].to_f, m[2].to_f, m[3].to_f)
98
+ return [r, g, b, alpha(m[4], m[5])]
99
+ end
100
+ if (m = RGB.match(text))
101
+ r, g, b = [[m[1], m[2]], [m[3], m[4]], [m[5], m[6]]].map do |number, percent|
102
+ (percent == "%" ? number.to_f * 2.55 : number.to_f).clamp(0, 255).round
103
+ end
104
+ return [r, g, b, alpha(m[7], m[8])]
105
+ end
106
+ nil
107
+ end
108
+
109
+ # [r, g, b] (0..255), or nil for "none".
110
+ def rgb(value) = rgba(css(value))&.first(3)
111
+
112
+ # Halfway between two colours is mix(a, b, 0.5). Answers "#rrggbb", or
113
+ # "rgba(...)" when either colour is see-through.
114
+ def mix(from, to, t)
115
+ a = rgba(css(from)) || raise(Messages.error(ArgumentError, :bad_color, got: from.inspect, hint: ""))
116
+ b = rgba(css(to)) || raise(Messages.error(ArgumentError, :bad_color, got: to.inspect, hint: ""))
117
+ t = t.to_f.clamp(0.0, 1.0)
118
+ r, g, bl, al = a.zip(b).map { |x, y| x + (y - x) * t }
119
+ return format("#%02x%02x%02x", r.round, g.round, bl.round) if al >= 1.0
120
+
121
+ "rgba(#{r.round}, #{g.round}, #{bl.round}, #{Asobi.fmt(al)})"
122
+ end
123
+
124
+ # hue in degrees (0..360), saturation and lightness in 0..100.
125
+ def hsl(hue, saturation = 70, lightness = 50)
126
+ %w[hue saturation lightness].zip([hue, saturation, lightness]).each do |name, v|
127
+ Check.number(v, word: :hsl, name:, example: "hsl(200, 70, 50)")
128
+ end
129
+ h = Asobi.fmt(hue % 360)
130
+ s = Asobi.fmt(saturation.clamp(0, 100))
131
+ l = Asobi.fmt(lightness.clamp(0, 100))
132
+ "hsl(#{h}, #{s}%, #{l}%)"
133
+ end
134
+
135
+ # 0 is black, 100 is white.
136
+ def gray(lightness = 50)
137
+ Check.number(lightness, word: :gray, name: :lightness, example: "gray(80)")
138
+ hsl(0, 0, lightness)
139
+ end
140
+
141
+ def hsl_to_rgb(h, s, l)
142
+ h = (h % 360) / 360.0
143
+ s = (s / 100.0).clamp(0.0, 1.0)
144
+ l = (l / 100.0).clamp(0.0, 1.0)
145
+ return [(l * 255).round] * 3 if s.zero?
146
+
147
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
148
+ p = 2 * l - q
149
+ [h + 1 / 3.0, h, h - 1 / 3.0].map do |t|
150
+ t += 1 if t.negative?
151
+ t -= 1 if t > 1
152
+ v =
153
+ if t < 1 / 6.0 then p + (q - p) * 6 * t
154
+ elsif t < 1 / 2.0 then q
155
+ elsif t < 2 / 3.0 then p + (q - p) * (2 / 3.0 - t) * 6
156
+ else p
157
+ end
158
+ (v * 255).round
159
+ end
160
+ end
161
+
162
+ def alpha(number, percent)
163
+ return 1.0 unless number
164
+
165
+ (percent == "%" ? number.to_f / 100 : number.to_f).clamp(0.0, 1.0)
166
+ end
167
+
168
+ def suggestion(key)
169
+ return "" if key.empty? || !defined?(DidYouMean::SpellChecker)
170
+
171
+ found = DidYouMean::SpellChecker.new(dictionary: NAMES.keys).correct(key).first
172
+ found ? Messages.text(:did_you_mean, name: found) : ""
173
+ end
174
+ end
175
+ end
176
+
177
+ module Asobi
178
+ # A gradient, used as a colour: colored(from: :gold, to: :crimson).
179
+ #
180
+ # +angle+ is in degrees, clockwise: 0 flows from top to bottom, 90 from
181
+ # right to left (the gradient turned a quarter clockwise). +around+ makes
182
+ # it flow from the centre outwards instead. Where it starts and ends is
183
+ # worked out when drawing, from the box of the picture it paints.
184
+ Gradient = Data.define(:from, :to, :angle, :around) do
185
+ def self.make(from, to, angle, around)
186
+ Asobi::Check.number(angle, word: :colored, name: :angle, example: "colored(from: :gold, to: :crimson, angle: 90)")
187
+ new(Asobi::Color.css(from), Asobi::Color.css(to), Asobi::Gradient.tidy(angle.to_f % 360), around ? true : false)
188
+ end
189
+
190
+ def self.tidy(degrees)
191
+ rounded = degrees.round(9)
192
+ rounded = 0.0 if rounded.zero? || rounded == 360.0
193
+ rounded
194
+ end
195
+
196
+ # The same gradient seen through +matrix+ (only its turning and
197
+ # stretching matter).
198
+ def through(matrix)
199
+ return self if around
200
+
201
+ radians = angle * Math::PI / 180
202
+ x, y = matrix.linear.apply(-Math.sin(radians), Math.cos(radians))
203
+ return self if x.abs < 1e-12 && y.abs < 1e-12
204
+
205
+ turned = Math.atan2(-x, y) * 180 / Math::PI
206
+ with(angle: Asobi::Gradient.tidy(turned % 360))
207
+ end
208
+
209
+ # The <linearGradient> or <radialGradient> for a Box (in the
210
+ # coordinates of the picture it paints), without its id.
211
+ def definition(box)
212
+ stops = %(<stop offset="0" stop-color="#{Asobi.escape(from)}"/>) +
213
+ %(<stop offset="1" stop-color="#{Asobi.escape(to)}"/>)
214
+ box ||= Asobi::Box.new(-1.0, -1.0, 1.0, 1.0)
215
+ if around
216
+ r = [[box.width, box.height].max / 2.0, 1e-3].max
217
+ return %(<radialGradient gradientUnits="userSpaceOnUse" cx="#{Asobi.fmt(box.cx)}" cy="#{Asobi.fmt(box.cy)}" ) +
218
+ %(r="#{Asobi.fmt(r)}">#{stops}</radialGradient>)
219
+ end
220
+
221
+ radians = angle * Math::PI / 180
222
+ dx = Asobi::Affine.tidy(-Math.sin(radians))
223
+ dy = Asobi::Affine.tidy(Math.cos(radians))
224
+ half = (dx.abs * box.width + dy.abs * box.height) / 2.0
225
+ if half < 1e-6 # a flat line across the flow: let it flow along the line
226
+ dx, dy = box.width >= box.height ? [1.0, 0.0] : [0.0, 1.0]
227
+ half = [box.width, box.height].max / 2.0
228
+ half = 1.0 if half < 1e-6
229
+ end
230
+ x1, y1 = box.cx - dx * half, box.cy - dy * half
231
+ x2, y2 = box.cx + dx * half, box.cy + dy * half
232
+ %(<linearGradient gradientUnits="userSpaceOnUse" x1="#{Asobi.fmt(x1)}" y1="#{Asobi.fmt(y1)}" ) +
233
+ %(x2="#{Asobi.fmt(x2)}" y2="#{Asobi.fmt(y2)}">#{stops}</linearGradient>)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pure"
4
+
5
+ # to_art is the new to_s.
6
+ #
7
+ # Ruby objects already know how to become a String (to_s), an Array (to_a),
8
+ # a Proc (to_proc). Here they learn to become a picture. The rules live in
9
+ # Asobi::Convert; these methods only open the door.
10
+ module Asobi
11
+ module CoreExt
12
+ module ToArt
13
+ # 8.to_art, [3, 1, 4].to_art(as: star)
14
+ def to_art(as: nil) = Asobi.art(self, as:)
15
+ end
16
+
17
+ module Turn
18
+ # Angles in turns: 0.25.turn is 90.0 degrees.
19
+ def turn = self * 360.0
20
+ alias_method :turns, :turn
21
+ end
22
+ end
23
+ end
24
+
25
+ [Integer, Float, Rational, Array, Range, Hash, String, Symbol, NilClass].each do |klass|
26
+ klass.include(Asobi::CoreExt::ToArt)
27
+ end
28
+ Numeric.include(Asobi::CoreExt::Turn)
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # 2D affine transform: x' = a*x + c*y + e, y' = b*x + d*y + f
5
+ #
6
+ # Coordinates are SVG's: x to the right, y downwards. With y pointing down
7
+ # the usual rotation matrix turns things clockwise, which is what
8
+ # rotate(45) promises.
9
+ Affine = Data.define(:a, :b, :c, :d, :e, :f) do
10
+ def self.translate(x, y) = new(1.0, 0.0, 0.0, 1.0, x.to_f, y.to_f)
11
+ def self.scale(sx, sy = sx) = new(sx.to_f, 0.0, 0.0, sy.to_f, 0.0, 0.0)
12
+
13
+ # Clockwise, in degrees. Quarter turns are exact, so rotate(90) four
14
+ # times gives back exactly the same picture.
15
+ def self.rotate(degrees)
16
+ radians = degrees * Math::PI / 180
17
+ cos = Asobi::Affine.tidy(Math.cos(radians))
18
+ sin = Asobi::Affine.tidy(Math.sin(radians))
19
+ new(cos, sin, -sin, cos, 0.0, 0.0)
20
+ end
21
+
22
+ def self.tidy(value) = value.abs < 1e-12 ? 0.0 : value
23
+
24
+ # Composition: (self * other) applies +other+ first, then +self+.
25
+ def *(other)
26
+ Affine.new(
27
+ a * other.a + c * other.b,
28
+ b * other.a + d * other.b,
29
+ a * other.c + c * other.d,
30
+ b * other.c + d * other.d,
31
+ a * other.e + c * other.f + e,
32
+ b * other.e + d * other.f + f
33
+ )
34
+ end
35
+
36
+ def apply(x, y) = [a * x + c * y + e, b * x + d * y + f]
37
+
38
+ # Only the turning and stretching part (no moving): for directions.
39
+ def linear = Affine.new(a, b, c, d, 0.0, 0.0)
40
+
41
+ # The linear part undone (the identity when it can't be undone).
42
+ def inverse_linear
43
+ det = a * d - b * c
44
+ return IDENTITY if det.abs < 1e-12
45
+
46
+ Affine.new(d / det, -b / det, -c / det, a / det, 0.0, 0.0)
47
+ end
48
+
49
+ # How much this transform stretches lengths, on average (for strokes).
50
+ def stretch = Math.sqrt((a * d - b * c).abs)
51
+
52
+ def identity? = self == IDENTITY
53
+ def translation? = a == 1.0 && b.zero? && c.zero? && d == 1.0
54
+ end
55
+
56
+ IDENTITY = Affine.new(1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
57
+
58
+ # Axis-aligned bounding box, in picture coordinates (y down).
59
+ Box = Data.define(:x0, :y0, :x1, :y1) do
60
+ def self.around(points)
61
+ xs = points.map(&:first)
62
+ ys = points.map(&:last)
63
+ new(xs.min.to_f, ys.min.to_f, xs.max.to_f, ys.max.to_f)
64
+ end
65
+
66
+ # The box around a rectangle w x h centred on the origin, transformed.
67
+ def self.of_rect(w, h, m)
68
+ x = w / 2.0
69
+ y = h / 2.0
70
+ around([[-x, -y], [x, -y], [x, y], [-x, y]].map { m.apply(*_1) })
71
+ end
72
+
73
+ def self.union(boxes)
74
+ boxes = boxes.compact
75
+ return nil if boxes.empty?
76
+
77
+ new(boxes.map(&:x0).min, boxes.map(&:y0).min, boxes.map(&:x1).max, boxes.map(&:y1).max)
78
+ end
79
+
80
+ def width = x1 - x0
81
+ def height = y1 - y0
82
+ def cx = (x0 + x1) / 2.0
83
+ def cy = (y0 + y1) / 2.0
84
+ def grow(n) = Box.new(x0 - n, y0 - n, x1 + n, y1 + n)
85
+
86
+ # Strictly overlapping (touching edges do not count).
87
+ def overlap?(other) = x0 < other.x1 && other.x0 < x1 && y0 < other.y1 && other.y0 < y1
88
+
89
+ # [x, y, width, height], the order SVG's viewBox uses.
90
+ def to_a = [x0, y0, width, height]
91
+ end
92
+
93
+ module Geometry
94
+ module_function
95
+
96
+ # The extent of a cubic Bézier along one axis: its end points plus any
97
+ # turning points in between. p0..p3 are numbers (one coordinate).
98
+ def cubic_extent(p0, p1, p2, p3)
99
+ values = [p0, p3]
100
+ # Derivative / 3 is a quadratic qa t^2 + qb t + qc
101
+ qa = -p0 + 3 * p1 - 3 * p2 + p3
102
+ qb = 2 * (p0 - 2 * p1 + p2)
103
+ qc = p1 - p0
104
+ roots =
105
+ if qa.abs < 1e-12
106
+ qb.abs < 1e-12 ? [] : [-qc / qb]
107
+ else
108
+ disc = qb * qb - 4 * qa * qc
109
+ disc.negative? ? [] : [(-qb + Math.sqrt(disc)) / (2 * qa), (-qb - Math.sqrt(disc)) / (2 * qa)]
110
+ end
111
+ roots.each do |t|
112
+ next unless t > 0 && t < 1
113
+
114
+ u = 1 - t
115
+ values << u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
116
+ end
117
+ values.minmax
118
+ end
119
+
120
+ # A point at +distance+ from the origin, +degrees+ clockwise from straight up.
121
+ def polar(distance, degrees)
122
+ radians = degrees * Math::PI / 180
123
+ [Affine.tidy(distance * Math.sin(radians)), Affine.tidy(-distance * Math.cos(radians))]
124
+ end
125
+ end
126
+ end
data/lib/asobi/live.rb ADDED
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+ require "rbconfig"
6
+ require_relative "sketch"
7
+
8
+ module Asobi
9
+ Say.add(:live_ready,
10
+ en: "asobi: %{file} is playing at %{url} - save the file to change the picture, Ctrl-C to stop",
11
+ ja: "asobi: %{file} を %{url} に表示しています。ファイルを保存すると絵が変わります。止めるには Ctrl-C")
12
+ Say.add(:live_ready_irb,
13
+ en: "asobi: playing at %{url} - every show changes the picture",
14
+ ja: "asobi: %{url} に表示しています。show するたびに絵が変わります")
15
+ Say.add(:live_bye, en: "asobi: bye!", ja: "asobi: またね")
16
+ Messages.add(:live_no_port,
17
+ en: "asobi couldn't find a free port from %{from} to %{to}. Close the other asobi, or pick one, like: asobi --port 5000",
18
+ ja: "%{from} から %{to} までの空いているポートが見つかりません。ほかの asobi を閉じるか、ポートを選んでください。例:asobi --port 5000")
19
+ Messages.add(:live_invalid_reel,
20
+ en: 'asobi could not send this sketch to the browser. Use UTF-8 for the file and its text, and finite numbers for drawing, like: show text("hello", size: 24)',
21
+ ja: 'このスケッチをブラウザに送れませんでした。ファイルと文字列には UTF-8 を使い、描画には有限の数を指定してください。例:show text("こんにちは", size: 24)')
22
+
23
+ # The live view: a tiny HTTP server (standard library only) that serves the
24
+ # player page and streams Reels to every open browser tab (Server-Sent
25
+ # Events). This is the only file that uses sockets and threads.
26
+ module Live
27
+ HOST = "127.0.0.1"
28
+ DEFAULT_PORT = 4567
29
+ PORT_TRIES = 20
30
+ HEARTBEAT = 15 # seconds between keep-alive comments
31
+ POLL = 0.2 # seconds between looks at the sketch file
32
+ PAGE = File.join(__dir__, "web", "live.html")
33
+ PLACEHOLDER = "/*ASOBI_PLAYER_JS*/"
34
+
35
+ module_function
36
+
37
+ # Blocking. Evaluate the sketch, serve it, and follow the file until
38
+ # Ctrl-C (or until the server is stopped). Yields the server once it is
39
+ # ready. +initial+ is a Reel already made from this run of the script.
40
+ def run(path, port: DEFAULT_PORT, open: true, initial: nil, out: $stdout)
41
+ path = File.expand_path(path.to_s)
42
+ server = Server.new(port:)
43
+ seen = stamp(path)
44
+ server.push(initial || Sketch.evaluate(path))
45
+ out.puts Say.text(:live_ready, file: File.basename(path), url: server.url)
46
+ out.flush
47
+ open_browser(server.url) if open
48
+ yield server if block_given?
49
+ watch(path, server, seen)
50
+ rescue Interrupt
51
+ out.puts
52
+ out.puts Say.text(:live_bye)
53
+ ensure
54
+ server&.stop
55
+ end
56
+
57
+ # Non-blocking, for irb: the server object (push, stop, url).
58
+ def start(port: DEFAULT_PORT, open: true, announce: false, out: $stdout)
59
+ server = Server.new(port:)
60
+ if announce
61
+ out.puts Say.text(:live_ready_irb, url: server.url)
62
+ out.flush
63
+ end
64
+ open_browser(server.url) if open
65
+ server
66
+ end
67
+
68
+ # Poll the file; when it changes (and has settled), evaluate and push.
69
+ def watch(path, server, seen = stamp(path))
70
+ until server.stopped?
71
+ sleep POLL
72
+ now = stamp(path)
73
+ next if now.nil? || now == seen
74
+
75
+ loop do # wait until the editor has finished writing
76
+ sleep 0.05
77
+ later = stamp(path)
78
+ break if later == now || later.nil?
79
+
80
+ now = later
81
+ end
82
+ seen = now
83
+ server.push(Sketch.evaluate(path))
84
+ end
85
+ end
86
+
87
+ def stamp(path)
88
+ stat = File.stat(path)
89
+ [stat.mtime, stat.size]
90
+ rescue SystemCallError
91
+ nil # briefly missing while an editor saves
92
+ end
93
+
94
+ def open_browser(url)
95
+ return if ENV["ASOBI_BROWSER"] == "0"
96
+
97
+ command =
98
+ case RbConfig::CONFIG["host_os"]
99
+ when /darwin/ then ["open", url]
100
+ when /mswin|mingw|cygwin/ then ["cmd", "/c", "start", "", url]
101
+ else ["xdg-open", url]
102
+ end
103
+ pid = Process.spawn(*command, out: File::NULL, err: File::NULL)
104
+ Process.detach(pid)
105
+ rescue SystemCallError
106
+ nil # no browser to open; the URL was printed
107
+ end
108
+
109
+ def page
110
+ @page ||= File.read(PAGE, encoding: "UTF-8").sub(PLACEHOLDER) { Reel.player_js }
111
+ end
112
+
113
+ class Server
114
+ attr_reader :port
115
+
116
+ def initialize(port: DEFAULT_PORT)
117
+ @mutex = Mutex.new
118
+ @clients = []
119
+ @reel = nil
120
+ @good = nil
121
+ @stopped = false
122
+ @listener = listen(port)
123
+ @port = @listener.addr[1]
124
+ @thread = Thread.new { accept_loop }
125
+ @thread.report_on_exception = false
126
+ end
127
+
128
+ def url = "http://#{HOST}:#{port}/"
129
+ def stopped? = @stopped
130
+
131
+ # Show this Reel (a Hash) in every open tab, now and for new tabs.
132
+ def push(reel)
133
+ begin
134
+ json = JSON.generate(reel)
135
+ rescue JSON::GeneratorError
136
+ code = reel["code"]&.encode("UTF-8", invalid: :replace, undef: :replace)
137
+ reel = Reel.error(Messages.text(:live_invalid_reel), code:)
138
+ json = JSON.generate(reel)
139
+ end
140
+ @mutex.synchronize do
141
+ @reel = json
142
+ @good = json unless reel["error"]
143
+ @clients.each { _1.queue << json }
144
+ end
145
+ reel
146
+ end
147
+
148
+ def stop
149
+ return if @stopped
150
+
151
+ @stopped = true
152
+ @listener.close unless @listener.closed?
153
+ @thread.kill
154
+ @mutex.synchronize { @clients.each { _1.queue << :stop } }
155
+ rescue IOError, SystemCallError
156
+ nil
157
+ end
158
+
159
+ private
160
+
161
+ Client = Struct.new(:socket, :queue)
162
+
163
+ def listen(port)
164
+ return TCPServer.new(HOST, port) if port.zero?
165
+
166
+ port.upto(port + PORT_TRIES) do |candidate|
167
+ next if taken?(candidate)
168
+
169
+ begin
170
+ return TCPServer.new(HOST, candidate)
171
+ rescue Errno::EADDRINUSE, Errno::EACCES
172
+ next
173
+ end
174
+ end
175
+ raise Messages.error(IOError, :live_no_port, from: port, to: port + PORT_TRIES)
176
+ end
177
+
178
+ # Something already answers there (even when binding would not fail).
179
+ def taken?(port)
180
+ Socket.tcp(HOST, port, connect_timeout: 0.3) { true }
181
+ rescue SystemCallError, IOError
182
+ false
183
+ end
184
+
185
+ def accept_loop
186
+ loop do
187
+ socket = @listener.accept
188
+ thread = Thread.new(socket) { |s| serve(s) }
189
+ thread.report_on_exception = false
190
+ end
191
+ rescue IOError, SystemCallError
192
+ nil
193
+ end
194
+
195
+ def serve(socket)
196
+ request = read_request(socket)
197
+ case request
198
+ when %r{\AGET / }, %r{\AGET /\?\S* } then respond(socket, "200 OK", "text/html; charset=utf-8", Live.page)
199
+ when %r{\AGET /events[ ?]} then stream(socket)
200
+ else respond(socket, "404 Not Found", "text/plain; charset=utf-8", "not found\n")
201
+ end
202
+ rescue IOError, SystemCallError
203
+ nil
204
+ ensure
205
+ socket.close unless socket.closed?
206
+ end
207
+
208
+ # The request line; the headers are read and dropped.
209
+ def read_request(socket)
210
+ return nil unless IO.select([socket], nil, nil, 5)
211
+
212
+ first = socket.gets("\n", 4096).to_s
213
+ 32.times do
214
+ break unless IO.select([socket], nil, nil, 2)
215
+
216
+ line = socket.gets("\n", 8192)
217
+ break if line.nil? || line.strip.empty?
218
+ end
219
+ first
220
+ end
221
+
222
+ def respond(socket, status, type, body)
223
+ body = body.b
224
+ socket.write "HTTP/1.1 #{status}\r\nContent-Type: #{type}\r\nContent-Length: #{body.bytesize}\r\n" \
225
+ "Cache-Control: no-store\r\nConnection: close\r\n\r\n"
226
+ socket.write body
227
+ end
228
+
229
+ def stream(socket)
230
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
231
+ socket.write "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\n" \
232
+ "Cache-Control: no-store\r\nConnection: keep-alive\r\n\r\nretry: 1000\n\n"
233
+ client = Client.new(socket, Queue.new)
234
+ @mutex.synchronize do
235
+ return if @stopped
236
+
237
+ client.queue << @good if @good && @good != @reel
238
+ client.queue << @reel if @reel
239
+ @clients << client
240
+ end
241
+ pump(client)
242
+ ensure
243
+ @mutex.synchronize { @clients.delete(client) } if client
244
+ end
245
+
246
+ def pump(client)
247
+ loop do
248
+ item = client.queue.pop(timeout: HEARTBEAT)
249
+ return if item == :stop
250
+
251
+ client.socket.write(item ? "data: #{item}\n\n" : ": keep-alive\n\n")
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end