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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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