tarazed 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1a28326d4fd2fe32691844accc2f1c43100d77a356c69f9c24e86da2af8634cc
4
+ data.tar.gz: f8c9ea79ea97aa1fc8a2ee4218846d0a6bf29a300bea85006b4b84617d6b477b
5
+ SHA512:
6
+ metadata.gz: 3ce64bd1794a8393be042fbd0e8004d024b34e3bc477a3e8b9067ac2e345cfad572f7245332024389208672bd075e6e3509465b9e388a28e1e901038f61d12b9
7
+ data.tar.gz: 3c7b4e9d94369ba16d7c870cc834d00b994bb67db87254ae2b326d305128fc4f1438959eb955d241083b30962f4cd49a0ae38b5f25c3af2a3a33e00cecbf5cce
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-16
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # Tarazed
2
+
3
+ Tarazed is a pure Ruby terminal emulator core. It provides the
4
+ terminal cell grid, scrollback, VT parser, keyboard/mouse encoding, and a
5
+ POSIX or Windows ConPTY session without depending on an editor or UI toolkit.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add tarazed
11
+ ```
12
+
13
+ Or run `gem install tarazed`.
14
+
15
+ ## Usage
16
+
17
+ Feed arbitrary byte chunks into a `VT`; incomplete UTF-8 and control sequences
18
+ are retained until the next chunk.
19
+
20
+ ```ruby
21
+ require "tarazed"
22
+
23
+ screen = Tarazed::Grid.new(columns: 80, rows: 24)
24
+ terminal = Tarazed::VT.new(screen)
25
+ terminal.feed("\e[32mready\e[0m\r\n")
26
+ puts screen.text
27
+ ```
28
+
29
+ `Tarazed::PTY` owns a child process and feeds its output into the same grid.
30
+ It uses a POSIX PTY on macOS and Linux and ConPTY on 64-bit Windows:
31
+
32
+ ```ruby
33
+ terminal = Tarazed::PTY.new(command: ["/bin/sh"], columns: 80, rows: 24)
34
+ terminal.write("printf 'hello\\n'\r")
35
+ terminal.read(timeout: 0.1)
36
+ terminal.close
37
+ ```
38
+
39
+ ConPTY requires a supported 64-bit Windows release and does not fall back to a
40
+ pipe-only console. Shell integration and the higher-level `Screen`, `Parser`,
41
+ and `Session` APIs remain future work.
42
+
43
+ ## Development
44
+
45
+ Run `bundle install`, then `bundle exec rake test`. Validate the signatures with
46
+ `bundle exec rbs -I sig validate` and run the benchmark with
47
+ `BUDGET=1 bundle exec rake bench`.
48
+
49
+ ## Contributing
50
+
51
+ Bug reports and pull requests are welcome at https://github.com/noxdea/tarazed.
52
+
53
+ ## License
54
+
55
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,17 @@
1
+ # ADR NNN: Implementation decision title
2
+
3
+ - Status: Proposed
4
+ - Date: YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ Describe the concrete implementation question and its compatibility, data,
9
+ runtime, or component constraints.
10
+
11
+ ## Decision
12
+
13
+ Describe the durable boundary or architecture choice.
14
+
15
+ ## Consequences
16
+
17
+ Describe the important positive and negative trade-offs and when to revisit it.
@@ -0,0 +1,26 @@
1
+ # ADR 001: Keep terminal state independent of renderers
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-15
5
+
6
+ ## Context
7
+
8
+ A terminal emulator interprets a child process's byte stream into screen state.
9
+ An editor or UI toolkit consumes that state but should not define its storage,
10
+ Unicode width rules, process lifetime, or protocol behavior.
11
+
12
+ ## Decision
13
+
14
+ Tarazed exposes the existing cell grid, bounded scrollback, VT parser, and
15
+ PTY facade as standalone Ruby objects. The facade selects a POSIX PTY on macOS
16
+ and Linux or a Fiddle-based ConPTY backend on 64-bit Windows. It depends
17
+ directly on `unicode-display_width`; scrollback uses a bounded Array because
18
+ the default 10,000-row limit does not justify a persistent tree dependency.
19
+
20
+ Higher-level screen, parser, session, and shell integration APIs remain outside
21
+ the extracted API.
22
+
23
+ ## Consequences
24
+
25
+ Tarazed can be embedded without Canopus, Denebola, or Zaniah. Unsupported or
26
+ 32-bit Windows systems fail clearly when ConPTY cannot be loaded.
@@ -0,0 +1,3 @@
1
+ # Architecture decision records
2
+
3
+ These records document Tarazed's durable boundaries.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tarazed
4
+ Cell = Struct.new(:text, :width, :foreground, :background, :attributes, :hyperlink, keyword_init: true)
5
+ end
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module Tarazed
6
+ class Grid
7
+ attr_reader :columns, :rows, :cells, :scrollback, :cursor_x, :cursor_y, :scroll_top, :scroll_bottom
8
+ attr_accessor :foreground, :background, :attributes, :hyperlink, :autowrap, :insert_mode, :origin_mode,
9
+ :cursor_visible
10
+
11
+ def initialize(columns: 80, rows: 24, scrollback: 10_000)
12
+ validate_dimensions(columns, rows)
13
+ @columns, @rows = columns, rows
14
+ @scrollback = Scrollback.new(scrollback)
15
+ reset
16
+ end
17
+
18
+ def reset
19
+ @foreground = @background = @hyperlink = nil
20
+ @attributes = {}.freeze
21
+ @autowrap = @cursor_visible = true
22
+ @insert_mode = @origin_mode = false
23
+ @cursor_x = @cursor_y = 0
24
+ @wrap_pending = false
25
+ @alternate = nil
26
+ @scroll_top, @scroll_bottom = 0, rows - 1
27
+ @cells = Array.new(rows) { blank_row }
28
+ @tabs = (8...columns).step(8).to_a
29
+ save_cursor
30
+ end
31
+
32
+ def [](row, column = nil) = column ? cells[row]&.[](column) : cells[row]
33
+ def alternate? = !@alternate.nil?
34
+ def lines = cells.map { |row| row.map(&:text).join.rstrip }
35
+ def text = lines.join("\n")
36
+
37
+ def put(char)
38
+ width = self.class.width(char)
39
+ previous_x = @wrap_pending ? cursor_x : cursor_x - 1
40
+ if previous_x >= 0 && !char.ascii_only?
41
+ previous_x -= 1 if cells[cursor_y][previous_x].width.zero? && previous_x.positive?
42
+ previous = cells[cursor_y][previous_x]
43
+ joined = previous.text + char
44
+ if joined.match?(/\A\X\z/)
45
+ width = [self.class.width(joined), columns].min
46
+ if previous_x + width > columns && autowrap
47
+ cells[cursor_y][previous_x] = blank
48
+ carriage_return
49
+ linefeed
50
+ return put(joined)
51
+ end
52
+ width = [width, columns - previous_x].min
53
+ clear_wide(cursor_y, previous_x + 1) if width == 2 && previous.width < 2
54
+ cells[cursor_y][previous_x + 1] = blank if width < previous.width
55
+ previous.text, previous.width = joined, width
56
+ if width == 2
57
+ cells[cursor_y][previous_x + 1] = previous.dup.tap { |cell| cell.text = ""; cell.width = 0 }
58
+ end
59
+ @cursor_x = [previous_x + width, columns - 1].min
60
+ @wrap_pending = previous_x + width >= columns && autowrap
61
+ return
62
+ end
63
+ end
64
+ return if width.zero?
65
+
66
+ width = 1 if width > columns
67
+ if @wrap_pending || (width == 2 && cursor_x == columns - 1)
68
+ if autowrap
69
+ @cursor_x = 0
70
+ linefeed
71
+ elsif width == 2
72
+ return
73
+ end
74
+ @wrap_pending = false
75
+ end
76
+ insert_characters(width) if insert_mode
77
+ clear_wide(cursor_y, cursor_x)
78
+ clear_wide(cursor_y, cursor_x + 1) if width == 2
79
+ cells[cursor_y][cursor_x] = Cell.new(text: char, width: width, foreground: foreground,
80
+ background: background, attributes: attributes, hyperlink: hyperlink)
81
+ if width == 2
82
+ cells[cursor_y][cursor_x + 1] = Cell.new(text: "", width: 0, foreground: foreground,
83
+ background: background, attributes: attributes, hyperlink: hyperlink)
84
+ end
85
+ if cursor_x + width >= columns
86
+ @cursor_x = columns - 1
87
+ @wrap_pending = autowrap
88
+ else
89
+ @cursor_x += width
90
+ end
91
+ end
92
+
93
+ # Internal parser fast path; callers normally use VT#feed.
94
+ def put_ascii(text)
95
+ capacity = columns * rows
96
+ if text.bytesize >= capacity && scrollback.limit.zero? && pristine_default_screen?
97
+ complete_rows, remainder = text.bytesize.divmod(columns)
98
+ retained_complete_rows = remainder.zero? ? rows : rows - 1
99
+ start = (complete_rows - retained_complete_rows) * columns
100
+ visible = text.byteslice(start..).dup.force_encoding(Encoding::UTF_8)
101
+ @cells = visible.each_char.each_slice(columns).map do |characters|
102
+ row = characters.map do |char|
103
+ Cell.new(text: char, width: 1, foreground: nil, background: nil, attributes: @attributes, hyperlink: nil)
104
+ end
105
+ row.concat(Array.new(columns - row.length) { blank })
106
+ end
107
+ @cursor_x = remainder.zero? ? columns - 1 : remainder
108
+ @cursor_y = rows - 1
109
+ @wrap_pending = remainder.zero?
110
+ else
111
+ text.each_byte { |byte| put(byte.chr) }
112
+ end
113
+ end
114
+
115
+ def self.width(char)
116
+ return 1 if char.bytesize == 1 && char.ord.between?(32, 126)
117
+
118
+ Unicode::DisplayWidth.of(char, ambiguous: 1, emoji: :rgi)
119
+ end
120
+
121
+ def move(x: cursor_x, y: cursor_y, relative: false)
122
+ if relative
123
+ x += cursor_x
124
+ y += cursor_y
125
+ end
126
+ top, bottom = origin_mode ? [scroll_top, scroll_bottom] : [0, rows - 1]
127
+ @cursor_x = x.clamp(0, columns - 1)
128
+ @cursor_y = y.clamp(top, bottom)
129
+ @wrap_pending = false
130
+ end
131
+
132
+ def position(row, column)
133
+ move(x: column - 1, y: row - 1 + (origin_mode ? scroll_top : 0))
134
+ end
135
+
136
+ def carriage_return = move(x: 0)
137
+ def backspace = move(x: cursor_x - 1)
138
+
139
+ def linefeed
140
+ @wrap_pending = false
141
+ if cursor_y == scroll_bottom
142
+ scroll_up
143
+ elsif cursor_y < rows - 1
144
+ @cursor_y += 1
145
+ end
146
+ end
147
+
148
+ def reverse_index
149
+ @wrap_pending = false
150
+ cursor_y == scroll_top ? scroll_down : @cursor_y = [0, cursor_y - 1].max
151
+ end
152
+
153
+ def tab(count = 1, backward: false)
154
+ count.times do
155
+ stop = if backward
156
+ @tabs.reverse.find { |column| column < cursor_x }
157
+ else
158
+ @tabs.find { |column| column > cursor_x }
159
+ end
160
+ move(x: stop || (backward ? 0 : columns - 1))
161
+ end
162
+ end
163
+
164
+ def tab_set = @tabs = (@tabs + [cursor_x]).uniq.sort
165
+ def tab_clear(all: false) = all ? @tabs.clear : @tabs.delete(cursor_x)
166
+
167
+ def margins(top = 1, bottom = rows)
168
+ return unless top >= 1 && top < bottom && bottom <= rows
169
+
170
+ @scroll_top, @scroll_bottom = top - 1, bottom - 1
171
+ position(1, 1)
172
+ end
173
+
174
+ def scroll_up(count = 1)
175
+ [count, scroll_bottom - scroll_top + 1].min.times do
176
+ removed = cells.delete_at(scroll_top)
177
+ scrollback.push(removed) if scroll_top.zero? && scroll_bottom == rows - 1 && !alternate?
178
+ cells.insert(scroll_bottom, blank_row)
179
+ end
180
+ end
181
+
182
+ def scroll_down(count = 1)
183
+ [count, scroll_bottom - scroll_top + 1].min.times do
184
+ cells.delete_at(scroll_bottom)
185
+ cells.insert(scroll_top, blank_row)
186
+ end
187
+ end
188
+
189
+ def erase_display(mode = 0)
190
+ case mode
191
+ when 0
192
+ erase_line(0)
193
+ ((cursor_y + 1)...rows).each { |row| cells[row] = blank_row }
194
+ when 1
195
+ (0...cursor_y).each { |row| cells[row] = blank_row }
196
+ erase_line(1)
197
+ when 2 then @cells = Array.new(rows) { blank_row }
198
+ when 3 then scrollback.clear
199
+ end
200
+ end
201
+
202
+ def erase_line(mode = 0)
203
+ first, last = case mode
204
+ when 0 then [cursor_x, columns - 1]
205
+ when 1 then [0, cursor_x]
206
+ when 2 then [0, columns - 1]
207
+ else return
208
+ end
209
+ (first..last).each do |column|
210
+ clear_wide(cursor_y, column)
211
+ cells[cursor_y][column] = blank
212
+ end
213
+ end
214
+
215
+ def erase_characters(count = 1)
216
+ (cursor_x...[cursor_x + count, columns].min).each do |column|
217
+ clear_wide(cursor_y, column)
218
+ cells[cursor_y][column] = blank
219
+ end
220
+ end
221
+
222
+ def insert_characters(count = 1)
223
+ clear_wide(cursor_y, cursor_x) if cells[cursor_y][cursor_x].width.zero?
224
+ cells[cursor_y].insert(cursor_x, *Array.new([count, columns - cursor_x].min) { blank })
225
+ cells[cursor_y] = cells[cursor_y].first(columns)
226
+ normalize_row(cursor_y)
227
+ end
228
+
229
+ def delete_characters(count = 1)
230
+ clear_wide(cursor_y, cursor_x)
231
+ count = [count, columns - cursor_x].min
232
+ cells[cursor_y].slice!(cursor_x, count)
233
+ cells[cursor_y].concat(Array.new(count) { blank })
234
+ normalize_row(cursor_y)
235
+ end
236
+
237
+ def insert_lines(count = 1)
238
+ return unless cursor_y.between?(scroll_top, scroll_bottom)
239
+
240
+ [count, scroll_bottom - cursor_y + 1].min.times do
241
+ cells.delete_at(scroll_bottom)
242
+ cells.insert(cursor_y, blank_row)
243
+ end
244
+ end
245
+
246
+ def delete_lines(count = 1)
247
+ return unless cursor_y.between?(scroll_top, scroll_bottom)
248
+
249
+ [count, scroll_bottom - cursor_y + 1].min.times do
250
+ cells.delete_at(cursor_y)
251
+ cells.insert(scroll_bottom, blank_row)
252
+ end
253
+ end
254
+
255
+ def save_cursor
256
+ @saved = [cursor_x, cursor_y, foreground, background, attributes, hyperlink, origin_mode, @wrap_pending]
257
+ end
258
+
259
+ def restore_cursor
260
+ return unless @saved
261
+
262
+ @cursor_x, @cursor_y, @foreground, @background, @attributes, @hyperlink, @origin_mode, @wrap_pending = @saved
263
+ @cursor_x = cursor_x.clamp(0, columns - 1)
264
+ @cursor_y = cursor_y.clamp(0, rows - 1)
265
+ end
266
+
267
+ def alternate(enable, save: true)
268
+ if enable && !alternate?
269
+ save_cursor if save
270
+ @alternate = cells
271
+ @cells = Array.new(rows) { blank_row }
272
+ @cursor_x = @cursor_y = 0
273
+ @scroll_top, @scroll_bottom = 0, rows - 1
274
+ @wrap_pending = false
275
+ elsif !enable && alternate?
276
+ @cells, @alternate = @alternate, nil
277
+ @scroll_top, @scroll_bottom = 0, rows - 1
278
+ restore_cursor if save
279
+ end
280
+ end
281
+
282
+ def resize(columns:, rows:)
283
+ validate_dimensions(columns, rows)
284
+ previous_columns = @columns
285
+ @columns, @rows = columns, rows
286
+ [cells, @alternate].compact.each do |screen|
287
+ while screen.length > rows
288
+ removed = screen.shift
289
+ scrollback.push(removed) unless alternate?
290
+ @cursor_y -= 1 if screen.equal?(cells)
291
+ end
292
+ screen << blank_row while screen.length < rows
293
+ screen.each do |row|
294
+ row.slice!(columns, row.length) if row.length > columns
295
+ row << blank while row.length < columns
296
+ end
297
+ end
298
+ cells.each_index { |index| normalize_row(index) }
299
+ added_tabs = (previous_columns...columns).select { |column| column.positive? && (column % 8).zero? }
300
+ @tabs = (@tabs.select { |column| column < columns } + added_tabs).uniq.sort
301
+ @scroll_top, @scroll_bottom = 0, rows - 1
302
+ move
303
+ end
304
+
305
+ # Coordinates are [column, row]; finish is exclusive, including scrollback
306
+ # when history: true. This is suitable for selection/copy without a renderer.
307
+ def selection(start, finish, history: false)
308
+ source = history ? scrollback.to_a + cells : cells
309
+ start, finish = finish, start if ([start[1], start[0]] <=> [finish[1], finish[0]]) == 1
310
+ (start[1]..finish[1]).map do |row|
311
+ next "" unless source[row]
312
+
313
+ first = row == start[1] ? start[0] : 0
314
+ last = row == finish[1] ? finish[0] : columns
315
+ source[row][first...last].to_a.map(&:text).join.rstrip
316
+ end.join("\n")
317
+ end
318
+
319
+ def links(row)
320
+ line = cells.fetch(row).map(&:text).join
321
+ explicit = []
322
+ cells[row].each_with_index do |cell, column|
323
+ next unless cell.hyperlink
324
+
325
+ if explicit.last && explicit.last[:url] == cell.hyperlink && explicit.last[:end_column] == column
326
+ explicit.last[:end_column] = column + 1
327
+ else
328
+ explicit << {url: cell.hyperlink, column: column, end_column: column + 1}
329
+ end
330
+ end
331
+ line.to_enum(:scan, %r{https?://[^\s<>"']+}).each do
332
+ found = Regexp.last_match
333
+ column = line[0...found.begin(0)].each_char.sum { |char| self.class.width(char) }
334
+ explicit << {url: found[0], column: column, end_column: column + found[0].length}
335
+ end
336
+ explicit.uniq
337
+ end
338
+
339
+ def file_paths(row, cwd: Dir.pwd)
340
+ line = cells.fetch(row).map(&:text).join
341
+ line.scan(%r{(?:\A|[\s("'])([^\s:"'()]+):(\d+)(?::(\d+))?}).filter_map do |name, number, column|
342
+ absolute = File.expand_path(name, cwd)
343
+ {path: absolute, line: number.to_i, column: column ? column.to_i : 1} if File.file?(absolute)
344
+ end
345
+ end
346
+
347
+ private
348
+
349
+ def validate_dimensions(columns, rows)
350
+ return if columns.is_a?(Integer) && rows.is_a?(Integer) && columns.positive? && rows.positive?
351
+
352
+ raise ArgumentError, "terminal dimensions must be positive integers"
353
+ end
354
+
355
+ def pristine_default_screen?
356
+ cursor_x.zero? && cursor_y.zero? && !@wrap_pending && !alternate? && foreground.nil? && background.nil? &&
357
+ hyperlink.nil? && attributes.empty? && cells.all? { |row| row.all? { |cell| cell.text == " " && cell.width == 1 } }
358
+ end
359
+
360
+ def blank = Cell.new(text: " ", width: 1, foreground: foreground, background: background, attributes: {}.freeze)
361
+ def blank_row = Array.new(columns) { blank }
362
+
363
+ def clear_wide(row, column)
364
+ return unless column.between?(0, columns - 1)
365
+
366
+ cell = cells[row][column]
367
+ cells[row][column - 1] = blank if cell.width.zero? && column.positive?
368
+ cells[row][column + 1] = blank if cell.width == 2 && column + 1 < columns
369
+ end
370
+
371
+ def normalize_row(row)
372
+ cells[row].each_with_index do |cell, column|
373
+ invalid_lead = cell.width == 2 && (column == columns - 1 || cells[row][column + 1].width != 0)
374
+ invalid_tail = cell.width.zero? && (column.zero? || cells[row][column - 1].width != 2)
375
+ cells[row][column] = blank if invalid_lead || invalid_tail
376
+ end
377
+ end
378
+ end
379
+ end