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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/exe/dopairb +6 -0
- data/lib/dopairb/canvas.rb +164 -0
- data/lib/dopairb/charge_line.rb +107 -0
- data/lib/dopairb/cli.rb +90 -0
- data/lib/dopairb/color.rb +126 -0
- data/lib/dopairb/config.rb +118 -0
- data/lib/dopairb/demo.rb +61 -0
- data/lib/dopairb/director.rb +190 -0
- data/lib/dopairb/font.rb +91 -0
- data/lib/dopairb/fx.rb +154 -0
- data/lib/dopairb/game.rb +182 -0
- data/lib/dopairb/input_fx.rb +499 -0
- data/lib/dopairb/irb_adapter.rb +65 -0
- data/lib/dopairb/output_tap.rb +94 -0
- data/lib/dopairb/probe.rb +159 -0
- data/lib/dopairb/reline_adapter.rb +247 -0
- data/lib/dopairb/scene.rb +154 -0
- data/lib/dopairb/scenes/failure.rb +306 -0
- data/lib/dopairb/scenes/session.rb +146 -0
- data/lib/dopairb/scenes/success.rb +418 -0
- data/lib/dopairb/session.rb +79 -0
- data/lib/dopairb/stage.rb +129 -0
- data/lib/dopairb/term.rb +131 -0
- data/lib/dopairb/version.rb +5 -0
- data/lib/dopairb.rb +126 -0
- data/spec.md +203 -0
- metadata +111 -0
|
@@ -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
|