dopairb 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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ module Scenes
5
+ # Ordinary value: a comet leaves "=>" and lands with a small burst.
6
+ class Hit < Scene
7
+ def top? = !ctx.input.nil?
8
+ def length = 0.42
9
+
10
+ def setup
11
+ @target = [[@w - 30, 34].min, 8].max
12
+ @parts = Fx::Particles.new(@rng)
13
+ pal = combo_palette
14
+ @parts.burst(@target, 0.5, 14 + [event.combo, 20].min, at: 0.16, speed: 26, life: 0.28, palette: pal,
15
+ gravity: 3, angle: 0, arc: Math::PI * 2)
16
+ end
17
+
18
+ def draw(c, t)
19
+ pal = combo_palette
20
+ hot = t < 0.3 ? Color.mix([255, 255, 255], Color.ramp(pal, 0.2), t / 0.3) : Color.ramp(pal, 0.4)
21
+ c.put(0, 0, "=>", hot, bold: true)
22
+ if t < 0.16
23
+ head = 3 + (@target - 3) * Fx.ease_in(t / 0.16)
24
+ 9.times do |i|
25
+ x = head - i
26
+ break if x < 3
27
+ c.put(x, 0, i.zero? ? Term.glyph("✦", "*") : (i < 3 ? "=" : "-"), Color.ramp(pal, i / 9.0), bold: i < 2)
28
+ end
29
+ else
30
+ p = Fx.phase(t, 0.16, 0.42)
31
+ @parts.draw(c, t)
32
+ txt = "+#{Fx.number_with_commas(event.gain)}"
33
+ col = Color.mix([255, 255, 255], Color.ramp(pal, 0.3), p)
34
+ x = c.put(@target + 2, 0, txt, col, bg: p < 0.25 ? Color.ramp(pal, 0.6) : nil, bold: true)
35
+ c.put(x + 2, 0, "COMBO #{event.combo}", combo_color(t), bold: event.combo >= 5) if event.combo > 1
36
+ end
37
+ end
38
+
39
+ def trail
40
+ paint("=> #{Term.glyph('✦', '*')} +#{Fx.number_with_commas(event.gain)}" + (event.combo > 1 ? " COMBO #{event.combo}" : ""),
41
+ combo_color)
42
+ end
43
+ end
44
+
45
+ # nil: a swing and a miss, then smoke.
46
+ class Puff < Scene
47
+ def top? = !ctx.input.nil?
48
+ def length = 0.5
49
+
50
+ def setup
51
+ @parts = Fx::Particles.new(@rng)
52
+ 18.times do
53
+ @parts.add(12 + @rng.rand * 6, 0.9, (@rng.rand - 0.3) * 10, -1.5 - @rng.rand * 2.5,
54
+ at: 0.12 + @rng.rand * 0.08, life: 0.35, palette: Color::SMOKE)
55
+ end
56
+ end
57
+
58
+ def draw(c, t)
59
+ c.put(0, 0, "=>", [160, 160, 170], bold: true)
60
+ if t < 0.12
61
+ x = 3 + 14 * Fx.ease_out(t / 0.12)
62
+ c.put(x - 3, 0, "~~~", [200, 200, 210])
63
+ else
64
+ @parts.draw(c, t)
65
+ p = Fx.phase(t, 0.12, 0.5)
66
+ c.put(22, 0, "nil", Color.mix([230, 230, 230], [90, 90, 100], p))
67
+ c.put(27, 0, "whiff...", Color.mix([150, 150, 160], [60, 60, 70], p)) if p > 0.2
68
+ end
69
+ end
70
+ end
71
+
72
+ # true / false: an ink stamp slams down.
73
+ class Stamp < Scene
74
+ def height = 3
75
+ def length = 0.6
76
+ def top? = !ctx.input.nil?
77
+
78
+ def setup
79
+ @yes = event.info[:type] == :true
80
+ @label = @yes ? "YES!" : "NO!"
81
+ @ink = @yes ? [30, 190, 90] : [220, 40, 60]
82
+ @x = 4
83
+ @bw = 12
84
+ @parts = Fx::Particles.new(@rng)
85
+ @parts.burst(@x + @bw / 2.0, 1.5, 26, at: 0.14, speed: 30, life: 0.35,
86
+ palette: [Color.mix(@ink, [255, 255, 255], 0.6), @ink, Color.scale(@ink, 0.3)], gravity: 10)
87
+ end
88
+
89
+ def shake(t) = t.between?(0.14, 0.24) ? (@rng.rand(3) - 1) : 0
90
+ def flash_at = 0.14
91
+
92
+ def draw(c, t)
93
+ if t < 0.14
94
+ p = Fx.ease_in(t / 0.14)
95
+ grow = ((1 - p) * 10).round
96
+ c.fill_bg(Color.scale(@ink, 0.25 + 0.5 * p), @x - grow, 0 - (grow > 4 ? 0 : 0), @bw + grow * 2, 3)
97
+ c.put(@x + (@bw - @label.size) / 2, 1, @label, [255, 255, 255], bold: true) if p > 0.6
98
+ else
99
+ p = Fx.phase(t, 0.14, 0.6)
100
+ @parts.draw(c, t)
101
+ edge = Color.scale(@ink, 0.55)
102
+ c.fill_bg(edge, @x, 0, @bw, 3)
103
+ c.fill_bg(t < 0.2 ? Color.mix(@ink, [255, 255, 255], 0.6) : @ink, @x + 1, 1, @bw - 2, 1)
104
+ c.put(@x + (@bw - @label.size) / 2, 1, @label, [255, 255, 255], bold: true)
105
+ c.put(@x + @bw + 3, 1, @yes ? "true" : "false", Color.mix([255, 255, 255], @ink, p), bold: true)
106
+ c.put(@x + @bw + 10, 1, sub_line, [150, 150, 160]) if p > 0.3 && event.combo > 1
107
+ end
108
+ end
109
+
110
+ def trail = paint("[ #{@label} ]", @ink, bold: true)
111
+ end
112
+
113
+ # Integers that deserve it: count up in giant digits, then a shockwave.
114
+ class Counter < Scene
115
+ def big? = true
116
+ def top? = !ctx.input.nil?
117
+ def length = 1.0
118
+
119
+ def setup
120
+ @value = event.info[:value]
121
+ @int = Integer === @value
122
+ @final = @int ? Fx.number_with_commas(@value) : format_float(@value)
123
+ @scale = best_scale(@final)
124
+ @parts = Fx::Particles.new(@rng)
125
+ cx = @w / 2.0
126
+ @parts.burst(cx, 3, 60, at: 0.55, speed: 55, life: 0.5, palette: Color::GOLD, gravity: 14)
127
+ @parts.burst(cx, 3, 20, at: 0.58, speed: 30, life: 0.45, palette: Color::FIRE, gravity: 8,
128
+ glyphs: ["*", "+", "'", "."])
129
+ end
130
+
131
+ def height = @scale ? 7 : 3
132
+
133
+ def shake(t) = t.between?(0.55, 0.7) ? (@rng.rand(5) - 2) : 0
134
+ def flash_at = 0.55
135
+
136
+ def draw(c, t)
137
+ p = Fx.phase(t, 0, 0.55)
138
+ shown = if p >= 1 then @final
139
+ elsif @int then Fx.number_with_commas((@value * Fx.ease_out(p)).to_i)
140
+ else format_float(@value * Fx.ease_out(p))
141
+ end
142
+ landed = t >= 0.55
143
+ shimmer = (t * 1.6) % 1.0
144
+ if @scale
145
+ x = (@w - Font.width(shown, scale: @scale)) / 2
146
+ fn = lambda do |_rx, ry, f|
147
+ base = landed ? Color.ramp(Color::GOLD, 0.15 + ry * 0.12) : Color.ramp(Color::ICE, 0.2 + ry * 0.1)
148
+ d = (f - shimmer).abs
149
+ d < 0.08 ? Color.mix([255, 255, 255], base, d / 0.08) : base
150
+ end
151
+ Fx.big_text(c, shown, x, 1, fn, scale: @scale)
152
+ Fx.shockwave(c, @w / 2.0, 3, t - 0.55, speed: 70, life: 0.45)
153
+ @parts.draw(c, t)
154
+ c.tint_bg([255, 220, 120], 0.35 * (1 - Fx.phase(t, 0.55, 0.7)), 0, 0, @w, 7) if landed && t < 0.7 && ctx.config.flash != :off
155
+ if landed
156
+ sub = sub_line([digits_label])
157
+ c.put_center(6, sub, Color.ramp(Color::GOLD, 0.2), bold: true)
158
+ end
159
+ else
160
+ c.put(2, 1, "=> ", [255, 220, 120], bold: true)
161
+ c.put(5, 1, shown, landed ? [255, 230, 120] : [140, 220, 255], bold: true)
162
+ @parts.draw(c, t)
163
+ c.put(8 + shown.size, 1, sub_line([digits_label]), [220, 200, 150]) if landed
164
+ end
165
+ end
166
+
167
+ def digits_label
168
+ if @int
169
+ "#{@value.abs.to_s.size} DIGITS"
170
+ else
171
+ "FLOAT"
172
+ end
173
+ end
174
+
175
+ def trail = paint("#{Term.glyph('✦', '*')} #{@final} #{sub_line([digits_label])}", [255, 210, 90], bold: true)
176
+
177
+ private
178
+
179
+ def format_float(f)
180
+ return f.to_s unless f.finite?
181
+ f.abs >= 1e15 ? format("%.3e", f) : format("%.3f", f).sub(/\.?0+\z/, "")
182
+ end
183
+ end
184
+
185
+ # Long strings: characters pour in while the length counts up.
186
+ class Stream < Scene
187
+ def height = 2
188
+ def length = 0.75
189
+ def top? = !ctx.input.nil?
190
+
191
+ def draw(c, t)
192
+ len = event.info[:length]
193
+ p = Fx.phase(t, 0, 0.5)
194
+ c.put(0, 0, "STRING", [255, 140, 220], bold: true)
195
+ c.put(8, 0, "#{Fx.number_with_commas((len * Fx.ease_out(p)).round)} chars", [255, 255, 255], bold: p >= 1)
196
+ c.put(26, 0, sub_line, [150, 150, 160]) if p >= 1
197
+ head = event.info[:head]
198
+ span = [@w - 4, head.size].min
199
+ span.times do |i|
200
+ arrive = i.fdiv([span, 1].max) * 0.45
201
+ lt = t - arrive
202
+ next if lt < 0
203
+ x = 2 + i + ((@w - i) * (1 - Fx.ease_out(lt / 0.18))).round
204
+ col = lt < 0.12 ? [255, 255, 255] : Color.rainbow(i / 40.0 + t, 0.6)
205
+ c.put(x, 1, head[i], col)
206
+ end
207
+ c.put(2 + span, 1, "...", [120, 120, 130]) if len > span && t > 0.5
208
+ end
209
+ end
210
+
211
+ # Arrays and hashes: one block per element pops into place.
212
+ class Crate < Scene
213
+ def height = 2
214
+ def length = 0.7
215
+ def top? = !ctx.input.nil?
216
+
217
+ def draw(c, t)
218
+ hash = event.info[:type] == :hash
219
+ size = event.info[:size]
220
+ label = hash ? "HASH" : "ARRAY"
221
+ cell = hash ? 3 : 2
222
+ fit = [(@w - 14) / cell, 1].max
223
+ n = [size, fit].min
224
+ c.put(0, 0, label, hash ? [255, 200, 90] : [110, 220, 255], bold: true)
225
+ c.put(label.size + 1, 0, "x #{Fx.number_with_commas((size * Fx.ease_out(Fx.phase(t, 0, 0.45))).round)}", [255, 255, 255], bold: true)
226
+ c.put(label.size + 12, 0, sub_line, [150, 150, 160]) if t > 0.45
227
+ n.times do |i|
228
+ born = i.fdiv([n, 1].max) * 0.4
229
+ age = t - born
230
+ next if age < 0
231
+ x = 2 + i * cell
232
+ pop = age < 0.06
233
+ if hash
234
+ c.bg(x, 1, pop ? [255, 255, 255] : Color.hsv(30 + i * 7, 0.7, 0.95))
235
+ c.bg(x + 1, 1, pop ? [255, 255, 255] : Color.hsv(200 + i * 7, 0.6, 0.8))
236
+ else
237
+ c.bg(x, 1, pop ? [255, 255, 255] : Color.hsv(180 + i * 9, 0.65, 0.95))
238
+ end
239
+ end
240
+ c.put(2 + n * cell + 1, 1, "+#{size - n}", [200, 200, 210]) if size > n && t > 0.4
241
+ end
242
+ end
243
+
244
+ # Big word banners: FIRST HIT, COMBO 10, NEW RECORD, COMEBACK, milestones.
245
+ class Banner < Scene
246
+ def initialize(ctx, event, text:, sub: nil, palette: Color::FIRE, mega: false, rainbow: false, alt: false)
247
+ @alt = alt
248
+ @text = text
249
+ @sub = sub
250
+ @pal = palette
251
+ @mega = mega
252
+ @rainbow = rainbow
253
+ super(ctx, event)
254
+ end
255
+
256
+ def big? = true
257
+ def mega? = @mega
258
+ def top? = !ctx.input.nil? && !@alt
259
+ def length = @mega ? 1.9 : 1.05
260
+
261
+ def setup
262
+ @scale = best_scale(@text)
263
+ @scale = 1 if @scale == 2 && !@mega && Font.width(@text, scale: 2) > @w * 0.8
264
+ @land = 0.2
265
+ @parts = Fx::Particles.new(@rng)
266
+ @fire = Fx::Particles.new(@rng)
267
+ return unless @scale
268
+ tw = Font.width(@text, scale: @scale)
269
+ @tx = (@w - tw) / 2
270
+ by = text_y + Font::HEIGHT
271
+ 10.times do |i|
272
+ @parts.burst(@tx + tw * i / 9.0, by, @mega ? 10 : 6, at: @land, speed: 40, life: 0.55, palette: @pal,
273
+ gravity: 20, angle: -Math::PI / 2, arc: Math::PI * 1.6)
274
+ end
275
+ return unless @mega
276
+ 8.times do |i|
277
+ at = 0.35 + i * 0.16
278
+ x = 4 + @rng.rand * (@w - 8)
279
+ y = 1 + @rng.rand * [height - 6, 2].max
280
+ pal = [Color::FIRE, Color::ICE, Color::NEON, Color::TOXIC, Color::GOLD][i % 5]
281
+ @fire.add(x, height.to_f, 0, -(height - y) / 0.15 * 2, at: at - 0.15, life: 0.15, palette: [[255, 255, 255], [255, 200, 120]])
282
+ @fire.burst(x, y, 45, at: at, speed: 45, life: 0.7, palette: pal, gravity: 10, drag: 2.5)
283
+ end
284
+ 40.times do
285
+ @fire.add(@rng.rand * @w, -1, (@rng.rand - 0.5) * 4, 2 + @rng.rand * 4, at: 0.3 + @rng.rand * 1.2, life: 1.2,
286
+ palette: [Color.rainbow(@rng.rand), Color.rainbow(@rng.rand)],
287
+ glyph: ["*", "+", "'", ",", Term.glyph("✦", "*")][@rng.rand(5)], gravity: 1.5)
288
+ end
289
+ end
290
+
291
+ def height
292
+ return ctx.rows if @alt
293
+ return 2 unless @scale
294
+ @mega ? [[ctx.rows - 4, 13].min, 8].max : 7
295
+ end
296
+
297
+ def text_y
298
+ @mega ? (height - Font::HEIGHT) / 2 - 1 : 1
299
+ end
300
+
301
+ def shake(t)
302
+ return 0 unless t >= @land
303
+ k = 1 - Fx.phase(t, @land, @land + (@mega ? 0.45 : 0.25))
304
+ k > 0 ? ((@rng.rand * 2 - 1) * (@mega ? 4 : 2) * k).round : 0
305
+ end
306
+
307
+ def flash_at = @land
308
+
309
+ def draw(c, t)
310
+ return draw_compact(c, t) unless @scale
311
+ drop = t < @land ? (1 - Fx.ease_in(t / @land)) * (text_y + Font::HEIGHT + 1) : 0
312
+ y = text_y - drop.round
313
+ sweep = (t * 1.3) % 1.4 - 0.2
314
+ fn = lambda do |_rx, ry, f|
315
+ base = @rainbow ? Color.rainbow(f * 0.8 - t * 0.9) : Color.ramp(@pal, 0.1 + ry * 0.14)
316
+ d = (f - sweep).abs
317
+ t >= @land && d < 0.07 ? Color.mix([255, 255, 255], base, d / 0.07) : base
318
+ end
319
+ @fire.draw(c, t) if @mega
320
+ Fx.big_text(c, @text, @tx, y, fn, scale: @scale)
321
+ if t >= @land
322
+ Fx.shockwave(c, @w / 2.0, text_y + 2.5, t - @land, speed: @mega ? 90 : 60, palette: @pal, life: 0.5, rings: @mega ? 3 : 2)
323
+ @parts.draw(c, t)
324
+ if ctx.config.flash != :off && t < @land + 0.12
325
+ c.tint_bg(Color.ramp(@pal, 0.1), 0.5 * (1 - Fx.phase(t, @land, @land + 0.12)), 0, 0, @w, height)
326
+ end
327
+ Fx.sparkles(c, t, 7, @mega ? 30 : 10, palette: @pal) if t > @land + 0.2
328
+ sub = banner_sub
329
+ n = (sub.size * Fx.phase(t, @land + 0.1, @land + 0.45)).round
330
+ c.put_center(text_y + Font::HEIGHT + (@mega ? 2 : 0), sub[0, n], Color.ramp(@pal, 0.15), bold: true) if n > 0 && text_y + Font::HEIGHT < height
331
+ end
332
+ end
333
+
334
+ def banner_sub
335
+ [@sub, *sub_bits.reject { |b| @text.include?(b) }].compact.join(" ")
336
+ end
337
+
338
+ def draw_compact(c, t)
339
+ txt = @text.chars.join(" ")
340
+ p = Fx.phase(t, 0, 0.3)
341
+ txt.each_char.with_index do |ch, i|
342
+ col = @rainbow ? Color.rainbow(i / 12.0 - t) : Color.ramp(@pal, (i / [txt.size, 1].max.to_f) * 0.5)
343
+ c.put(1 + i, 0, ch, col, bold: true) if i <= txt.size * p
344
+ end
345
+ c.put(1, 1, banner_sub, Color.ramp(@pal, 0.2)) if t > 0.3
346
+ Fx.sparkles(c, t, 3, 6, palette: @pal)
347
+ end
348
+
349
+ def trail
350
+ star = Term.glyph("✦", "*")
351
+ s = +"#{star} #{@text} #{star}"
352
+ sub = banner_sub
353
+ s << " " << sub unless sub.empty?
354
+ if @rainbow && depth != :none
355
+ s.each_char.with_index.map { |ch, i| "#{Color.sgr(Color.rainbow(i / 20.0), nil, true, depth)}#{ch}" }.join + "\e[0m"
356
+ else
357
+ paint(s, Color.ramp(@pal, 0.15), bold: true)
358
+ end
359
+ end
360
+ end
361
+
362
+ # Definitions: a new ability unlocks.
363
+ class Unlock < Banner
364
+ def initialize(ctx, event)
365
+ what = event.info[:what]
366
+ text = { method: "NEW ABILITY", class: "NEW CLASS", module: "NEW MODULE" }[what] || "UNLOCKED"
367
+ super(ctx, event, text: text, palette: Color::NEON)
368
+ end
369
+
370
+ def draw(c, t)
371
+ super
372
+ return unless @scale && t >= @land + 0.15
373
+ name = event.info[:name].to_s
374
+ label = "#{event.info[:what] == :method ? 'def' : event.info[:what]} #{name}"
375
+ n = (label.size * Fx.phase(t, @land + 0.15, @land + 0.5)).round
376
+ x = (@w - label.size - 4) / 2
377
+ c.fill_bg([90, 30, 160], x, 6, label.size + 4, 1)
378
+ c.put(x + 2, 6, label[0, n], [255, 255, 255], bold: true)
379
+ end
380
+
381
+ def trail
382
+ paint("#{Term.glyph('✦', '*')} #{@text}: #{event.info[:name]} #{sub_line}", Color.ramp(Color::NEON, 0.2), bold: true)
383
+ end
384
+ end
385
+
386
+ # Floods of stdout: digital rain behind a counter.
387
+ class Rain < Scene
388
+ def height = 5
389
+ def length = 0.9
390
+ def big? = true
391
+
392
+ def setup
393
+ @cols = Array.new(@w) { [@rng.rand * 5, 6 + @rng.rand * 14, @rng.rand(3)] }
394
+ @chars = ("0".."9").to_a + ("a".."z").to_a + %w[{ } ( ) < > = ; :]
395
+ end
396
+
397
+ def draw(c, t)
398
+ @cols.each_with_index do |(off, speed, kind), x|
399
+ next if kind == 2
400
+ head = (t * speed - off) % (height + 6)
401
+ 6.times do |k|
402
+ y = (head - k).floor
403
+ next if y < 0 || y >= height
404
+ ch = @chars[(x * 7 + y * 13 + (t * 20).to_i) % @chars.size]
405
+ col = k.zero? ? [230, 255, 230] : Color.ramp(Color::TOXIC, k / 6.0 + 0.1)
406
+ c.put(x, y, ch, col, bold: k.zero?)
407
+ end
408
+ end
409
+ label = " OUTPUT RAIN x#{Fx.number_with_commas(event.info[:out_lines] || 0)} LINES "
410
+ x = (@w - label.size) / 2
411
+ c.fill_bg([0, 40, 10], x, 2, label.size, 1)
412
+ c.put(x, 2, label, [180, 255, 180], bold: true)
413
+ end
414
+
415
+ def trail = paint("#{Term.glyph('✦', '*')} OUTPUT RAIN x#{event.info[:out_lines]} lines", [120, 230, 140], bold: true)
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # One interactive session: owns the game state and ties the evaluation
5
+ # cycle to the director.
6
+ class Session
7
+ attr_reader :config, :game, :director, :fx
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ @game = Game.new
12
+ @director = Director.new(config)
13
+ @fx = InputFx.new(config, @game)
14
+ @finished = false
15
+ end
16
+
17
+ def active?
18
+ !@config.off? && Term.interactive?
19
+ end
20
+
21
+ def intro
22
+ @director.intro if active?
23
+ end
24
+
25
+ def finish
26
+ return if @finished
27
+ @finished = true
28
+ @director.outro(@game.stats) if active? && @game.evals > 0
29
+ end
30
+
31
+ def around_eval(code, last_value)
32
+ input = RelineAdapter.take_input(code)
33
+ OutputTap.start
34
+ charge = @config.motion ? ChargeLine.new(@config, charge: @game.charge).start : nil
35
+ t0 = now
36
+ completed = false
37
+ failure = nil
38
+ begin
39
+ result = yield
40
+ completed = true
41
+ result
42
+ rescue Exception => e # rubocop:disable Lint/RescueException
43
+ failure = e
44
+ raise
45
+ ensure
46
+ charge&.stop
47
+ lines, = OutputTap.stop
48
+ # a throw (e.g. irb_exit) leaves neither flag set: stay quiet
49
+ if completed
50
+ react(:success, code, last_value, input, lines, now - t0, charge)
51
+ elsif failure && !(SystemExit === failure)
52
+ react(failure, code, last_value, input, lines, now - t0, charge)
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def react(outcome, code, last_value, input, lines, elapsed, charge)
60
+ clean = !OutputTap.output?
61
+ shot = clean ? input : nil
62
+ if outcome == :success
63
+ info = Probe.value(last_value.call, code).merge(out_lines: lines)
64
+ event = @game.success(info, code: code, out_lines: lines)
65
+ pre = elapsed > 0.2 || shot.nil? ? 0.0 : [@config.charge, 0.3].min
66
+ @director.play_event(event, input: shot, pre: pre)
67
+ else
68
+ info = Probe.exception(outcome, code)
69
+ event = info[:type] == :interrupt ? @game.interrupted : @game.failure(info)
70
+ @director.play_event(event, input: shot)
71
+ end
72
+ rescue Exception => e # rubocop:disable Lint/RescueException
73
+ raise if SystemExit === e
74
+ Dopairb.debug(e)
75
+ end
76
+
77
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ end
79
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # A temporary block of terminal rows starting at the cursor. `top` rows
5
+ # already on screen (the submitted input) can be taken over and are
6
+ # restored verbatim on close.
7
+ class Stage
8
+ attr_reader :height, :top, :owner
9
+
10
+ def initialize(height, top: [])
11
+ @height = height
12
+ @top = top
13
+ @owner = Thread.current
14
+ @open = false
15
+ @closed = false
16
+ end
17
+
18
+ def total = @top.size + @height
19
+ def open? = @open && !@closed
20
+
21
+ def open
22
+ Term::LOCK.synchronize do
23
+ buf = +"\e[?25l\r"
24
+ buf << ("\n" * (@height - 1)) if @height > 1
25
+ up = @height - 1 + @top.size
26
+ buf << "\e[#{up}A" if up > 0
27
+ buf << "\r"
28
+ Term.out.write(buf)
29
+ @open = true
30
+ Term.overlay = self
31
+ end
32
+ self
33
+ end
34
+
35
+ def draw(lines)
36
+ Term::LOCK.synchronize do
37
+ return unless open?
38
+ Term.out.write(frame(lines))
39
+ end
40
+ end
41
+
42
+ def close
43
+ Term::LOCK.synchronize do
44
+ return unless open?
45
+ restore
46
+ Term.overlay = nil if Term.overlay.equal?(self)
47
+ end
48
+ end
49
+
50
+ # Another thread is about to print: get off the screen immediately.
51
+ def yield_to_output
52
+ return unless open?
53
+ restore
54
+ Term.overlay = nil if Term.overlay.equal?(self)
55
+ end
56
+
57
+ private
58
+
59
+ def restore
60
+ buf = frame(@top + Array.new(@height, ""))
61
+ buf << "\e[#{@top.size}B" if @top.size > 0
62
+ buf << "\r\e[0m\e[?25h"
63
+ Term.out.write(buf)
64
+ @closed = true
65
+ end
66
+
67
+ def frame(lines)
68
+ buf = +""
69
+ n = total
70
+ n.times do |i|
71
+ buf << "\r\e[2K" << (lines[i] || "")
72
+ buf << "\e[0m\n" if i < n - 1
73
+ end
74
+ buf << "\e[0m"
75
+ buf << "\e[#{n - 1}A" if n > 1
76
+ buf << "\r"
77
+ buf
78
+ end
79
+ end
80
+
81
+ # Full-screen stage on the alternate screen: scrollback is left untouched.
82
+ class AltStage
83
+ attr_reader :height, :owner
84
+
85
+ def initialize(height)
86
+ @height = height
87
+ @owner = Thread.current
88
+ @open = false
89
+ end
90
+
91
+ def top = []
92
+ def open? = @open
93
+
94
+ def open
95
+ Term::LOCK.synchronize do
96
+ Term.out.write("\e[?1049h\e[?25l\e[H\e[2J")
97
+ @open = true
98
+ Term.overlay = self
99
+ end
100
+ self
101
+ end
102
+
103
+ def draw(lines)
104
+ Term::LOCK.synchronize do
105
+ return unless @open
106
+ buf = +""
107
+ lines.each_with_index { |l, i| buf << "\e[#{i + 1};1H\e[2K" << l << "\e[0m" }
108
+ Term.out.write(buf)
109
+ end
110
+ end
111
+
112
+ def close
113
+ Term::LOCK.synchronize { restore }
114
+ end
115
+
116
+ def yield_to_output
117
+ restore
118
+ end
119
+
120
+ private
121
+
122
+ def restore
123
+ return unless @open
124
+ Term.out.write("\e[0m\e[?1049l\e[?25h")
125
+ @open = false
126
+ Term.overlay = nil if Term.overlay.equal?(self)
127
+ end
128
+ end
129
+ end