r2ui 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pure-Ruby replacement for bubbletea 0.1.4's native extension (ext/bubbletea/{extension,program}.c
|
|
4
|
+
# over go/*.go): Bubbletea::Program and the Bubbletea module functions, with the C methods' return
|
|
5
|
+
# values and argument errors. Never loads the real gem.
|
|
6
|
+
|
|
7
|
+
require_relative "terminal"
|
|
8
|
+
require_relative "input_reader"
|
|
9
|
+
require_relative "input"
|
|
10
|
+
require_relative "renderer"
|
|
11
|
+
require_relative "ansi"
|
|
12
|
+
|
|
13
|
+
module R2UI
|
|
14
|
+
module Compat
|
|
15
|
+
module Tea
|
|
16
|
+
# Ruby's C conversion macros, so arguments fail exactly as the extension's do.
|
|
17
|
+
module CArgs
|
|
18
|
+
INT_RANGE = (-2**31..2**31 - 1)
|
|
19
|
+
FIXNUM_RANGE = (-2**62..2**62 - 1)
|
|
20
|
+
ULL_MAX = 2**64 - 1
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def type_name(value)
|
|
25
|
+
case value
|
|
26
|
+
when nil then "nil"
|
|
27
|
+
when true then "true"
|
|
28
|
+
when false then "false"
|
|
29
|
+
else value.class.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Check_Type(value, T_STRING) + StringValueCStr
|
|
34
|
+
def cstr(value)
|
|
35
|
+
raise TypeError, "wrong argument type #{type_name(value)} (expected String)" unless value.is_a?(String)
|
|
36
|
+
raise ArgumentError, "string contains null byte" if value.include?("\0")
|
|
37
|
+
|
|
38
|
+
value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check_Type(value, T_FIXNUM) + FIX2INT
|
|
42
|
+
def fix2int(value)
|
|
43
|
+
unless value.is_a?(Integer) && FIXNUM_RANGE.cover?(value)
|
|
44
|
+
raise TypeError, "wrong argument type #{type_name(value)} (expected Integer)"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
int_range(value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# NUM2INT
|
|
51
|
+
def num2int(value)
|
|
52
|
+
raise TypeError, "no implicit conversion from nil to integer" if value.nil?
|
|
53
|
+
|
|
54
|
+
int_range(to_integer(value))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# NUM2ULL
|
|
58
|
+
def num2ull(value)
|
|
59
|
+
raise TypeError, "no implicit conversion of nil into Integer" if value.nil?
|
|
60
|
+
|
|
61
|
+
integer = to_integer(value)
|
|
62
|
+
raise RangeError, "bignum out of range of unsigned long long" if integer > ULL_MAX || integer < -ULL_MAX
|
|
63
|
+
|
|
64
|
+
integer & ULL_MAX
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_integer(value)
|
|
68
|
+
case value
|
|
69
|
+
when Integer then value
|
|
70
|
+
when Float
|
|
71
|
+
raise RangeError, "float #{value} out of range of integer" if value.nan? || value.infinite?
|
|
72
|
+
|
|
73
|
+
value.to_i
|
|
74
|
+
else
|
|
75
|
+
unless !value.is_a?(TrueClass) && !value.is_a?(FalseClass) && value.respond_to?(:to_int)
|
|
76
|
+
raise TypeError, "no implicit conversion of #{type_name(value)} into Integer"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
value.to_int
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def int_range(integer)
|
|
84
|
+
return integer if INT_RANGE.cover?(integer)
|
|
85
|
+
|
|
86
|
+
raise RangeError, "integer #{integer} too #{integer.positive? ? "big" : "small"} to convert to 'int'"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Program and renderer ids share one counter starting at 1, as Go's getNextID.
|
|
91
|
+
@next_id = 1
|
|
92
|
+
@next_id_lock = Mutex.new
|
|
93
|
+
|
|
94
|
+
def self.next_id
|
|
95
|
+
@next_id_lock.synchronize do
|
|
96
|
+
id = @next_id
|
|
97
|
+
@next_id += 1
|
|
98
|
+
id
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Renderers are global in upstream, keyed by id: any program may drive any id, and unknown ids
|
|
103
|
+
# are ignored.
|
|
104
|
+
@renderers = {}
|
|
105
|
+
@renderers_lock = Mutex.new
|
|
106
|
+
|
|
107
|
+
def self.register_renderer(renderer)
|
|
108
|
+
id = next_id
|
|
109
|
+
@renderers_lock.synchronize { @renderers[id] = renderer }
|
|
110
|
+
id
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.renderer(id) = @renderers_lock.synchronize { @renderers[id] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
module Bubbletea
|
|
119
|
+
# The charmbracelet/x/ansi version upstream's Go build reports.
|
|
120
|
+
def self.upstream_version = +"v0.8.0"
|
|
121
|
+
|
|
122
|
+
def self.version
|
|
123
|
+
format("bubbletea v%s (upstream charmbracelet/x/ansi %s) [r2ui pure Ruby]", VERSION, upstream_version)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.tty?
|
|
127
|
+
$stdin.tty?
|
|
128
|
+
rescue StandardError
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.clear_screen
|
|
133
|
+
R2UI::Compat::Tea::Terminal.write(
|
|
134
|
+
$stdout, R2UI::Compat::Tea::Terminal::ERASE_ENTIRE_SCREEN + R2UI::Compat::Tea::Terminal::CURSOR_HOME
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self._set_window_title(title)
|
|
139
|
+
title = R2UI::Compat::Tea::CArgs.cstr(title)
|
|
140
|
+
R2UI::Compat::Tea::Terminal.write($stdout, R2UI::Compat::Tea::Terminal.window_title(title))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def self.get_key_name(key_type)
|
|
144
|
+
R2UI::Compat::Tea::Input.key_name(R2UI::Compat::Tea::CArgs.fix2int(key_type))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
class Program
|
|
148
|
+
Tea = R2UI::Compat::Tea
|
|
149
|
+
CArgs = Tea::CArgs
|
|
150
|
+
private_constant :Tea, :CArgs
|
|
151
|
+
|
|
152
|
+
# Like Go's tea_free_program, a collected program stops its reader. (Terminal modes it left on are
|
|
153
|
+
# restored by Terminal's exit hook, which holds every dirty terminal.)
|
|
154
|
+
def self.reader_finalizer(state) = proc { state[:reader]&.stop(wait: false) }
|
|
155
|
+
private_class_method :reader_finalizer
|
|
156
|
+
|
|
157
|
+
# Upstream takes no arguments and uses the process's stdin/stdout; the keywords let tests inject a pty.
|
|
158
|
+
def initialize(input: $stdin, output: $stdout)
|
|
159
|
+
@id = Tea.next_id
|
|
160
|
+
@state = { terminal: Tea::Terminal.new(input, output), reader: nil }
|
|
161
|
+
@pending_events = []
|
|
162
|
+
ObjectSpace.define_finalizer(self, self.class.send(:reader_finalizer, @state))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def enter_raw_mode = terminal.enter_raw_mode
|
|
166
|
+
def exit_raw_mode = terminal.exit_raw_mode
|
|
167
|
+
def enter_alt_screen = terminal.enter_alt_screen
|
|
168
|
+
def exit_alt_screen = terminal.exit_alt_screen
|
|
169
|
+
def hide_cursor = terminal.hide_cursor
|
|
170
|
+
def show_cursor = terminal.show_cursor
|
|
171
|
+
def enable_mouse_cell_motion = terminal.enable_mouse_cell_motion
|
|
172
|
+
def enable_mouse_all_motion = terminal.enable_mouse_all_motion
|
|
173
|
+
def disable_mouse = terminal.disable_mouse
|
|
174
|
+
def enable_bracketed_paste = terminal.enable_bracketed_paste
|
|
175
|
+
def disable_bracketed_paste = terminal.disable_bracketed_paste
|
|
176
|
+
def enable_report_focus = terminal.enable_report_focus
|
|
177
|
+
def disable_report_focus = terminal.disable_report_focus
|
|
178
|
+
def terminal_size = terminal.size
|
|
179
|
+
|
|
180
|
+
def start_input_reader
|
|
181
|
+
return true if @state[:reader]
|
|
182
|
+
|
|
183
|
+
@state[:reader] = Tea::InputReader.new(terminal.input).start
|
|
184
|
+
@state[:decoder] = Tea::Input::Decoder.new
|
|
185
|
+
true
|
|
186
|
+
rescue StandardError
|
|
187
|
+
false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Stops consuming input; like Go dropping the reader, anything still buffered is discarded.
|
|
191
|
+
def stop_input_reader
|
|
192
|
+
reader = @state[:reader]
|
|
193
|
+
return unless reader
|
|
194
|
+
|
|
195
|
+
reader.stop
|
|
196
|
+
@state[:reader] = nil
|
|
197
|
+
@state[:decoder] = nil
|
|
198
|
+
@pending_events.clear
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def read_raw_input(timeout_ms)
|
|
203
|
+
timeout_ms = CArgs.num2int(timeout_ms)
|
|
204
|
+
@state[:reader]&.pop(timeout_ms)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Next decoded event as a Hash, or nil on timeout. Unlike upstream, which decodes only the first
|
|
208
|
+
# event of each chunk, the rest of a chunk's events stay queued for the following calls.
|
|
209
|
+
def poll_event(timeout_ms)
|
|
210
|
+
timeout_ms = CArgs.num2int(timeout_ms)
|
|
211
|
+
reader = @state[:reader]
|
|
212
|
+
return nil unless reader
|
|
213
|
+
return @pending_events.shift unless @pending_events.empty?
|
|
214
|
+
|
|
215
|
+
chunk = reader.pop(timeout_ms)
|
|
216
|
+
return nil unless chunk
|
|
217
|
+
|
|
218
|
+
@pending_events.concat(@state[:decoder].feed(chunk))
|
|
219
|
+
@pending_events.shift
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def create_renderer = Tea.register_renderer(Tea::Renderer.new(terminal.output))
|
|
223
|
+
|
|
224
|
+
def render(renderer_id, view)
|
|
225
|
+
view = CArgs.cstr(view)
|
|
226
|
+
Tea.renderer(CArgs.num2ull(renderer_id))&.render(view)
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def renderer_set_size(renderer_id, width, height)
|
|
231
|
+
id = CArgs.num2ull(renderer_id)
|
|
232
|
+
width = CArgs.num2int(width)
|
|
233
|
+
height = CArgs.num2int(height)
|
|
234
|
+
Tea.renderer(id)&.set_size(width, height)
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def renderer_set_alt_screen(renderer_id, enabled)
|
|
239
|
+
Tea.renderer(CArgs.num2ull(renderer_id))&.alt_screen = enabled ? true : false
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def renderer_clear(renderer_id)
|
|
244
|
+
Tea.renderer(CArgs.num2ull(renderer_id))&.clear
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def string_width(str) = Tea::ANSI.string_width(CArgs.cstr(str))
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def terminal = @state[:terminal]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ansi"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
module Compat
|
|
7
|
+
module Tea
|
|
8
|
+
# Port of the bubbletea 0.1.4 gem's Go renderer (go/renderer.go):
|
|
9
|
+
# redraws the whole view in place, line by line, erasing what each line
|
|
10
|
+
# leaves behind. Writes each frame with one write followed by a flush.
|
|
11
|
+
#
|
|
12
|
+
# r2ui addition: with `synchronized` on, each frame is wrapped in DEC mode 2026 (synchronized
|
|
13
|
+
# output), so the terminal paints it at once. Off by default, as upstream.
|
|
14
|
+
#
|
|
15
|
+
# r2ui addition: with `line_diff` on, in the alt screen and with a known size, a frame with
|
|
16
|
+
# as many lines as the last one writes only the lines that differ from it (as Go Bubble Tea
|
|
17
|
+
# v1's standard renderer does), each placed with CUP (or CR LF right after the line above).
|
|
18
|
+
# Anything that may leave the screen unlike the last frame (clear, a resize, alt_screen=)
|
|
19
|
+
# forgets it, so the next frame is drawn whole. Off by default, as upstream.
|
|
20
|
+
class Renderer
|
|
21
|
+
SYNC_BEGIN = "\e[?2026h"
|
|
22
|
+
SYNC_END = "\e[?2026l"
|
|
23
|
+
|
|
24
|
+
def initialize(output, synchronized: false, line_diff: false)
|
|
25
|
+
@output = output
|
|
26
|
+
@synchronized = synchronized ? true : false
|
|
27
|
+
@line_diff = line_diff ? true : false
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
@last_render = "".b
|
|
30
|
+
@last_lines = []
|
|
31
|
+
@lines_rendered = 0
|
|
32
|
+
@width = 0
|
|
33
|
+
@height = 0
|
|
34
|
+
@alt_screen = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def set_size(width, height)
|
|
38
|
+
width = Integer(width)
|
|
39
|
+
height = Integer(height)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@last_lines = [] if width != @width || height != @height
|
|
42
|
+
@width = width
|
|
43
|
+
@height = height
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Every call forgets the diff state, even without a change: the runner calls it after
|
|
48
|
+
# (re-)entering the alt screen, which clears it.
|
|
49
|
+
def alt_screen=(enabled)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
@alt_screen = enabled ? true : false
|
|
52
|
+
@last_lines = []
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def synchronized=(enabled)
|
|
57
|
+
@mutex.synchronize { @synchronized = enabled ? true : false }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def line_diff=(enabled)
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
@line_diff = enabled ? true : false
|
|
63
|
+
@last_lines = []
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render(view)
|
|
68
|
+
view = c_string(view)
|
|
69
|
+
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
return if view == @last_render
|
|
72
|
+
|
|
73
|
+
new_lines = view.empty? ? ["".b] : view.split("\n", -1)
|
|
74
|
+
new_lines = new_lines.last(@height) if @height.positive? && new_lines.length > @height
|
|
75
|
+
|
|
76
|
+
if diffable?(new_lines)
|
|
77
|
+
render_diff(new_lines)
|
|
78
|
+
@last_render = view
|
|
79
|
+
@last_lines = new_lines
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
buffer = +"".b
|
|
84
|
+
buffer << SYNC_BEGIN if @synchronized
|
|
85
|
+
buffer << ANSI::CURSOR_HOME_POSITION if @alt_screen
|
|
86
|
+
if !@alt_screen && @lines_rendered > 1
|
|
87
|
+
buffer << cursor_up(@lines_rendered - 1)
|
|
88
|
+
end
|
|
89
|
+
buffer << "\r" unless @alt_screen
|
|
90
|
+
|
|
91
|
+
new_lines.each_with_index do |line, i|
|
|
92
|
+
if @width.positive? && ANSI.string_width(line) > @width
|
|
93
|
+
line = ANSI.truncate(line, @width, "").b
|
|
94
|
+
end
|
|
95
|
+
buffer << line << erase_line(0)
|
|
96
|
+
buffer << "\r\n" if i < new_lines.length - 1
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if new_lines.length < @lines_rendered
|
|
100
|
+
(new_lines.length...@lines_rendered).each do
|
|
101
|
+
buffer << "\r\n" << erase_line(2)
|
|
102
|
+
end
|
|
103
|
+
buffer << cursor_up(@lines_rendered - new_lines.length) unless @alt_screen
|
|
104
|
+
end
|
|
105
|
+
buffer << "\r" unless @alt_screen
|
|
106
|
+
buffer << SYNC_END if @synchronized
|
|
107
|
+
|
|
108
|
+
emit(buffer)
|
|
109
|
+
|
|
110
|
+
@last_render = view
|
|
111
|
+
@last_lines = new_lines
|
|
112
|
+
@lines_rendered = new_lines.length
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clear
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
emit(ANSI::ERASE_ENTIRE_SCREEN + ANSI::CURSOR_HOME_POSITION)
|
|
120
|
+
@last_render = "".b
|
|
121
|
+
@last_lines = []
|
|
122
|
+
@lines_rendered = 0
|
|
123
|
+
end
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# A known size keeps every line on its own row (wide lines are cut), so CUP rows are exact.
|
|
130
|
+
def diffable?(new_lines)
|
|
131
|
+
@line_diff && @alt_screen && @width.positive? && @height.positive? &&
|
|
132
|
+
!@last_lines.empty? && new_lines.length == @last_lines.length
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Writes only the changed lines; unchanged ones are neither written nor measured.
|
|
136
|
+
def render_diff(new_lines)
|
|
137
|
+
buffer = nil
|
|
138
|
+
previous = nil
|
|
139
|
+
new_lines.each_with_index do |line, i|
|
|
140
|
+
next if line == @last_lines[i]
|
|
141
|
+
|
|
142
|
+
buffer ||= @synchronized ? SYNC_BEGIN.b : +"".b
|
|
143
|
+
buffer << (previous == i - 1 ? "\r\n" : cursor_position(i + 1))
|
|
144
|
+
line = ANSI.truncate(line, @width, "").b if ANSI.string_width(line) > @width
|
|
145
|
+
buffer << line << erase_line(0)
|
|
146
|
+
previous = i
|
|
147
|
+
end
|
|
148
|
+
return unless buffer
|
|
149
|
+
|
|
150
|
+
buffer << SYNC_END if @synchronized
|
|
151
|
+
emit(buffer)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# CUP to column 1 of `row` (1-based): CSI H for row 1, CSI row H otherwise.
|
|
155
|
+
def cursor_position(row)
|
|
156
|
+
row == 1 ? ANSI::CURSOR_HOME_POSITION : "\e[#{row}H"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The C glue passes the view through StringValueCStr.
|
|
160
|
+
def c_string(view)
|
|
161
|
+
unless view.is_a?(String)
|
|
162
|
+
raise TypeError, "no implicit conversion of #{view.class} into String" unless view.respond_to?(:to_str)
|
|
163
|
+
|
|
164
|
+
view = view.to_str
|
|
165
|
+
end
|
|
166
|
+
raise ArgumentError, "string contains null byte" if view.include?("\0")
|
|
167
|
+
|
|
168
|
+
view.b
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# x/ansi CursorUp: CSI A for 1, CSI n A otherwise.
|
|
172
|
+
def cursor_up(n)
|
|
173
|
+
"\e[#{n > 1 ? n : ""}A"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# x/ansi EraseLine: CSI K for 0, CSI n K otherwise.
|
|
177
|
+
def erase_line(n)
|
|
178
|
+
"\e[#{n.positive? ? n : ""}K"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Upstream ignores stdout write errors, so a hangup (EIO/EPIPE) or a
|
|
182
|
+
# closed stream doesn't raise into the program; see Terminal.write.
|
|
183
|
+
def emit(bytes)
|
|
184
|
+
@output.write(bytes)
|
|
185
|
+
@output.flush
|
|
186
|
+
rescue IOError, SystemCallError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|