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,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# Cheap, side-effect free facts about results and exceptions. Never calls
|
|
5
|
+
# the user's #inspect / #to_s: those may be slow, raise, or print.
|
|
6
|
+
module Probe
|
|
7
|
+
KERNEL_CLASS = Kernel.instance_method(:class)
|
|
8
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
9
|
+
STR_LENGTH = String.instance_method(:length)
|
|
10
|
+
STR_SLICE = String.instance_method(:[])
|
|
11
|
+
ARY_SIZE = Array.instance_method(:size)
|
|
12
|
+
HASH_SIZE = Hash.instance_method(:size)
|
|
13
|
+
SYM_NAME = Symbol.instance_method(:name)
|
|
14
|
+
INT_BITS = Integer.instance_method(:bit_length)
|
|
15
|
+
INT_TO_S = Integer.instance_method(:to_s)
|
|
16
|
+
EXC_MESSAGE = Exception.instance_method(:message)
|
|
17
|
+
|
|
18
|
+
HUGE_BITS = 4000
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def value(v, code = "")
|
|
23
|
+
info = classify(v)
|
|
24
|
+
if (m = code.match(/\A\s*def\s+(?:self\.)?([^\s(=;]+)/)) && info[:type] == :symbol
|
|
25
|
+
return { type: :definition, what: :method, name: m[1] }
|
|
26
|
+
end
|
|
27
|
+
if (m = code.match(/\A\s*(class|module)\s+(?!<<)([A-Z][\w:]*)/))
|
|
28
|
+
return { type: :definition, what: m[1].to_sym, name: m[2] }
|
|
29
|
+
end
|
|
30
|
+
info
|
|
31
|
+
rescue StandardError
|
|
32
|
+
{ type: :object, class_name: "?" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def classify(v)
|
|
36
|
+
if NilClass === v then { type: :nil }
|
|
37
|
+
elsif TrueClass === v then { type: :true }
|
|
38
|
+
elsif FalseClass === v then { type: :false }
|
|
39
|
+
elsif Integer === v
|
|
40
|
+
bits = INT_BITS.bind_call(v)
|
|
41
|
+
if bits > HUGE_BITS
|
|
42
|
+
{ type: :huge, digits: (bits * 0.30103).floor + 1 }
|
|
43
|
+
else
|
|
44
|
+
{ type: :integer, value: v }
|
|
45
|
+
end
|
|
46
|
+
elsif Float === v then { type: :float, value: v }
|
|
47
|
+
elsif String === v
|
|
48
|
+
len = STR_LENGTH.bind_call(v)
|
|
49
|
+
{ type: :string, length: len, head: sanitize(STR_SLICE.bind_call(v, 0, 160) || "") }
|
|
50
|
+
elsif Array === v then { type: :array, size: ARY_SIZE.bind_call(v) }
|
|
51
|
+
elsif Hash === v then { type: :hash, size: HASH_SIZE.bind_call(v) }
|
|
52
|
+
elsif Symbol === v then { type: :symbol, name: SYM_NAME.bind_call(v) }
|
|
53
|
+
else { type: :object, class_name: class_name(v) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def class_name(v)
|
|
58
|
+
MODULE_NAME.bind_call(KERNEL_CLASS.bind_call(v)) || "(anonymous)"
|
|
59
|
+
rescue TypeError
|
|
60
|
+
"BasicObject"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sanitize(s)
|
|
64
|
+
s = s.dup.force_encoding(Encoding::UTF_8)
|
|
65
|
+
s = s.scrub("?")
|
|
66
|
+
s.gsub(/[\p{Cc}\p{Cf}]/) { |c| c == "\n" ? " " : "." }
|
|
67
|
+
rescue StandardError
|
|
68
|
+
"?"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def message(exc)
|
|
72
|
+
sanitize(EXC_MESSAGE.bind_call(exc).to_s.lines.first.to_s.chomp)
|
|
73
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
74
|
+
""
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def exception(exc, code = "")
|
|
78
|
+
klass = MODULE_NAME.bind_call(KERNEL_CLASS.bind_call(exc)) || "Exception"
|
|
79
|
+
msg = message(exc)
|
|
80
|
+
info = { class_name: klass, message: msg }
|
|
81
|
+
case exc
|
|
82
|
+
when SyntaxError
|
|
83
|
+
info[:type] = :syntax
|
|
84
|
+
info.merge!(syntax_location(code))
|
|
85
|
+
when NoMethodError
|
|
86
|
+
info[:type] = :nomethod
|
|
87
|
+
info[:name] = safe_name(exc)
|
|
88
|
+
info[:receiver] = receiver_name(exc, msg)
|
|
89
|
+
when NameError
|
|
90
|
+
info[:type] = :name
|
|
91
|
+
info[:name] = safe_name(exc)
|
|
92
|
+
when TypeError
|
|
93
|
+
info[:type] = :type
|
|
94
|
+
info[:sides] = type_sides(msg)
|
|
95
|
+
when ArgumentError
|
|
96
|
+
info[:type] = :argument
|
|
97
|
+
info[:sides] = argument_sides(msg)
|
|
98
|
+
else
|
|
99
|
+
info[:type] = interrupt?(exc) ? :interrupt : :other
|
|
100
|
+
end
|
|
101
|
+
info
|
|
102
|
+
rescue StandardError
|
|
103
|
+
{ type: :other, class_name: "Exception", message: "" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def interrupt?(exc)
|
|
107
|
+
Interrupt === exc || (defined?(IRB::Abort) && IRB::Abort === exc)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def safe_name(exc)
|
|
111
|
+
n = NameError.instance_method(:name).bind_call(exc)
|
|
112
|
+
Symbol === n ? SYM_NAME.bind_call(n) : nil
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def receiver_name(exc, msg)
|
|
118
|
+
if (m = msg.match(/for (?:an instance of |)([A-Z][\w:]*|nil|true|false)/))
|
|
119
|
+
return m[1]
|
|
120
|
+
end
|
|
121
|
+
r = NameError.instance_method(:receiver).bind_call(exc)
|
|
122
|
+
Module === r ? MODULE_NAME.bind_call(r).to_s : class_name(r)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
"?"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def syntax_location(code)
|
|
128
|
+
return {} unless defined?(Prism)
|
|
129
|
+
err = Prism.parse(code).errors.first
|
|
130
|
+
return {} unless err
|
|
131
|
+
loc = err.location
|
|
132
|
+
{ line: loc.start_line, column: loc.start_column, detail: sanitize(err.message) }
|
|
133
|
+
rescue StandardError
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def type_sides(msg)
|
|
138
|
+
if (m = msg.match(/(?:no implicit conversion of|conversion of) (\S+) into (\S+)/))
|
|
139
|
+
[m[1], m[2]]
|
|
140
|
+
elsif (m = msg.match(/(\S+) can't be coerced into (\S+)/))
|
|
141
|
+
[m[1], m[2]]
|
|
142
|
+
elsif (m = msg.match(/wrong argument type (\S+) \(expected (\S+)\)/))
|
|
143
|
+
[m[1], m[2]]
|
|
144
|
+
else
|
|
145
|
+
%w[TYPE TYPE]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def argument_sides(msg)
|
|
150
|
+
if (m = msg.match(/given (\d+), expected ([\d.+\-]+)/))
|
|
151
|
+
["given #{m[1]}", "expected #{m[2]}"]
|
|
152
|
+
elsif (m = msg.match(/(missing|unknown) keywords?: (.+)/))
|
|
153
|
+
[m[1], m[2][0, 20]]
|
|
154
|
+
else
|
|
155
|
+
%w[ARG ARG]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# Every dependency on Reline internals lives in this file.
|
|
5
|
+
#
|
|
6
|
+
# Tested with Reline 0.6.x. Public hooks used: add_dialog_proc,
|
|
7
|
+
# output_modifier_proc. Internal ones (prepended): LineEditor#input_key,
|
|
8
|
+
# #handle_signal (called every 10ms while Reline waits for a key -- our
|
|
9
|
+
# animation tick), #render, #render_finished, Core#readmultiline.
|
|
10
|
+
module RelineAdapter
|
|
11
|
+
SUPPORTED = Gem::Requirement.new(">= 0.5.0", "< 0.8")
|
|
12
|
+
DIALOGS = %i[dopa_strip dopa_trail dopa_hud].freeze
|
|
13
|
+
TICK = 1 / 30.0
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_reader :fx, :editor
|
|
17
|
+
|
|
18
|
+
def supported?
|
|
19
|
+
defined?(Reline::VERSION) && SUPPORTED.satisfied_by?(Gem::Version.new(Reline::VERSION))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def install(fx)
|
|
23
|
+
@fx = fx
|
|
24
|
+
return false unless supported?
|
|
25
|
+
return true if @installed
|
|
26
|
+
Reline::LineEditor.prepend(LineEditorHook)
|
|
27
|
+
Reline::Core.prepend(CoreHook)
|
|
28
|
+
DIALOGS.each do |name|
|
|
29
|
+
kind = name.to_s.delete_prefix("dopa_").to_sym
|
|
30
|
+
Reline.add_dialog_proc(name, ->() { Dopairb::RelineAdapter.dialog(kind, self) }, nil)
|
|
31
|
+
end
|
|
32
|
+
@installed = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def active?
|
|
36
|
+
@installed && Dopairb.active? && !@broken
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def keys? = active? && Dopairb.config.keys
|
|
40
|
+
|
|
41
|
+
# Something went wrong inside our hooks: stop decorating, keep the REPL.
|
|
42
|
+
def broken!(e)
|
|
43
|
+
Dopairb.debug(e)
|
|
44
|
+
@broken = true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
48
|
+
|
|
49
|
+
# ---- called from hooks ------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def before_read(core)
|
|
52
|
+
wrap_output_modifier(core)
|
|
53
|
+
@editor = core.line_editor
|
|
54
|
+
return unless keys?
|
|
55
|
+
@fx.reset_line
|
|
56
|
+
# Keep a lane of blank rows under the prompt for the effect strip.
|
|
57
|
+
n = InputFx::STRIP_ROWS
|
|
58
|
+
Term.write("\eD" * n + "\e[#{n}A")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wrap_output_modifier(core)
|
|
62
|
+
orig = core.output_modifier_proc
|
|
63
|
+
return if orig&.instance_variable_get(:@dopairb)
|
|
64
|
+
memo = [nil, nil, nil]
|
|
65
|
+
wrapper = proc do |input, complete:|
|
|
66
|
+
# highlights re-render the same text many times; colorize it once
|
|
67
|
+
if memo[0] == input && memo[1] == complete
|
|
68
|
+
colored = memo[2]
|
|
69
|
+
else
|
|
70
|
+
colored = orig ? orig.call(input, complete: complete) : nil
|
|
71
|
+
memo.replace([input.dup, complete, colored])
|
|
72
|
+
end
|
|
73
|
+
if complete || !RelineAdapter.keys?
|
|
74
|
+
colored
|
|
75
|
+
else
|
|
76
|
+
begin
|
|
77
|
+
RelineAdapter.fx.apply(colored || Reline::Unicode.escape_for_print(input), input)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
RelineAdapter.broken!(e)
|
|
80
|
+
colored
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
wrapper.instance_variable_set(:@dopairb, true)
|
|
85
|
+
core.output_modifier_proc = wrapper
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def after_key(le, key, before, prev_x, pasting)
|
|
89
|
+
@editor = le
|
|
90
|
+
return if le.finished?
|
|
91
|
+
after = [le.whole_lines, le.instance_variable_get(:@line_index), le.instance_variable_get(:@byte_pointer)]
|
|
92
|
+
cur_x, = le.wrapped_cursor_position
|
|
93
|
+
@fx.on_key(key.method_symbol, before, after, prev_x: prev_x, cur_x: cur_x, pasting: pasting)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def layout(le)
|
|
97
|
+
key = [le.whole_lines, le.instance_variable_get(:@byte_pointer), le.instance_variable_get(:@line_index), le.screen_width, le.screen_scroll_top]
|
|
98
|
+
return @layout[1] if @layout && @layout[0] == key
|
|
99
|
+
lay = compute_layout(le)
|
|
100
|
+
@layout = [key.map { |k| k.frozen? ? k : k.dup }, lay]
|
|
101
|
+
lay
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compute_layout(le)
|
|
105
|
+
x, y = le.wrapped_cursor_position
|
|
106
|
+
top = le.screen_scroll_top
|
|
107
|
+
rows = le.wrapped_prompt_and_input_lines.flatten(1)
|
|
108
|
+
prompt, line = rows[y] || ["", ""]
|
|
109
|
+
InputFx::Layout.new(cursor_x: x, cursor_row: y - top,
|
|
110
|
+
row_end_x: Term.str_width(prompt.to_s) + Term.str_width(line.to_s),
|
|
111
|
+
last_row: rows.size - 1 - top, screen_width: le.screen_width,
|
|
112
|
+
room_below: le.rest_height(y))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def dialog(kind, scope)
|
|
116
|
+
return nil unless keys? && @editor
|
|
117
|
+
lay = layout(@editor)
|
|
118
|
+
info = case kind
|
|
119
|
+
when :hud
|
|
120
|
+
r = @fx.hud_render(lay)
|
|
121
|
+
r && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(r[0], -1), contents: r[1], height: 1)
|
|
122
|
+
when :trail
|
|
123
|
+
r = @fx.trail_render(lay)
|
|
124
|
+
r && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(r[0], -1), contents: r[1], height: 1)
|
|
125
|
+
when :strip
|
|
126
|
+
return nil if scope.completion_journey_data
|
|
127
|
+
dy = lay.last_row - lay.cursor_row
|
|
128
|
+
return nil if lay.room_below - dy < InputFx::STRIP_ROWS
|
|
129
|
+
lines = @fx.strip_render(lay)
|
|
130
|
+
lines && Reline::DialogRenderInfo.new(pos: Reline::CursorPos.new(0, dy), contents: lines,
|
|
131
|
+
width: lay.screen_width - 1, height: InputFx::STRIP_ROWS)
|
|
132
|
+
end
|
|
133
|
+
info
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
broken!(e)
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def tick(le)
|
|
140
|
+
return unless keys?
|
|
141
|
+
t = now
|
|
142
|
+
return if @last_tick && t - @last_tick < TICK
|
|
143
|
+
@last_tick = t
|
|
144
|
+
@editor = le
|
|
145
|
+
return unless @fx.animating?(t)
|
|
146
|
+
le.__send__(:dopairb_refresh)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
broken!(e)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def hl_dirty? = keys? && @fx.hl_dirty?
|
|
152
|
+
|
|
153
|
+
# Snapshot what render_finished is about to print.
|
|
154
|
+
def capture(le, rows, lines)
|
|
155
|
+
@last_input = InputShot.new(rows: rows, lines: lines, code: le.whole_buffer, screen_width: le.screen_width)
|
|
156
|
+
rescue StandardError => e
|
|
157
|
+
Dopairb.debug(e)
|
|
158
|
+
@last_input = nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The shot of the input that produced `code`, if it is still on screen.
|
|
162
|
+
def take_input(code)
|
|
163
|
+
shot = @last_input
|
|
164
|
+
@last_input = nil
|
|
165
|
+
return nil unless shot
|
|
166
|
+
return nil unless shot.code.chomp == code.chomp
|
|
167
|
+
return nil if shot.rows.size > Term.rows - 3
|
|
168
|
+
shot
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
module LineEditorHook
|
|
173
|
+
def input_key(key)
|
|
174
|
+
return super unless RelineAdapter.keys?
|
|
175
|
+
if @in_pasting
|
|
176
|
+
# keep pastes cheap: no layout work, just count
|
|
177
|
+
RelineAdapter.fx.on_key(nil, nil, nil, prev_x: nil, cur_x: nil, pasting: true) unless key.char.nil?
|
|
178
|
+
return super
|
|
179
|
+
end
|
|
180
|
+
before = nil
|
|
181
|
+
prev_x = nil
|
|
182
|
+
pasting = false
|
|
183
|
+
begin
|
|
184
|
+
before = [whole_lines.map(&:dup), @line_index, @byte_pointer]
|
|
185
|
+
prev_x, = wrapped_cursor_position
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
RelineAdapter.broken!(e)
|
|
188
|
+
end
|
|
189
|
+
result = super
|
|
190
|
+
if before
|
|
191
|
+
begin
|
|
192
|
+
RelineAdapter.after_key(self, key, before, prev_x, pasting)
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
RelineAdapter.broken!(e)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
result
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_signal
|
|
201
|
+
super
|
|
202
|
+
RelineAdapter.tick(self) unless @in_pasting || finished?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def render
|
|
206
|
+
@cache.delete(:modified_lines) if RelineAdapter.hl_dirty?
|
|
207
|
+
super
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def render_finished
|
|
211
|
+
if RelineAdapter.active?
|
|
212
|
+
begin
|
|
213
|
+
lines = []
|
|
214
|
+
rows = @buffer_of_lines.size.times.flat_map do |i|
|
|
215
|
+
prompt = Reline::Unicode.strip_non_printing_start_end(prompt_list[i])
|
|
216
|
+
line = prompt + modified_lines[i]
|
|
217
|
+
lines << [Term.str_width(prompt), whole_lines[i]]
|
|
218
|
+
wrapped = split_line_by_width(line, screen_width)
|
|
219
|
+
wrapped.last.empty? ? split_line_by_width("#{line} ", screen_width) : wrapped
|
|
220
|
+
end
|
|
221
|
+
RelineAdapter.capture(self, rows, lines)
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
Dopairb.debug(e)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
super
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def dopairb_refresh
|
|
232
|
+
x, y = wrapped_cursor_position
|
|
233
|
+
@dialogs.each do |d|
|
|
234
|
+
update_each_dialog(d, x, y - screen_scroll_top) if RelineAdapter::DIALOGS.include?(d.name)
|
|
235
|
+
end
|
|
236
|
+
render
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
module CoreHook
|
|
241
|
+
def readmultiline(prompt = "", add_hist = false, &block)
|
|
242
|
+
RelineAdapter.before_read(self) if RelineAdapter.active?
|
|
243
|
+
super
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# Snapshot of the submitted input as it sits on screen right above the cursor.
|
|
5
|
+
class InputShot
|
|
6
|
+
attr_reader :rows, :plain, :code, :prompt_widths, :lines, :screen_width
|
|
7
|
+
|
|
8
|
+
# rows: physical rows (with SGR) exactly as Reline printed them
|
|
9
|
+
# lines: [[prompt_width, code_line], ...] per logical line
|
|
10
|
+
def initialize(rows:, lines:, code:, screen_width:)
|
|
11
|
+
@rows = rows
|
|
12
|
+
@plain = rows.map { |r| r.gsub(/\e\[[\d;?]*[A-Za-z]/, "").gsub(/[\x01\x02]/, "") }
|
|
13
|
+
@lines = lines
|
|
14
|
+
@code = code
|
|
15
|
+
@screen_width = screen_width
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def size = @rows.size
|
|
19
|
+
|
|
20
|
+
# [row, x] on screen of (logical line index, byte column)
|
|
21
|
+
def locate(line_idx, byte_col)
|
|
22
|
+
row = 0
|
|
23
|
+
@lines.each_with_index do |(pw, text), i|
|
|
24
|
+
if i == line_idx
|
|
25
|
+
prefix = text.byteslice(0, byte_col).to_s.scrub("?")
|
|
26
|
+
x = pw + Term.str_width(prefix)
|
|
27
|
+
return [row + x / @screen_width, x % @screen_width]
|
|
28
|
+
end
|
|
29
|
+
row += (pw + Term.str_width(text)) / @screen_width + 1
|
|
30
|
+
end
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [[row, x, width], ...] for each word-bounded occurrence of name
|
|
35
|
+
def find(name)
|
|
36
|
+
return [] if name.nil? || name.empty?
|
|
37
|
+
hits = []
|
|
38
|
+
re = /(?<![\w@$])#{Regexp.escape(name)}(?![\w?!])/
|
|
39
|
+
@lines.each_with_index do |(_, text), i|
|
|
40
|
+
text.to_enum(:scan, re).each do
|
|
41
|
+
b = Regexp.last_match.byteoffset(0)[0]
|
|
42
|
+
pos = locate(i, b)
|
|
43
|
+
hits << [pos[0], pos[1], Term.str_width(name)] if pos
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
hits
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Everything a scene needs to know about where it is drawing.
|
|
51
|
+
Ctx = Struct.new(:w, :rows, :depth, :config, :rng, :input, :pre, :compact, keyword_init: true)
|
|
52
|
+
|
|
53
|
+
class Scene
|
|
54
|
+
attr_reader :ctx, :event
|
|
55
|
+
|
|
56
|
+
def initialize(ctx, event = nil)
|
|
57
|
+
@ctx = ctx
|
|
58
|
+
@event = event
|
|
59
|
+
@rng = ctx.rng
|
|
60
|
+
@w = ctx.w
|
|
61
|
+
setup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def setup; end
|
|
65
|
+
|
|
66
|
+
def height = 1
|
|
67
|
+
def length = 0.4
|
|
68
|
+
def top? = false
|
|
69
|
+
def big? = false
|
|
70
|
+
def mega? = false
|
|
71
|
+
def bell? = mega?
|
|
72
|
+
def trail = nil
|
|
73
|
+
def shake(_t) = 0
|
|
74
|
+
def flash_at = nil
|
|
75
|
+
|
|
76
|
+
# Wind-up before the main animation (input row glow); 0 when not wanted.
|
|
77
|
+
def pre = top? ? ctx.pre.to_f : 0.0
|
|
78
|
+
def total_length = pre + length
|
|
79
|
+
|
|
80
|
+
# Top rows (the input) as canvas, or nil to leave them untouched.
|
|
81
|
+
def draw_top(c, t)
|
|
82
|
+
return nil unless t < pre
|
|
83
|
+
glow_input(c, t / pre)
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def draw(_c, _t); end
|
|
88
|
+
|
|
89
|
+
def w = @w
|
|
90
|
+
def depth = ctx.depth
|
|
91
|
+
|
|
92
|
+
def paint(text, col, bold: false)
|
|
93
|
+
return text if depth == :none && !bold
|
|
94
|
+
"#{Color.sgr(col, nil, bold, depth)}#{text}\e[0m"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sub_bits(extra = [])
|
|
98
|
+
bits = extra.dup
|
|
99
|
+
return bits unless event
|
|
100
|
+
bits << "+#{Fx.number_with_commas(event.gain)}" if event.gain.to_i > 0
|
|
101
|
+
bits << "COMBO #{event.combo}" if event.combo.to_i > 1
|
|
102
|
+
bits << "NEW RECORD" if event.flag?(:new_record) && !bits.include?("NEW RECORD")
|
|
103
|
+
bits << "FIRST HIT" if event.flag?(:first_hit)
|
|
104
|
+
bits << "RECOVERED after #{event.streak} error#{'s' if event.streak > 1}" if event.flag?(:comeback)
|
|
105
|
+
bits
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def sub_line(extra = [])
|
|
109
|
+
sub_bits(extra).join(" ")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
protected
|
|
113
|
+
|
|
114
|
+
def plain_input(c, color = [150, 150, 160])
|
|
115
|
+
ctx.input.plain.each_with_index { |r, y| c.put(0, y, r, color) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A hot band sweeping across the submitted code: "EXECUTE".
|
|
119
|
+
def glow_input(c, p)
|
|
120
|
+
band = p * (@w + 16) - 8
|
|
121
|
+
ctx.input.plain.each_with_index do |r, y|
|
|
122
|
+
x = 0
|
|
123
|
+
r.each_char do |ch|
|
|
124
|
+
d = (x - band - y * 2).abs
|
|
125
|
+
col = d < 6 ? Color.mix([255, 255, 255], [255, 170, 40], d / 6.0) : [200, 200, 215]
|
|
126
|
+
bg = d < 3 ? Color.mix([255, 120, 0], [60, 20, 0], d / 3.0) : nil
|
|
127
|
+
c.put(x, y, ch, col, bg: bg, bold: d < 3)
|
|
128
|
+
x += ch.ord < 0x80 ? 1 : Term.char_width(ch)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def combo_palette(combo = event&.combo.to_i)
|
|
134
|
+
case combo
|
|
135
|
+
when 0..2 then Color::ICE
|
|
136
|
+
when 3..5 then Color::TOXIC
|
|
137
|
+
when 6..9 then Color::GOLD
|
|
138
|
+
when 10..19 then Color::FIRE
|
|
139
|
+
else Color::NEON
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def combo_color(t = 0, combo = event&.combo.to_i)
|
|
144
|
+
return Color.rainbow(t * 1.5) if combo >= 20
|
|
145
|
+
Color.ramp(combo_palette(combo), 0.25)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def ease(t) = Fx.ease_out(t)
|
|
149
|
+
|
|
150
|
+
def best_scale(text)
|
|
151
|
+
ctx.compact ? nil : Fx.best_scale(text, @w)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|