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,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Per-keystroke effects. Pure logic: it is told what changed in the line
5
+ # editor and answers what to draw; RelineAdapter does the plumbing.
6
+ class InputFx
7
+ Layout = Struct.new(:cursor_x, :cursor_row, :row_end_x, :last_row, :screen_width, :room_below, keyword_init: true)
8
+
9
+ STRIP_ROWS = 2
10
+ OPEN = %i[PARENTHESIS_LEFT PARENTHESIS_LEFT_PARENTHESES BRACKET_LEFT BRACKET_LEFT_ARRAY BRACE_LEFT LAMBDA_BEGIN EMBEXPR_BEGIN].freeze
11
+ CLOSE = %i[PARENTHESIS_RIGHT BRACKET_RIGHT BRACE_RIGHT EMBEXPR_END].freeze
12
+ PAIRS = { "(" => ")", "[" => "]", "{" => "}", '#{' => "}" }.freeze
13
+ STR_BEGIN = %i[STRING_BEGIN XSTRING_BEGIN REGEXP_BEGIN SYMBOL_BEGIN PERCENT_LOWER_W PERCENT_UPPER_W PERCENT_LOWER_I PERCENT_UPPER_I PERCENT_LOWER_X].freeze
14
+ STR_END = %i[STRING_END REGEXP_END].freeze
15
+ BLOCK_OPEN = %i[KEYWORD_CLASS KEYWORD_MODULE KEYWORD_DEF KEYWORD_IF KEYWORD_UNLESS KEYWORD_WHILE KEYWORD_UNTIL
16
+ KEYWORD_CASE KEYWORD_BEGIN KEYWORD_DO KEYWORD_FOR].freeze
17
+ HISTORY_KEYS = %i[ed_prev_history ed_next_history ed_search_prev_history ed_search_next_history
18
+ ed_beginning_of_history ed_end_of_history vi_search_prev vi_search_next
19
+ incremental_search_history reverse_search_history forward_search_history].freeze
20
+ COMPLETE_KEYS = %i[complete menu_complete menu_complete_backward completion_journey_up completion_journey_down
21
+ em_complete vi_complete].freeze
22
+
23
+ Highlight = Struct.new(:from, :to, :born, :life, :kind)
24
+ Popup = Struct.new(:text, :x, :born, :life, :palette, :rise, :prio)
25
+
26
+ attr_reader :game
27
+
28
+ def initialize(config, game, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
29
+ @config = config
30
+ @game = game
31
+ @clock = clock
32
+ @rng = rng
33
+ @parts = Fx::Particles.new(rng)
34
+ @popups = []
35
+ @highlights = []
36
+ @inserts = []
37
+ @last_kind = nil
38
+ @paste_chars = 0
39
+ @hl_was_active = false
40
+ @last_hud = nil
41
+ end
42
+
43
+ def now = @clock.call
44
+ def depth = Term.depth(@config)
45
+ def lively? = @config.keys && @config.level >= 1
46
+ def full? = @config.level >= 2
47
+
48
+ # before/after: [lines, line_index, byte_pointer]; prev_x/cur_x: cursor columns
49
+ def on_key(method_symbol, before, after, prev_x:, cur_x:, pasting: false)
50
+ t = now
51
+ if pasting
52
+ @paste_chars += 1
53
+ return
54
+ end
55
+ if @paste_chars > 0
56
+ flush_paste(cur_x, t)
57
+ end
58
+ if method_symbol == :insert_multiline_text
59
+ n = after[0].join("\n").size - before[0].join("\n").size
60
+ @paste_chars = n.abs
61
+ return flush_paste(cur_x, t)
62
+ end
63
+
64
+ b_lines, b_li, b_bp = before
65
+ a_lines, a_li, a_bp = after
66
+ if b_lines == a_lines
67
+ return if [b_li, b_bp] == [a_li, a_bp]
68
+ return moved(prev_x, cur_x, t)
69
+ end
70
+
71
+ old = b_lines.join("\n")
72
+ new = a_lines.join("\n")
73
+ if HISTORY_KEYS.include?(method_symbol)
74
+ return history(new, cur_x, t)
75
+ end
76
+ pre = common_prefix(old, new)
77
+ suf = common_suffix(old, new, pre)
78
+ removed = old.byteslice(pre, old.bytesize - pre - suf).to_s
79
+ added = new.byteslice(pre, new.bytesize - pre - suf).to_s
80
+ if COMPLETE_KEYS.include?(method_symbol) && !added.empty?
81
+ return completed(pre, added, cur_x, t)
82
+ end
83
+ if a_lines.size > b_lines.size && added.start_with?("\n")
84
+ @game.key(:newline)
85
+ pop("CHARGE LV#{a_lines.size}", cur_x, t, Color::FIRE, life: 0.6)
86
+ @parts.burst(cur_x + 2, 0.2, 14, at: t, speed: 24, life: 0.4, palette: Color::FIRE, gravity: 6, angle: 0, arc: Math::PI) if full?
87
+ @last_kind = :newline
88
+ return
89
+ end
90
+ if !removed.empty? && added.empty?
91
+ return deleted(removed, cur_x, t)
92
+ end
93
+ if added.size == 1 && removed.empty?
94
+ return inserted(added, pre, new, cur_x, t)
95
+ end
96
+ unless added.empty?
97
+ @game.key(:insert, added[0])
98
+ spark(cur_x, t, 2)
99
+ end
100
+ rescue StandardError => e
101
+ Dopairb.debug(e)
102
+ end
103
+
104
+ def animating?(t = now)
105
+ return true if @parts.alive?(t)
106
+ return true if @popups.any? { |p| t - p.born < p.life }
107
+ return true if @inline && t - @inline.born < @inline.life + 0.1
108
+ return true if @inserts.any? { |it| t - it < 0.4 }
109
+ return true if hl_dirty?(t)
110
+ hud = @config.hud ? hud_key(t) : nil
111
+ if hud != @last_hud
112
+ @last_hud = hud
113
+ return true
114
+ end
115
+ false
116
+ end
117
+
118
+ # True while highlights are live and for the one render after they end.
119
+ def hl_dirty?(t = now)
120
+ active = @highlights.any? { |h| t - h.born < h.life }
121
+ dirty = active || @hl_was_active
122
+ @hl_was_active = active
123
+ dirty
124
+ end
125
+
126
+ def reset_line
127
+ @inline = nil
128
+ @highlights.clear
129
+ @inserts.clear
130
+ @popups.clear
131
+ @parts.clear
132
+ end
133
+
134
+ # ---- rendering -------------------------------------------------------
135
+
136
+ def hud_render(lay, t = now)
137
+ return nil unless @config.hud
138
+ text = hud_text(lay.screen_width - lay.row_end_x - 4, t)
139
+ return nil unless text
140
+ x = lay.screen_width - Term.str_width(text) - 1
141
+ return nil if x <= lay.row_end_x + 1
142
+ heat = @game.heat(t)
143
+ bg = Color.mix([22, 26, 44], [90, 26, 10], heat)
144
+ fg = Color.mix([120, 135, 170], [255, 215, 120], heat)
145
+ gauge_on = Color.mix([80, 110, 170], Color.ramp(Color::FIRE, 0.35), heat)
146
+ c = Canvas.new(Term.str_width(text), 1)
147
+ c.put(0, 0, text, fg, bg: bg, bold: heat > 0.5)
148
+ g = text.index("[")
149
+ if g
150
+ cells = 8
151
+ filled = (@game.charge / Game::CHARGE_MAX * cells).round
152
+ cells.times do |i|
153
+ c.put(g + 1 + i, 0, Term.glyph(i < filled ? "⣿" : "⣀", i < filled ? "#" : "."), i < filled ? gauge_on : [70, 70, 90], bg: bg)
154
+ end
155
+ end
156
+ [x, c.render(depth)]
157
+ end
158
+
159
+ def hud_key(t)
160
+ [hud_text(200, t), (@game.heat(t) * 12).round, (@game.charge / Game::CHARGE_MAX * 8).round]
161
+ end
162
+
163
+ def trail_render(lay, t = now)
164
+ return nil unless lively?
165
+ return nil unless lay.cursor_x == lay.row_end_x
166
+ if @inline && t - @inline.born < @inline.life
167
+ return inline_render(lay, t)
168
+ end
169
+ recent = @inserts.select { |it| t - it < 0.35 }
170
+ return nil if recent.empty?
171
+ len = [recent.size + 1, 6].min
172
+ room = lay.screen_width - lay.cursor_x - 2
173
+ len = [len, room].min
174
+ return nil if len <= 0
175
+ c = Canvas.new(len, 1)
176
+ glyphs = [Term.glyph("✦", "*"), "*", "+", "'", "."]
177
+ age = t - recent.max
178
+ len.times do |i|
179
+ f = i.fdiv(len) + age * 2
180
+ next if f > 1
181
+ c.put(i, 0, glyphs[(i + (t * 30).to_i) % glyphs.size], Color.ramp(Color::FIRE, f), bold: i.zero?)
182
+ end
183
+ [lay.cursor_x + 1, c.render(depth)]
184
+ end
185
+
186
+ # Popup on the cursor row, for moments when the strip below is covered.
187
+ def inline_render(lay, t)
188
+ p = @inline
189
+ room = lay.screen_width - lay.cursor_x - 2
190
+ text = p.text[0, room]
191
+ return nil if text.nil? || text.empty?
192
+ age = (t - p.born) / p.life
193
+ c = Canvas.new(text.size, 1)
194
+ text.each_char.with_index do |ch, i|
195
+ sweep = (i - age * text.size * 2.5).abs < 1.5
196
+ col = sweep ? [255, 255, 255] : Color.ramp(p.palette, 0.1 + age * 0.7)
197
+ c.put(i, 0, ch, col, bg: age < 0.15 ? Color.ramp(p.palette, 0.5) : nil, bold: age < 0.6)
198
+ end
199
+ [lay.cursor_x + 1, c.render(depth)]
200
+ end
201
+
202
+ def strip_render(lay, t = now)
203
+ return nil unless lively?
204
+ @popups.reject! { |p| t - p.born > p.life }
205
+ @parts.prune(t)
206
+ return nil if @popups.empty? && @parts.empty?
207
+ w = lay.screen_width - 1
208
+ c = Canvas.new(w, STRIP_ROWS)
209
+ @parts.draw(c, t)
210
+ @popups.each do |p|
211
+ age = (t - p.born) / p.life
212
+ y = p.rise && age > 0.5 ? 0 : 1
213
+ col = age < 0.15 ? [255, 255, 255] : Color.ramp(p.palette, 0.1 + age * 0.7)
214
+ bg = age < 0.12 ? Color.ramp(p.palette, 0.4) : nil
215
+ x = [[p.x, 0].max, w - p.text.size].min
216
+ c.put(x, y, p.text, col, bg: bg, bold: age < 0.6)
217
+ end
218
+ c.render(depth)
219
+ end
220
+
221
+ # Decorate IRB's colorized input with the live highlights.
222
+ def apply(colored, raw, t = now)
223
+ live = @highlights.select { |h| t - h.born < h.life }
224
+ return colored if live.empty? || colored.nil?
225
+ spans = live.map do |h|
226
+ from = raw.byteslice(0, h.from).to_s.size
227
+ to = raw.byteslice(0, h.to).to_s.size
228
+ [from, to, h, (t - h.born) / h.life]
229
+ end
230
+ out = +""
231
+ cur = +""
232
+ ci = 0
233
+ colored.scan(/\e\[[\d;]*m|\e\[?[\d;?]*[A-Za-z]|./m) do |tok|
234
+ if tok.start_with?("\e")
235
+ out << tok
236
+ if tok.end_with?("m")
237
+ (tok == "\e[0m" || tok == "\e[m") ? cur.clear : cur << tok
238
+ end
239
+ next
240
+ end
241
+ if tok != "\n" && (span = spans.reverse.find { |f, to, _, _| ci >= f && ci < to })
242
+ out << style(span, ci, tok, cur)
243
+ else
244
+ out << tok
245
+ end
246
+ ci += 1
247
+ end
248
+ ci == raw.size ? out : colored
249
+ end
250
+
251
+ private
252
+
253
+ def hud_text(space, t)
254
+ score = Fx.number_with_commas(@game.score)
255
+ mult = @game.multiplier(t)
256
+ m = mult > 1.04 ? format(" x%.1f", mult) : ""
257
+ full = " COMBO #{format('%02d', @game.combo)} SCORE #{score} [#{' ' * 8}]#{m} "
258
+ return full if Term.str_width(full) <= space
259
+ short = " C#{format('%02d', @game.combo)} #{score} "
260
+ Term.str_width(short) <= space ? short : nil
261
+ end
262
+
263
+ def style(span, ci, ch, cur)
264
+ from, to, h, f = span
265
+ d = depth
266
+ case h.kind
267
+ when :flash
268
+ bg = Color.ramp(Color::FIRE, 0.05 + f * 0.8)
269
+ "#{Color.sgr([20, 10, 0], bg, true, d)}#{ch}\e[0m#{cur}"
270
+ when :pair
271
+ pulse = 0.5 + 0.5 * Math.cos(f * Math::PI * 5)
272
+ bg = Color.mix([255, 190, 40], [255, 255, 200], pulse)
273
+ bg = Color.mix(bg, [40, 30, 10], f)
274
+ "#{Color.sgr([30, 20, 0], bg, true, d)}#{ch}\e[0m#{cur}"
275
+ when :range, :lock
276
+ base = h.kind == :lock ? [0, 170, 200] : [70, 60, 150]
277
+ sweep = from + (to - from) * [f * 2.5, 1].min
278
+ bg = (ci - sweep).abs < 1.5 ? [230, 250, 255] : Color.mix(base, [20, 20, 30], f)
279
+ bgs = d == :none ? "\e[7m" : "\e[#{Color.bg_code(bg, d)}m"
280
+ "#{bgs}#{ch}\e[0m#{cur}"
281
+ when :reveal
282
+ head = from + (to - from) * [f * 1.6, 1].min
283
+ if ci > head
284
+ "#{Color.sgr([70, 70, 85], nil, false, d)}#{ch}\e[0m#{cur}"
285
+ elsif head - ci < 1.5
286
+ "#{Color.sgr([20, 20, 30], [220, 240, 255], true, d)}#{ch}\e[0m#{cur}"
287
+ else
288
+ ch
289
+ end
290
+ else
291
+ ch
292
+ end
293
+ end
294
+
295
+ def inserted(ch, off, new, cur_x, t)
296
+ resumed = @game.key(:insert, ch)
297
+ recovering = @last_kind == :delete
298
+ @last_kind = :insert
299
+ @inserts << t
300
+ @inserts.shift while @inserts.size > 8
301
+ return unless lively?
302
+ if new.size < 4000
303
+ @highlights.reject! { |h| h.kind == :flash }
304
+ @highlights << Highlight.new(off, off + ch.bytesize, t, 0.18, :flash)
305
+ end
306
+ spark(cur_x, t, full? ? 4 : 1)
307
+ if resumed == :resume
308
+ pop("CHARGE!", cur_x - 3, t, Color::FIRE, life: 0.7, rise: true, prio: 3)
309
+ @parts.burst(cur_x, 0.0, 26, at: t, speed: 34, life: 0.55, palette: Color::FIRE, gravity: 10) if full?
310
+ elsif recovering
311
+ pop("RECOVERY", cur_x - 4, t, Color::TOXIC, life: 0.55)
312
+ end
313
+ streak = @game.typing_combo
314
+ if [20, 50, 100, 200].include?(streak)
315
+ pop("STREAK #{streak}!", cur_x - 5, t, streak >= 100 ? Color::NEON : Color::GOLD, life: 0.9, rise: true, prio: 3)
316
+ @parts.burst(cur_x, 0.5, 30, at: t, speed: 40, life: 0.6, palette: Color::GOLD, gravity: 12) if full?
317
+ end
318
+ syntax_moment(ch, off, new, cur_x, t)
319
+ end
320
+
321
+ def syntax_moment(ch, off, new, cur_x, t)
322
+ return unless defined?(Prism) && new.size < 20_000
323
+ if ")]}\"'`/>|!".include?(ch)
324
+ tokens = Prism.lex(new).value.map(&:first)
325
+ if (open = match_bracket(tokens, off))
326
+ @game.key(:bracket)
327
+ @highlights << Highlight.new(open, open + (tokens.find { |tk| tk.location.start_offset == open }&.value&.bytesize || 1), t, 0.6, :pair)
328
+ @highlights << Highlight.new(off, off + 1, t, 0.6, :pair)
329
+ @highlights.reject! { |h| h.kind == :flash }
330
+ pop("NICE!", cur_x - 3, t, Color::GOLD, life: 0.6, rise: true, prio: 2)
331
+ @parts.burst(cur_x - 1, 0.0, 12, at: t, speed: 26, life: 0.4, palette: Color::GOLD, gravity: 8) if full?
332
+ elsif (range = match_string(tokens, off))
333
+ @game.key(:string)
334
+ @highlights << Highlight.new(range[0], range[1], t, 0.45, :range)
335
+ @parts.burst(cur_x - 1, 0.0, 6, at: t, speed: 16, life: 0.35, palette: Color::ICE, gravity: 6) if full?
336
+ end
337
+ elsif ch == "d" && new.byteslice(off - 2, 3) == "end"
338
+ tokens = Prism.lex(new).value.map(&:first)
339
+ if (start = match_block(tokens, off - 2))
340
+ @game.key(:block)
341
+ @highlights << Highlight.new(start, off + 1, t, 0.55, :range)
342
+ pop("SEALED!", cur_x - 4, t, Color::NEON, life: 0.7, rise: true, prio: 2)
343
+ @parts.burst(cur_x - 2, 0.0, 16, at: t, speed: 30, life: 0.5, palette: Color::NEON, gravity: 8) if full?
344
+ end
345
+ end
346
+ rescue StandardError => e
347
+ Dopairb.debug(e)
348
+ end
349
+
350
+ def match_bracket(tokens, off)
351
+ stack = []
352
+ tokens.each do |tk|
353
+ o = tk.location.start_offset
354
+ if OPEN.include?(tk.type)
355
+ stack << tk
356
+ elsif CLOSE.include?(tk.type)
357
+ opener = stack.pop
358
+ if o == off
359
+ return nil unless opener && PAIRS[opener.value] == tk.value
360
+ return opener.location.start_offset
361
+ end
362
+ end
363
+ break if o > off
364
+ end
365
+ nil
366
+ end
367
+
368
+ def match_string(tokens, off)
369
+ stack = []
370
+ tokens.each do |tk|
371
+ o = tk.location.start_offset
372
+ if STR_BEGIN.include?(tk.type)
373
+ stack << o
374
+ elsif STR_END.include?(tk.type)
375
+ b = stack.pop
376
+ return (b ? [b, tk.location.end_offset] : nil) if tk.location.end_offset == off + 1
377
+ end
378
+ break if o > off
379
+ end
380
+ nil
381
+ end
382
+
383
+ def match_block(tokens, end_off)
384
+ stack = []
385
+ tokens.each_with_index do |tk, i|
386
+ o = tk.location.start_offset
387
+ if BLOCK_OPEN.include?(tk.type)
388
+ stack << o unless tk.type == :KEYWORD_DEF && endless_def?(tokens, i)
389
+ elsif tk.type == :KEYWORD_END
390
+ b = stack.pop
391
+ return b if o == end_off
392
+ end
393
+ break if o > end_off
394
+ end
395
+ nil
396
+ end
397
+
398
+ def endless_def?(tokens, i)
399
+ depth = 0
400
+ tokens[(i + 1)..].each do |tk|
401
+ case tk.type
402
+ when :PARENTHESIS_LEFT, :PARENTHESIS_LEFT_PARENTHESES then depth += 1
403
+ when :PARENTHESIS_RIGHT then depth -= 1
404
+ when :EQUAL then return depth.zero?
405
+ when :NEWLINE, :SEMICOLON then return false if depth.zero?
406
+ end
407
+ end
408
+ false
409
+ end
410
+
411
+ def deleted(removed, cur_x, t)
412
+ @game.key(:delete)
413
+ @last_kind = :delete
414
+ @highlights.clear
415
+ return unless lively?
416
+ glyph = removed.gsub(/\s/, "").chars.last || "."
417
+ glyph = "." unless Term.char_width(glyph) == 1
418
+ @parts.add(cur_x, 0.0, (@rng.rand - 0.5) * 6, 3, at: t, life: 0.45, palette: [[255, 255, 255], [255, 120, 120], [120, 40, 40]], glyph: glyph, gravity: 16)
419
+ n = full? ? 7 : 3
420
+ n.times do
421
+ @parts.add(cur_x + 0.5, 0.1, (@rng.rand - 0.5) * 22, -2 + @rng.rand * 6, at: t, life: 0.4 + @rng.rand * 0.2,
422
+ palette: [[255, 200, 200], [230, 60, 60], [80, 20, 20]], glyph: ["'", ",", ".", "`"][@rng.rand(4)], gravity: 26, drag: 1.0)
423
+ end
424
+ end
425
+
426
+ def moved(prev_x, cur_x, t)
427
+ @game.key(:move)
428
+ @last_kind = :move
429
+ return unless lively? && prev_x && cur_x && prev_x != cur_x
430
+ dir = cur_x > prev_x ? 1 : -1
431
+ span = (cur_x - prev_x).abs
432
+ steps = [span, 30].min
433
+ steps.times do |i|
434
+ x = cur_x - dir * (i + 1) * span.fdiv(steps)
435
+ @parts.add(x, 0.15, dir * 4.0, 0, at: t + i * 0.004, life: 0.12 + 0.2 * (1 - i.fdiv(steps)), palette: Color::ICE)
436
+ end
437
+ end
438
+
439
+ def history(new, cur_x, t)
440
+ @game.key(:history)
441
+ @last_kind = :history
442
+ return unless lively?
443
+ @highlights.clear
444
+ @highlights << Highlight.new(0, new.bytesize, t, 0.35, :reveal) if new.size < 4000
445
+ pop("<< REWIND", cur_x - 10, t, Color::ICE, life: 0.5)
446
+ end
447
+
448
+ def completed(off, added, cur_x, t)
449
+ @game.key(:complete)
450
+ @last_kind = :complete
451
+ return unless lively?
452
+ @highlights.reject! { |h| h.kind == :flash }
453
+ @highlights << Highlight.new(off, off + added.bytesize, t, 0.5, :lock)
454
+ @inline = Popup.new("<< LOCK ON", cur_x + 1, t, 0.6 * @config.duration, Color::ICE, false, 2)
455
+ @parts.burst(cur_x, 0.2, 10, at: t, speed: 28, life: 0.3, palette: Color::ICE, gravity: 0) if full?
456
+ end
457
+
458
+ def flush_paste(cur_x, t)
459
+ n = @paste_chars
460
+ @paste_chars = 0
461
+ @game.key(:paste)
462
+ @last_kind = :paste
463
+ pop("PASTE x#{n}", (cur_x || 0) - 6, t, Color::NEON, life: 0.8, rise: true) if lively?
464
+ end
465
+
466
+ def spark(cur_x, t, n)
467
+ return if n <= 0
468
+ @parts.burst(cur_x - 0.5, -0.3, n, at: t, speed: 14, life: 0.35, palette: Color::FIRE, gravity: 20, angle: Math::PI / 2, arc: Math::PI * 1.2)
469
+ end
470
+
471
+ # One popup at a time: a newer one wins unless the current one is more important and still fresh.
472
+ def pop(text, x, t, palette, life: 0.5, rise: false, prio: 1)
473
+ cur = @popups.last
474
+ return if cur && t - cur.born < cur.life * 0.5 && cur.prio > prio
475
+ @popups.replace([Popup.new(text, x, t, life * @config.duration, palette, rise, prio)])
476
+ end
477
+
478
+ def common_prefix(a, b)
479
+ n = [a.bytesize, b.bytesize].min
480
+ i = 0
481
+ i += 1 while i < n && a.getbyte(i) == b.getbyte(i)
482
+ i -= 1 while i > 0 && !char_boundary?(a, i)
483
+ i
484
+ end
485
+
486
+ def common_suffix(a, b, pre)
487
+ n = [a.bytesize, b.bytesize].min - pre
488
+ i = 0
489
+ i += 1 while i < n && a.getbyte(a.bytesize - 1 - i) == b.getbyte(b.bytesize - 1 - i)
490
+ i -= 1 while i > 0 && !char_boundary?(a, a.bytesize - i)
491
+ i
492
+ end
493
+
494
+ def char_boundary?(s, i)
495
+ b = s.getbyte(i)
496
+ b.nil? || (b & 0xC0) != 0x80
497
+ end
498
+ end
499
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Every dependency on IRB internals lives in this file.
5
+ #
6
+ # Tested with IRB 1.14 - 1.18. Public: IRB::Command.register, IRB.conf[:AT_EXIT].
7
+ # Internal (prepended): IRB::Context#evaluate, which runs one statement and
8
+ # raises what it raises; IRB prints `=> value` or the error afterwards.
9
+ module IrbAdapter
10
+ SUPPORTED = Gem::Requirement.new(">= 1.14.0", "< 2")
11
+
12
+ class << self
13
+ def supported?
14
+ defined?(IRB::VERSION) && SUPPORTED.satisfied_by?(Gem::Version.new(IRB::VERSION))
15
+ end
16
+
17
+ def install
18
+ return false unless supported?
19
+ return true if @installed
20
+ IRB::Context.prepend(ContextHook)
21
+ IRB::WorkSpace.prepend(WorkSpaceHook)
22
+ IRB::Command.register(:dopa, DopaCommand) if defined?(IRB::Command) && IRB::Command.respond_to?(:register)
23
+ IRB.conf[:AT_EXIT] ||= []
24
+ IRB.conf[:AT_EXIT] << proc do
25
+ nested = defined?(IRB::Irb.run_nesting_depth) && IRB::Irb.run_nesting_depth > 0
26
+ Dopairb.session&.finish unless nested
27
+ end
28
+ @installed = true
29
+ end
30
+
31
+ def expression?(statement)
32
+ defined?(IRB::Statement::Expression) && IRB::Statement::Expression === statement
33
+ end
34
+ end
35
+
36
+ module ContextHook
37
+ def evaluate(statement, line_no)
38
+ session = Dopairb.session
39
+ return super unless session&.active? && IrbAdapter.expression?(statement)
40
+ session.around_eval(statement.code, -> { last_value }) { super }
41
+ end
42
+ end
43
+
44
+ # Keep our own frames out of user-visible backtraces, like IRB does for itself.
45
+ module WorkSpaceHook
46
+ OWN = %r{/lib/dopairb(?:/[\w/]+)?\.rb:}
47
+
48
+ def filter_backtrace(bt)
49
+ return nil if bt =~ OWN
50
+ super
51
+ end
52
+ end
53
+
54
+ if defined?(IRB::Command::Base)
55
+ class DopaCommand < IRB::Command::Base
56
+ category "dopairb"
57
+ description "Tune dopairb effects. `dopa help` for details."
58
+
59
+ def execute(arg)
60
+ Dopairb.command(arg.to_s)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Watches writes to STDOUT/STDERR. Before any program output reaches the
5
+ # terminal, transient decorations get out of the way; during an eval it
6
+ # also counts output lines for OUTPUT RAIN.
7
+ module OutputTap
8
+ @counting = false
9
+ @lines = 0
10
+ @bytes = 0
11
+
12
+ class << self
13
+ attr_reader :lines, :bytes
14
+
15
+ def install
16
+ return if @installed
17
+ [STDOUT, STDERR].each { |io| io.singleton_class.prepend(Hook) }
18
+ Kernel.prepend(PrivateSpawnHook)
19
+ [Kernel.singleton_class, Process.singleton_class].each { |m| m.prepend(SpawnHook) }
20
+ IO.singleton_class.prepend(PopenHook)
21
+ @installed = true
22
+ end
23
+
24
+ def start
25
+ @lines = 0
26
+ @bytes = 0
27
+ @spawned = false
28
+ @counting = true
29
+ end
30
+
31
+ def stop
32
+ @counting = false
33
+ [@lines, @bytes]
34
+ end
35
+
36
+ def output? = @bytes > 0 || @spawned
37
+
38
+ def before_write(str)
39
+ Term::LOCK.synchronize { Term.yield_to_output } if Term.overlay
40
+ return unless @counting
41
+ s = str.to_s
42
+ @bytes += s.bytesize
43
+ @lines += s.count("\n")
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ # A child process may write straight to the tty: stop decorating.
49
+ def spawned!
50
+ @spawned = true if @counting
51
+ Term::LOCK.synchronize { Term.yield_to_output } if Term.overlay
52
+ end
53
+ end
54
+
55
+ module Hook
56
+ def write(*args)
57
+ args.each { |a| OutputTap.before_write(a) }
58
+ super
59
+ end
60
+
61
+ def syswrite(str)
62
+ OutputTap.before_write(str)
63
+ super
64
+ end
65
+
66
+ def write_nonblock(str, *rest, **kw)
67
+ OutputTap.before_write(str)
68
+ super
69
+ end
70
+ end
71
+
72
+ module SpawnHook
73
+ %i[system spawn].each do |m|
74
+ define_method(m) do |*args, **kw, &blk|
75
+ OutputTap.spawned!
76
+ super(*args, **kw, &blk)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Kernel#system and friends are private; keep them that way.
82
+ module PrivateSpawnHook
83
+ include SpawnHook
84
+ private(*SpawnHook.instance_methods(false))
85
+ end
86
+
87
+ module PopenHook
88
+ def popen(*args, **kw, &blk)
89
+ OutputTap.spawned!
90
+ super
91
+ end
92
+ end
93
+ end
94
+ end