canopus 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.
Files changed (146) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/docs/adr/001-runtime-gem-components.md +24 -0
  6. data/docs/adr/002-bounded-diff-engine.md +22 -0
  7. data/docs/adr/003-background-language-analysis.md +22 -0
  8. data/docs/adr/004-persistent-display-map.md +23 -0
  9. data/docs/adr/005-windows-runtime.md +27 -0
  10. data/docs/adr/README.md +36 -0
  11. data/docs/distribution.md +27 -0
  12. data/docs/lsp.md +38 -0
  13. data/docs/performance.md +40 -0
  14. data/docs/snippets.md +32 -0
  15. data/docs/vim.md +24 -0
  16. data/docs/workspace_edits.md +32 -0
  17. data/examples/native_smoke.rb +25 -0
  18. data/examples/plugins/word_count.rb +11 -0
  19. data/exe/canopus +19 -0
  20. data/lib/canopus/block_map.rb +19 -0
  21. data/lib/canopus/buffer.rb +293 -0
  22. data/lib/canopus/cli.rb +171 -0
  23. data/lib/canopus/controller.rb +507 -0
  24. data/lib/canopus/data_compat.rb +25 -0
  25. data/lib/canopus/display_map/line_builder.rb +54 -0
  26. data/lib/canopus/display_map/line_set.rb +8 -0
  27. data/lib/canopus/display_map/pending_line_set.rb +9 -0
  28. data/lib/canopus/display_map/summary.rb +23 -0
  29. data/lib/canopus/display_map/worker.rb +89 -0
  30. data/lib/canopus/display_map.rb +350 -0
  31. data/lib/canopus/display_point.rb +5 -0
  32. data/lib/canopus/editor/snippet_expandable.rb +281 -0
  33. data/lib/canopus/editor.rb +445 -0
  34. data/lib/canopus/error.rb +5 -0
  35. data/lib/canopus/fold_map.rb +87 -0
  36. data/lib/canopus/git/blame.rb +50 -0
  37. data/lib/canopus/git/commit.rb +7 -0
  38. data/lib/canopus/git/corrupt_object.rb +7 -0
  39. data/lib/canopus/git/diff.rb +131 -0
  40. data/lib/canopus/git/index.rb +94 -0
  41. data/lib/canopus/git/object_database.rb +45 -0
  42. data/lib/canopus/git/pack.rb +186 -0
  43. data/lib/canopus/git/repository.rb +313 -0
  44. data/lib/canopus/git/status.rb +74 -0
  45. data/lib/canopus/git/tree_entry.rb +7 -0
  46. data/lib/canopus/git.rb +15 -0
  47. data/lib/canopus/icon_theme.rb +43 -0
  48. data/lib/canopus/language/background_analysis/job.rb +12 -0
  49. data/lib/canopus/language/background_analysis/scheduler.rb +70 -0
  50. data/lib/canopus/language/background_analysis.rb +280 -0
  51. data/lib/canopus/language/definition.rb +7 -0
  52. data/lib/canopus/language/document.rb +143 -0
  53. data/lib/canopus/language/symbol.rb +7 -0
  54. data/lib/canopus/language/syntax_worker.rb +91 -0
  55. data/lib/canopus/language.rb +42 -0
  56. data/lib/canopus/lazy_rope.rb +218 -0
  57. data/lib/canopus/lsp/client.rb +299 -0
  58. data/lib/canopus/lsp/error.rb +7 -0
  59. data/lib/canopus/lsp/future/subscription.rb +9 -0
  60. data/lib/canopus/lsp/future.rb +87 -0
  61. data/lib/canopus/lsp/protocol.rb +83 -0
  62. data/lib/canopus/lsp/server_error.rb +13 -0
  63. data/lib/canopus/lsp/timeout.rb +7 -0
  64. data/lib/canopus/lsp/transport.rb +123 -0
  65. data/lib/canopus/lsp.rb +19 -0
  66. data/lib/canopus/markdown.rb +69 -0
  67. data/lib/canopus/match_data_compat.rb +17 -0
  68. data/lib/canopus/multi_buffer.rb +266 -0
  69. data/lib/canopus/pane.rb +61 -0
  70. data/lib/canopus/patch/composite.rb +12 -0
  71. data/lib/canopus/patch/reload.rb +18 -0
  72. data/lib/canopus/patch.rb +27 -0
  73. data/lib/canopus/performance_recorder.rb +207 -0
  74. data/lib/canopus/plugins/api.rb +50 -0
  75. data/lib/canopus/plugins/isolated_runtime.rb +167 -0
  76. data/lib/canopus/plugins/local_runtime.rb +25 -0
  77. data/lib/canopus/plugins/permission_denied.rb +7 -0
  78. data/lib/canopus/plugins/registry.rb +30 -0
  79. data/lib/canopus/plugins.rb +11 -0
  80. data/lib/canopus/project/ignore_matcher.rb +104 -0
  81. data/lib/canopus/project/search.rb +164 -0
  82. data/lib/canopus/project/search_worker/cancelled.rb +3 -0
  83. data/lib/canopus/project/search_worker/runner.rb +9 -0
  84. data/lib/canopus/project/search_worker.rb +108 -0
  85. data/lib/canopus/project/tree.rb +48 -0
  86. data/lib/canopus/project/watcher.rb +59 -0
  87. data/lib/canopus/project.rb +127 -0
  88. data/lib/canopus/regexp_compat.rb +30 -0
  89. data/lib/canopus/save_conflict.rb +5 -0
  90. data/lib/canopus/selection.rb +11 -0
  91. data/lib/canopus/settings.rb +118 -0
  92. data/lib/canopus/snippet/transform.rb +209 -0
  93. data/lib/canopus/snippet.rb +183 -0
  94. data/lib/canopus/tab_map.rb +30 -0
  95. data/lib/canopus/terminal/cell.rb +7 -0
  96. data/lib/canopus/terminal/grid.rb +321 -0
  97. data/lib/canopus/terminal/pty.rb +103 -0
  98. data/lib/canopus/terminal/scrollback.rb +38 -0
  99. data/lib/canopus/terminal/vt.rb +398 -0
  100. data/lib/canopus/terminal.rb +13 -0
  101. data/lib/canopus/theme.rb +43 -0
  102. data/lib/canopus/version.rb +5 -0
  103. data/lib/canopus/vim/commandable.rb +148 -0
  104. data/lib/canopus/vim/motionable.rb +321 -0
  105. data/lib/canopus/vim/operator_capable.rb +377 -0
  106. data/lib/canopus/vim/text_object_selectable.rb +141 -0
  107. data/lib/canopus/vim.rb +426 -0
  108. data/lib/canopus/workspace/edit/executable.rb +139 -0
  109. data/lib/canopus/workspace/edit/node.rb +8 -0
  110. data/lib/canopus/workspace/edit/plan.rb +170 -0
  111. data/lib/canopus/workspace/edit/resource_preparable.rb +98 -0
  112. data/lib/canopus/workspace/edit.rb +6 -0
  113. data/lib/canopus/workspace/file_change_aware.rb +40 -0
  114. data/lib/canopus/workspace/file_previewable.rb +31 -0
  115. data/lib/canopus/workspace/git_aware.rb +140 -0
  116. data/lib/canopus/workspace/language_aware.rb +413 -0
  117. data/lib/canopus/workspace/language_server_configurable.rb +181 -0
  118. data/lib/canopus/workspace/project_searchable.rb +208 -0
  119. data/lib/canopus/workspace/project_tree_editable.rb +82 -0
  120. data/lib/canopus/workspace/session_persistable.rb +153 -0
  121. data/lib/canopus/workspace/settings_aware.rb +91 -0
  122. data/lib/canopus/workspace/view/terminal_presentable.rb +185 -0
  123. data/lib/canopus/workspace/view.rb +643 -0
  124. data/lib/canopus/workspace.rb +525 -0
  125. data/lib/canopus/wrap_map.rb +108 -0
  126. data/lib/canopus.rb +24 -0
  127. data/sig/canopus.rbs +375 -0
  128. data/sig/controller.rbs +55 -0
  129. data/sig/display_stages.rbs +49 -0
  130. data/sig/git.rbs +140 -0
  131. data/sig/lsp.rbs +99 -0
  132. data/sig/markdown.rbs +11 -0
  133. data/sig/performance_recorder.rbs +23 -0
  134. data/sig/search_services.rbs +8 -0
  135. data/sig/services.rbs +81 -0
  136. data/sig/snippet.rbs +42 -0
  137. data/sig/terminal.rbs +125 -0
  138. data/sig/vim.rbs +27 -0
  139. data/sig/workspace_edits.rbs +11 -0
  140. data/sig/workspace_services.rbs +117 -0
  141. data/tools/certify_snippet_regex.rb +35 -0
  142. data/tools/check_dependencies.rb +80 -0
  143. data/tools/native_check.rb +70 -0
  144. data/tools/package.rb +113 -0
  145. data/tools/package_test.rb +32 -0
  146. metadata +314 -0
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "denebola"
4
+ require "zaniah"
5
+
6
+ module Canopus
7
+ module Terminal
8
+ class Grid
9
+ attr_reader :columns, :rows, :cells, :scrollback, :cursor_x, :cursor_y, :scroll_top, :scroll_bottom
10
+ attr_accessor :foreground, :background, :attributes, :hyperlink, :autowrap, :insert_mode, :origin_mode, :cursor_visible
11
+
12
+ def initialize(columns: 80, rows: 24, scrollback: 10_000)
13
+ validate_dimensions(columns, rows)
14
+ @columns, @rows = columns, rows
15
+ @scrollback = Scrollback.new(scrollback)
16
+ reset
17
+ end
18
+
19
+ def reset
20
+ @foreground = @background = @hyperlink = nil
21
+ @attributes = {}.freeze
22
+ @autowrap = @cursor_visible = true
23
+ @insert_mode = @origin_mode = false
24
+ @cursor_x = @cursor_y = 0
25
+ @wrap_pending = false
26
+ @alternate = nil
27
+ @scroll_top, @scroll_bottom = 0, rows - 1
28
+ @cells = Array.new(rows) { blank_row }
29
+ @tabs = (8...columns).step(8).to_a
30
+ save_cursor
31
+ end
32
+
33
+ def [](row, column = nil) = column ? cells[row]&.[](column) : cells[row]
34
+ def alternate? = !@alternate.nil?
35
+ def lines = cells.map { |row| row.map(&:text).join.rstrip }
36
+ def text = lines.join("\n")
37
+
38
+ def put(char)
39
+ width = self.class.width(char)
40
+ previous_x = @wrap_pending ? cursor_x : cursor_x - 1
41
+ if previous_x >= 0 && !char.ascii_only?
42
+ previous_x -= 1 if cells[cursor_y][previous_x].width.zero? && previous_x.positive?
43
+ previous = cells[cursor_y][previous_x]
44
+ joined = previous.text + char
45
+ if joined.match?(/\A\X\z/)
46
+ width = [self.class.width(joined), columns].min
47
+ if previous_x + width > columns && autowrap
48
+ cells[cursor_y][previous_x] = blank
49
+ carriage_return
50
+ linefeed
51
+ return put(joined)
52
+ end
53
+ width = [width, columns - previous_x].min
54
+ clear_wide(cursor_y, previous_x + 1) if width == 2 && previous.width < 2
55
+ cells[cursor_y][previous_x + 1] = blank if width < previous.width
56
+ previous.text, previous.width = joined, width
57
+ cells[cursor_y][previous_x + 1] = previous.dup.tap { |cell| cell.text = ""; cell.width = 0 } if width == 2
58
+ @cursor_x = [previous_x + width, columns - 1].min
59
+ @wrap_pending = previous_x + width >= columns && autowrap
60
+ return
61
+ end
62
+ end
63
+ return if width.zero?
64
+ width = 1 if width > columns
65
+ if @wrap_pending || (width == 2 && cursor_x == columns - 1)
66
+ if autowrap
67
+ @cursor_x = 0
68
+ linefeed
69
+ elsif width == 2
70
+ return
71
+ end
72
+ @wrap_pending = false
73
+ end
74
+ insert_characters(width) if insert_mode
75
+ clear_wide(cursor_y, cursor_x)
76
+ clear_wide(cursor_y, cursor_x + 1) if width == 2
77
+ cells[cursor_y][cursor_x] = Cell.new(text: char, width: width, foreground: foreground,
78
+ background: background, attributes: attributes, hyperlink: hyperlink)
79
+ cells[cursor_y][cursor_x + 1] = Cell.new(text: "", width: 0, foreground: foreground,
80
+ background: background, attributes: attributes, hyperlink: hyperlink) if width == 2
81
+ if cursor_x + width >= columns
82
+ @cursor_x = columns - 1
83
+ @wrap_pending = autowrap
84
+ else
85
+ @cursor_x += width
86
+ end
87
+ end
88
+
89
+ def self.width(char)
90
+ return 1 if char.bytesize == 1 && char.ord.between?(32, 126)
91
+ Zaniah::Unicode.width(char)
92
+ end
93
+
94
+ def move(x: cursor_x, y: cursor_y, relative: false)
95
+ if relative
96
+ x += cursor_x
97
+ y += cursor_y
98
+ end
99
+ top, bottom = origin_mode ? [scroll_top, scroll_bottom] : [0, rows - 1]
100
+ @cursor_x = x.clamp(0, columns - 1)
101
+ @cursor_y = y.clamp(top, bottom)
102
+ @wrap_pending = false
103
+ end
104
+
105
+ def position(row, column)
106
+ move(x: column - 1, y: row - 1 + (origin_mode ? scroll_top : 0))
107
+ end
108
+
109
+ def carriage_return = move(x: 0)
110
+ def backspace = move(x: cursor_x - 1)
111
+
112
+ def linefeed
113
+ @wrap_pending = false
114
+ if cursor_y == scroll_bottom
115
+ scroll_up
116
+ elsif cursor_y < rows - 1
117
+ @cursor_y += 1
118
+ end
119
+ end
120
+
121
+ def reverse_index
122
+ @wrap_pending = false
123
+ cursor_y == scroll_top ? scroll_down : @cursor_y = [0, cursor_y - 1].max
124
+ end
125
+
126
+ def tab(count = 1, backward: false)
127
+ count.times do
128
+ stop = backward ? @tabs.reverse.find { |column| column < cursor_x } : @tabs.find { |column| column > cursor_x }
129
+ move(x: stop || (backward ? 0 : columns - 1))
130
+ end
131
+ end
132
+
133
+ def tab_set = @tabs = (@tabs + [cursor_x]).uniq.sort
134
+ def tab_clear(all: false) = all ? @tabs.clear : @tabs.delete(cursor_x)
135
+
136
+ def margins(top = 1, bottom = rows)
137
+ return unless top >= 1 && top < bottom && bottom <= rows
138
+ @scroll_top, @scroll_bottom = top - 1, bottom - 1
139
+ position(1, 1)
140
+ end
141
+
142
+ def scroll_up(count = 1)
143
+ [count, scroll_bottom - scroll_top + 1].min.times do
144
+ removed = cells.delete_at(scroll_top)
145
+ scrollback.push(removed) if scroll_top.zero? && scroll_bottom == rows - 1 && !alternate?
146
+ cells.insert(scroll_bottom, blank_row)
147
+ end
148
+ end
149
+
150
+ def scroll_down(count = 1)
151
+ [count, scroll_bottom - scroll_top + 1].min.times do
152
+ cells.delete_at(scroll_bottom)
153
+ cells.insert(scroll_top, blank_row)
154
+ end
155
+ end
156
+
157
+ def erase_display(mode = 0)
158
+ case mode
159
+ when 0
160
+ erase_line(0)
161
+ ((cursor_y + 1)...rows).each { |row| cells[row] = blank_row }
162
+ when 1
163
+ (0...cursor_y).each { |row| cells[row] = blank_row }
164
+ erase_line(1)
165
+ when 2 then @cells = Array.new(rows) { blank_row }
166
+ when 3 then scrollback.clear
167
+ end
168
+ end
169
+
170
+ def erase_line(mode = 0)
171
+ first, last = case mode
172
+ when 0 then [cursor_x, columns - 1]
173
+ when 1 then [0, cursor_x]
174
+ when 2 then [0, columns - 1]
175
+ else return
176
+ end
177
+ (first..last).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
178
+ end
179
+
180
+ def erase_characters(count = 1)
181
+ (cursor_x...[cursor_x + count, columns].min).each { |column| clear_wide(cursor_y, column); cells[cursor_y][column] = blank }
182
+ end
183
+
184
+ def insert_characters(count = 1)
185
+ clear_wide(cursor_y, cursor_x) if cells[cursor_y][cursor_x].width.zero?
186
+ cells[cursor_y].insert(cursor_x, *Array.new([count, columns - cursor_x].min) { blank })
187
+ cells[cursor_y] = cells[cursor_y].first(columns)
188
+ normalize_row(cursor_y)
189
+ end
190
+
191
+ def delete_characters(count = 1)
192
+ clear_wide(cursor_y, cursor_x)
193
+ count = [count, columns - cursor_x].min
194
+ cells[cursor_y].slice!(cursor_x, count)
195
+ cells[cursor_y].concat(Array.new(count) { blank })
196
+ normalize_row(cursor_y)
197
+ end
198
+
199
+ def insert_lines(count = 1)
200
+ return unless cursor_y.between?(scroll_top, scroll_bottom)
201
+ [count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(scroll_bottom); cells.insert(cursor_y, blank_row) }
202
+ end
203
+
204
+ def delete_lines(count = 1)
205
+ return unless cursor_y.between?(scroll_top, scroll_bottom)
206
+ [count, scroll_bottom - cursor_y + 1].min.times { cells.delete_at(cursor_y); cells.insert(scroll_bottom, blank_row) }
207
+ end
208
+
209
+ def save_cursor
210
+ @saved = [cursor_x, cursor_y, foreground, background, attributes, hyperlink, origin_mode, @wrap_pending]
211
+ end
212
+
213
+ def restore_cursor
214
+ return unless @saved
215
+ @cursor_x, @cursor_y, @foreground, @background, @attributes, @hyperlink, @origin_mode, @wrap_pending = @saved
216
+ @cursor_x = cursor_x.clamp(0, columns - 1)
217
+ @cursor_y = cursor_y.clamp(0, rows - 1)
218
+ end
219
+
220
+ def alternate(enable, save: true)
221
+ if enable && !alternate?
222
+ save_cursor if save
223
+ @alternate = cells
224
+ @cells = Array.new(rows) { blank_row }
225
+ @cursor_x = @cursor_y = 0
226
+ @scroll_top, @scroll_bottom = 0, rows - 1
227
+ @wrap_pending = false
228
+ elsif !enable && alternate?
229
+ @cells, @alternate = @alternate, nil
230
+ @scroll_top, @scroll_bottom = 0, rows - 1
231
+ restore_cursor if save
232
+ end
233
+ end
234
+
235
+ def resize(columns:, rows:)
236
+ validate_dimensions(columns, rows)
237
+ previous_columns = @columns
238
+ @columns, @rows = columns, rows
239
+ [cells, @alternate].compact.each do |screen|
240
+ while screen.length > rows
241
+ removed = screen.shift
242
+ scrollback.push(removed) unless alternate?
243
+ @cursor_y -= 1 if screen.equal?(cells)
244
+ end
245
+ screen << blank_row while screen.length < rows
246
+ screen.each do |row|
247
+ row.slice!(columns, row.length) if row.length > columns
248
+ row << blank while row.length < columns
249
+ end
250
+ end
251
+ cells.each_index { |index| normalize_row(index) }
252
+ @tabs = (@tabs.select { |column| column < columns } + (previous_columns...columns).select { |column| column.positive? && (column % 8).zero? }).uniq.sort
253
+ @scroll_top, @scroll_bottom = 0, rows - 1
254
+ move
255
+ end
256
+
257
+ # Coordinates are [column, row]; finish is exclusive, including scrollback
258
+ # when history: true. This is suitable for selection/copy without a renderer.
259
+ def selection(start, finish, history: false)
260
+ source = history ? scrollback.to_a + cells : cells
261
+ start, finish = finish, start if ([start[1], start[0]] <=> [finish[1], finish[0]]) == 1
262
+ (start[1]..finish[1]).map do |row|
263
+ next "" unless source[row]
264
+ first = row == start[1] ? start[0] : 0
265
+ last = row == finish[1] ? finish[0] : columns
266
+ source[row][first...last].to_a.map(&:text).join.rstrip
267
+ end.join("\n")
268
+ end
269
+
270
+ def links(row)
271
+ line = cells.fetch(row).map(&:text).join
272
+ explicit = []
273
+ cells[row].each_with_index do |cell, column|
274
+ next unless cell.hyperlink
275
+ if explicit.last && explicit.last[:url] == cell.hyperlink && explicit.last[:end_column] == column
276
+ explicit.last[:end_column] = column + 1
277
+ else
278
+ explicit << {url: cell.hyperlink, column: column, end_column: column + 1}
279
+ end
280
+ end
281
+ line.to_enum(:scan, %r{https?://[^\s<>"']+}).each do
282
+ found = Regexp.last_match
283
+ column = line[0...found.begin(0)].each_char.sum { |char| self.class.width(char) }
284
+ explicit << {url: found[0], column: column, end_column: column + found[0].length}
285
+ end
286
+ explicit.uniq
287
+ end
288
+
289
+ def file_paths(row, cwd: Dir.pwd)
290
+ line = cells.fetch(row).map(&:text).join
291
+ line.scan(%r{(?:\A|[\s("'])([^\s:"'()]+):(\d+)(?::(\d+))?}).filter_map do |name, number, column|
292
+ absolute = File.expand_path(name, cwd)
293
+ {path: absolute, line: number.to_i, column: column ? column.to_i : 1} if File.file?(absolute)
294
+ end
295
+ end
296
+
297
+ private
298
+
299
+ def validate_dimensions(columns, rows)
300
+ raise ArgumentError, "terminal dimensions must be positive integers" unless columns.is_a?(Integer) && rows.is_a?(Integer) && columns.positive? && rows.positive?
301
+ end
302
+
303
+ def blank = Cell.new(text: " ", width: 1, foreground: foreground, background: background, attributes: {}.freeze)
304
+ def blank_row = Array.new(columns) { blank }
305
+
306
+ def clear_wide(row, column)
307
+ return unless column.between?(0, columns - 1)
308
+ cell = cells[row][column]
309
+ cells[row][column - 1] = blank if cell.width.zero? && column.positive?
310
+ cells[row][column + 1] = blank if cell.width == 2 && column + 1 < columns
311
+ end
312
+
313
+ def normalize_row(row)
314
+ cells[row].each_with_index do |cell, column|
315
+ cells[row][column] = blank if (cell.width == 2 && (column == columns - 1 || cells[row][column + 1].width != 0)) ||
316
+ (cell.width.zero? && (column.zero? || cells[row][column - 1].width != 2))
317
+ end
318
+ end
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "shellwords"
5
+
6
+ module Canopus
7
+ module Terminal
8
+ class PTY
9
+ attr_reader :reader, :writer, :pid, :grid, :vt, :status
10
+
11
+ def initialize(command: ENV.fetch("SHELL", "/bin/sh"), cwd: Dir.pwd, columns: 80, rows: 24, env: {}, scrollback: 10_000)
12
+ @grid = Grid.new(columns: columns, rows: rows, scrollback: scrollback)
13
+ if RUBY_PLATFORM.match?(/mswin|mingw/)
14
+ require "zaniah/platform/windows/terminal"
15
+ command = nil if command == "/bin/sh"
16
+ @native = Zaniah::Platform::Windows::Terminal.new(command: command, cwd: cwd, columns: columns, rows: rows, env: env)
17
+ @pid = @native.pid
18
+ @vt = VT.new(grid) { |bytes| write(bytes) }
19
+ return
20
+ end
21
+ require "pty"
22
+ arguments = command.is_a?(Array) ? command : Shellwords.split(command)
23
+ raise ArgumentError, "terminal command required" if arguments.empty?
24
+ @reader, @writer, @pid = ::PTY.spawn({"TERM" => "xterm-256color", "COLORTERM" => "truecolor"}.merge(env), *arguments, chdir: cwd)
25
+ @reader.binmode
26
+ @writer.binmode
27
+ @writer.sync = true
28
+ @vt = VT.new(grid) { |bytes| write(bytes) }
29
+ resize(columns: columns, rows: rows)
30
+ end
31
+
32
+ # nil means EOF; an empty String means that no data was ready yet.
33
+ def read(timeout: 0, max_bytes: 65_536)
34
+ return nil if @eof
35
+ if @native
36
+ data = @native.read_available(limit: max_bytes)
37
+ data ? vt.feed(data) : @eof = true
38
+ return data
39
+ end
40
+ return "" unless IO.select([reader], nil, nil, timeout)
41
+ data = reader.read_nonblock(max_bytes, exception: false)
42
+ return "" if data == :wait_readable
43
+ if data.nil?
44
+ @eof = true
45
+ else
46
+ vt.feed(data)
47
+ end
48
+ data
49
+ rescue Errno::EIO, EOFError
50
+ @eof = true
51
+ nil
52
+ end
53
+
54
+ def write(bytes) = @native ? @native.write(bytes) : writer.write(bytes)
55
+ def paste(text) = write(vt.paste(text))
56
+ def key(name, **modifiers) = write(vt.key(name, **modifiers))
57
+ def mouse(**event) = write(vt.mouse(**event))
58
+
59
+ def resize(columns:, rows:)
60
+ grid.resize(columns: columns, rows: rows)
61
+ @native ? @native.resize(columns, rows) : reader.winsize = [rows, columns]
62
+ end
63
+
64
+ def signal(name = "INT")
65
+ return @native.write("\x03") if @native && name == "INT"
66
+ return @native.close if @native
67
+ Process.kill(name, -pid)
68
+ rescue Errno::ESRCH
69
+ false
70
+ end
71
+
72
+ def alive?
73
+ return @native.alive? if @native
74
+ return false if @status
75
+ result = Process.waitpid2(pid, Process::WNOHANG)
76
+ @status = result.last if result
77
+ !result
78
+ rescue Errno::ECHILD
79
+ false
80
+ end
81
+
82
+ def close
83
+ return @native.close if @native
84
+ signal("HUP") if alive?
85
+ reader.close unless reader.closed?
86
+ writer.close unless writer.closed?
87
+ # A shell may trap HUP: reap it after a short grace period.
88
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 0.5
89
+ while alive? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
90
+ sleep 0.01
91
+ end
92
+ if alive?
93
+ signal("KILL")
94
+ _, @status = Process.waitpid2(pid)
95
+ end
96
+ @eof = true
97
+ self
98
+ rescue Errno::ECHILD
99
+ self
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "denebola"
4
+
5
+ module Canopus
6
+ module Terminal
7
+ class Scrollback
8
+ include Enumerable
9
+ Count = Struct.new(:lines) do
10
+ def self.zero = new(0)
11
+ def +(other) = self.class.new(lines + other.lines)
12
+ end
13
+ Row = Struct.new(:cells) do
14
+ def summary = Count.new(1)
15
+ end
16
+ attr_reader :tree, :limit
17
+
18
+ def initialize(limit)
19
+ raise ArgumentError, "scrollback limit must be nonnegative" unless limit.is_a?(Integer) && limit >= 0
20
+ @limit = limit
21
+ clear
22
+ end
23
+
24
+ def clear = @tree = Denebola::Tree.new(summary: Count)
25
+ def length = tree.size
26
+ alias size length
27
+ def [](index) = tree[index]&.cells
28
+ def each(&block) = block ? tree.each { |row| block.call(row.cells) } : enum_for(__method__)
29
+
30
+ def push(cells)
31
+ return if limit.zero?
32
+ frozen = cells.map { |cell| cell.dup.tap { |copy| copy.text = copy.text.dup.freeze }.freeze }.freeze
33
+ @tree = tree.push(Row.new(frozen).freeze)
34
+ @tree = tree.slice(tree.size - limit, limit) if tree.size > limit
35
+ end
36
+ end
37
+ end
38
+ end