termvas 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: 5863ee4752675b2b9b5c77b5e4bf0d0a4864911c7976ce661d21e468dd92e0b6
4
+ data.tar.gz: 3b850fdefa94a1e8707e320717f72bd9a2822c1142fc098ba0c64e2c0d520d88
5
+ SHA512:
6
+ metadata.gz: 7aed972961ce1dd18f54e9973ec4952becc545c35178bf87bd9f838694e018e49921bbf01cc6cd7d60b552e5890af0b77458269471dd758ac2d4da3192719ea9
7
+ data.tar.gz: 285deddc562970d9e952fb35fd76ebc4a2d4b8ef7926e2d7dfd0f69e339f6c14edf634e001b2023af9e5604389f7bf5252cb37f0df93b4e0dd07c734739169fe
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
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,89 @@
1
+ # Termvas
2
+
3
+ [![Gem version](https://badge.fury.io/rb/termvas.svg)](https://rubygems.org/gems/termvas)
4
+ [![Downloads](https://img.shields.io/gem/dt/termvas?label=downloads)](https://rubygems.org/gems/termvas)
5
+ [![CI](https://github.com/rbgfx/termvas/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/termvas/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > A terminal display backend for Ruby graphics.
10
+
11
+ Termvas presents RGBA frames in terminals through half blocks, Kitty, iTerm2,
12
+ or Sixel output. It also parses keyboard and mouse input for interactive
13
+ applications.
14
+
15
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Terminal notes](#terminal-notes) · [Development](#development)**
16
+
17
+ ## Features
18
+
19
+ - Automatic protocol detection with a manual override.
20
+ - Half-block output that works in ordinary ANSI terminals.
21
+ - Kitty, iTerm2, and Sixel image protocols.
22
+ - Image viewing and frame-sequence playback commands.
23
+ - Split-safe CSI, keyboard, wheel, and SGR mouse input parsing.
24
+ - Nearest-neighbor fitting for frames larger than the terminal.
25
+ - Idempotent alternate-screen and cursor restoration.
26
+
27
+ ## Installation
28
+
29
+ Add Termvas to your Gemfile:
30
+
31
+ ~~~ruby
32
+ gem "termvas"
33
+ ~~~
34
+
35
+ Then run:
36
+
37
+ ~~~sh
38
+ bundle install
39
+ ~~~
40
+
41
+ Or install the released gem:
42
+
43
+ ~~~sh
44
+ gem install termvas
45
+ ~~~
46
+
47
+ ## Quick start
48
+
49
+ Inspect the current terminal and display an image:
50
+
51
+ ~~~sh
52
+ termvas doctor
53
+ termvas view image.png
54
+ termvas play frames/*.png --fps 10
55
+ ~~~
56
+
57
+ Use the backend from Ruby:
58
+
59
+ ~~~ruby
60
+ require "termvas"
61
+
62
+ backend = Termvas::Backend.new(320, 180, protocol: :blocks)
63
+ backend.set_pixels(rgba_bytes, 320, 180)
64
+ backend.close
65
+ ~~~
66
+
67
+ The backend expects top-down RGBA8 bytes. Set
68
+ <code>TERMVAS_PROTOCOL</code> to force a protocol.
69
+
70
+ ## Terminal notes
71
+
72
+ <code>fit: :contain</code> is the default and reduces oversized frames to the
73
+ terminal cell area. Use <code>fit: :none</code> to keep the source size.
74
+
75
+ Inside tmux, Kitty and Sixel output uses DCS passthrough and requires
76
+ <code>allow-passthrough on</code> in tmux.
77
+
78
+ ## Development
79
+
80
+ ~~~sh
81
+ bundle install
82
+ bundle exec rake verify
83
+ ~~~
84
+
85
+ See [docs/keys.md](docs/keys.md) for the input event mapping.
86
+
87
+ ## License
88
+
89
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |task|
7
+ task.ruby_opts = %w[-I../rbgl/lib -I../larb/lib -I../tessel/lib]
8
+ end
9
+
10
+ task default: :spec
11
+ task verify: :spec
data/docs/keys.md ADDED
@@ -0,0 +1,10 @@
1
+ # Terminal input
2
+
3
+ `Termvas::InputParser` emits `key_press` events. Printable bytes include a
4
+ `char`; Ctrl-C emits `key: :close`. CSI and SS3 arrows, Home/End, Insert,
5
+ Delete, Page Up/Down, F1-F4, and SGR mouse events are supported.
6
+
7
+ CSI modifier parameters and SGR mouse modifier bits become `:shift`, `:alt`,
8
+ and `:control`. SGR wheel events become `scroll` events with `dx` and `dy`;
9
+ the parser reports one logical wheel step per terminal event. Terminals do not
10
+ report key release events, so Termvas emits presses only.
data/exe/termvas ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "termvas"
6
+
7
+ command = ARGV.shift
8
+ if command == "doctor"
9
+ puts "protocol: #{Termvas::Detector.protocol}"
10
+ puts "terminal: #{ENV['TERM'] || 'unknown'}"
11
+ puts "size: #{IO.console&.winsize&.join('x') || 'unknown'}"
12
+ elsif %w[view play].include?(command)
13
+ require "tessel"
14
+ fps = 10.0
15
+ protocol = :auto
16
+ OptionParser.new do |options|
17
+ options.on("--fps N", Float) { |value| fps = value }
18
+ options.on("--protocol NAME") { |value| protocol = value.to_sym }
19
+ end.parse!(ARGV)
20
+ raise ArgumentError, "fps must be positive" unless fps.positive?
21
+ raise ArgumentError, "at least one image is required" if ARGV.empty?
22
+ files = ARGV.flat_map { |path| File.directory?(path) ? Dir[File.join(path, "*.png")].sort : path }
23
+ files = [files.first] if command == "view"
24
+ raise ArgumentError, "at least one image is required" if files.empty?
25
+ backend = nil
26
+ files.each do |path|
27
+ image = Tessel.read(path)
28
+ rows, columns = IO.console&.winsize || [24, 80]
29
+ width, height = Termvas::Scaler.contain(image.width, image.height, columns, rows)
30
+ bytes, = Termvas::Scaler.nearest(image.bytes, image.width, image.height, width, height)
31
+ backend ||= Termvas::Backend.new(width, height, protocol: protocol, alt_screen: command == "play")
32
+ backend.set_pixels(bytes, width, height)
33
+ sleep(1.0 / fps) if command == "play"
34
+ end
35
+ else
36
+ warn "usage: termvas doctor | view IMAGE [--protocol NAME] | play IMAGE... [--fps N]"
37
+ exit 1
38
+ end
39
+ backend&.close if defined?(backend)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termvas
4
+ VERSION = "0.1.0"
5
+ end
data/lib/termvas.rb ADDED
@@ -0,0 +1,526 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "zlib"
5
+ require "rbgl"
6
+
7
+ require_relative "termvas/version"
8
+
9
+ module Termvas
10
+ class Error < StandardError; end
11
+
12
+ module Scaler
13
+ module_function
14
+
15
+ def contain(width, height, columns, rows)
16
+ raise ArgumentError, "dimensions must be positive" unless [width, height, columns, rows].all?(&:positive?)
17
+ scale = [1.0, columns.to_f / width, (rows * 2).to_f / height].min
18
+ [[(width * scale).floor, 1].max, [[((height * scale).floor / 2) * 2, 2].max, rows * 2].min]
19
+ end
20
+
21
+ def nearest(bytes, width, height, target_width, target_height)
22
+ width, height, target_width, target_height = [width, height, target_width, target_height].map(&:to_i)
23
+ raise ArgumentError, "dimensions must be positive" unless [width, height, target_width, target_height].all?(&:positive?)
24
+ bytes = String.try_convert(bytes)
25
+ raise ArgumentError, "pixel buffer must be a String" unless bytes
26
+ expected_size = width * height * 4
27
+ raise ArgumentError, "pixel buffer size mismatch" unless bytes.bytesize == expected_size
28
+ return [bytes, width, height] if width == target_width && height == target_height
29
+ output = "".b
30
+ target_height.times do |y|
31
+ source_y = y * height / target_height
32
+ target_width.times do |x|
33
+ source_x = x * width / target_width
34
+ output << bytes.byteslice((source_y * width + source_x) * 4, 4)
35
+ end
36
+ end
37
+ [output, target_width, target_height]
38
+ end
39
+ end
40
+
41
+ module Encoders
42
+ module Blocks
43
+ module_function
44
+
45
+ def encode(bytes, width, height, previous: nil)
46
+ cells = "\e[H".dup
47
+ (0...height).step(2) do |y|
48
+ current = nil
49
+ width.times do |x|
50
+ top = bytes.byteslice((y * width + x) * 4, 4) || "\0\0\0\0".b
51
+ bottom = bytes.byteslice(((y + 1) * width + x) * 4, 4) || "\0\0\0\0".b
52
+ old_top = previous&.byteslice((y * width + x) * 4, 4)
53
+ old_bottom = previous&.byteslice(((y + 1) * width + x) * 4, 4)
54
+ if previous && old_top == top && old_bottom == bottom
55
+ cells << "\e[C"
56
+ next
57
+ end
58
+ if current != [top, bottom]
59
+ cells << "\e[38;2;#{top.getbyte(0)};#{top.getbyte(1)};#{top.getbyte(2)}m"
60
+ cells << "\e[48;2;#{bottom.getbyte(0)};#{bottom.getbyte(1)};#{bottom.getbyte(2)}m"
61
+ current = [top, bottom]
62
+ end
63
+ cells << "▀"
64
+ end
65
+ cells << "\e[0m\r\n"
66
+ end
67
+ cells
68
+ end
69
+ end
70
+
71
+ module Kitty
72
+ module_function
73
+
74
+ def encode(bytes, width, height, id: 1, compress: true)
75
+ payload = compress ? Zlib::Deflate.deflate(bytes) : bytes
76
+ encoded = [payload].pack("m0")
77
+ chunks = encoded.scan(/.{1,4096}/m)
78
+ chunks.each_with_index.map do |chunk, index|
79
+ more = index == chunks.length - 1 ? 0 : 1
80
+ control = "a=T,f=32,s=#{width},v=#{height},i=#{id},q=2,m=#{more}"
81
+ control << ",o=z" if compress
82
+ "\e_G#{control};#{chunk}\e\\"
83
+ end.join
84
+ end
85
+ end
86
+
87
+ module ITerm2
88
+ module_function
89
+
90
+ def encode(png_bytes, width:, height:)
91
+ "\e[H\e]1337;File=inline=1;width=#{width}px;height=#{height}px;preserveAspectRatio=1:#{[png_bytes].pack("m0")}\a"
92
+ end
93
+ end
94
+
95
+ module Sixel
96
+ module_function
97
+
98
+ def encode(bytes, width, height, palette: nil, quantize: :fixed)
99
+ quantize = quantize.to_sym
100
+ raise ArgumentError, "unsupported Sixel quantizer: #{quantize}" unless %i[fixed median_cut].include?(quantize)
101
+ if quantize == :median_cut
102
+ raise ArgumentError, "palette cannot be combined with median cut" if palette
103
+ palette, quantized = Quantizer.median_cut(bytes, colors: 256)
104
+ end
105
+ fixed_palette = palette.nil?
106
+ palette ||= default_palette
107
+ indices = if quantized
108
+ quantized.bytes
109
+ else
110
+ bytes.bytes.each_slice(4).map do |r, g, b, _a|
111
+ if fixed_palette
112
+ red = (r * 5.0 / 255).round
113
+ green = (g * 5.0 / 255).round
114
+ blue = (b * 5.0 / 255).round
115
+ cube = red * 36 + green * 6 + blue
116
+ gray = ((r + g + b) / 3.0 * 39 / 255).round
117
+ cube_color = palette[cube]
118
+ gray_color = palette[216 + gray]
119
+ cube_distance = [r, g, b].zip(cube_color).sum { |a, c| (a - c)**2 }
120
+ gray_distance = [r, g, b].zip(gray_color).sum { |a, c| (a - c)**2 }
121
+ cube_distance <= gray_distance ? cube : 216 + gray
122
+ else
123
+ palette.each_index.min_by { |index| [r, g, b].zip(palette[index]).sum { |a, c| (a - c)**2 } }
124
+ end
125
+ end
126
+ end.pack("C*")
127
+ output = +"\ePq"
128
+ palette.each_with_index { |(r, g, b), index| output << "##{index};2;#{r * 100 / 255};#{g * 100 / 255};#{b * 100 / 255}" }
129
+ (0...height).step(6) do |band|
130
+ palette.each_index do |index|
131
+ output << "##{index}"
132
+ x = 0
133
+ while x < width
134
+ run = 0
135
+ while x + run < width && sixel_column(indices, width, x + run, band, index) == sixel_column(indices, width, x, band, index)
136
+ run += 1
137
+ end
138
+ column = sixel_column(indices, width, x, band, index)
139
+ if run > 3
140
+ output << "!#{run}#{(63 + column).chr}"
141
+ else
142
+ output << (63 + column).chr * run
143
+ end
144
+ x += run
145
+ end
146
+ output << "$"
147
+ end
148
+ output << "-" if band + 6 < height
149
+ end
150
+ output << "\e\\"
151
+ end
152
+
153
+ def sixel_column(indices, width, x, y, index)
154
+ value = 0
155
+ 6.times do |offset|
156
+ next unless indices.getbyte((y + offset) * width + x) == index
157
+ value |= 1 << offset
158
+ end
159
+ value
160
+ end
161
+ private_class_method :sixel_column
162
+
163
+ def default_palette
164
+ 6.times.flat_map { |r| 6.times.flat_map { |g| 6.times.map { |b| [r * 51, g * 51, b * 51] } } } + Array.new(40) { |index| value = index * 255 / 39; [value, value, value] }
165
+ end
166
+ private_class_method :default_palette
167
+ end
168
+ end
169
+
170
+ module Quantizer
171
+ module_function
172
+
173
+ def median_cut(bytes, colors: 256)
174
+ colors = Integer(colors)
175
+ raise ArgumentError, "colors must be positive" unless colors.positive?
176
+ pixels = bytes.bytes.each_slice(4).map { |red, green, blue, _alpha| [red, green, blue] }
177
+ return [[], "".b] if pixels.empty?
178
+
179
+ boxes = [pixels]
180
+ while boxes.length < colors
181
+ index = boxes.each_index.max_by { |box_index| split_range(boxes[box_index]) }
182
+ box = boxes[index]
183
+ break if box.length < 2
184
+
185
+ axis = widest_axis(box)
186
+ sorted = box.sort_by { |pixel| pixel[axis] }
187
+ midpoint = sorted.length / 2
188
+ boxes[index] = sorted.first(midpoint)
189
+ boxes << sorted.drop(midpoint)
190
+ end
191
+
192
+ palette = boxes.map do |box|
193
+ box.transpose.map { |channel| (channel.sum.to_f / channel.length).round }
194
+ end
195
+ indices = pixels.map do |pixel|
196
+ palette.each_index.min_by { |index| pixel.zip(palette[index]).sum { |left, right| (left - right)**2 } }
197
+ end.pack("C*")
198
+ [palette, indices]
199
+ end
200
+
201
+ def split_range(box)
202
+ ranges = 3.times.map { |axis| box.map { |pixel| pixel[axis] }.max - box.map { |pixel| pixel[axis] }.min }
203
+ ranges.max * box.length
204
+ end
205
+ private_class_method :split_range
206
+
207
+ def widest_axis(box)
208
+ 3.times.max_by { |axis| box.map { |pixel| pixel[axis] }.max - box.map { |pixel| pixel[axis] }.min }
209
+ end
210
+ private_class_method :widest_axis
211
+ end
212
+
213
+ class InputParser
214
+ CSI_KEYS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left, "H" => :home, "F" => :end }.freeze
215
+ CSI_TILDE_KEYS = { 1 => :home, 2 => :insert, 3 => :delete, 4 => :end, 5 => :page_up, 6 => :page_down }.freeze
216
+
217
+ def initialize
218
+ @buffer = "".b
219
+ end
220
+
221
+ def feed(bytes)
222
+ @buffer << bytes
223
+ events = []
224
+ loop do
225
+ if @buffer.start_with?("\e[<")
226
+ match = @buffer.match(/\A\e\[<([0-9]+);([0-9]+);([0-9]+)([Mm])/
227
+ )
228
+ break unless match
229
+ @buffer = @buffer.byteslice(match[0].bytesize..).to_s.b
230
+ button = match[1].to_i
231
+ event = { x: match[2].to_i - 1, y: match[3].to_i - 1, modifiers: mouse_modifiers(button) }
232
+ if button & 64 != 0
233
+ event.merge!(type: :scroll, dx: 0.0, dy: (button & 1).zero? ? 1.0 : -1.0)
234
+ elsif button & 32 != 0
235
+ event.merge!(type: :mouse_move, button: button & 3)
236
+ else
237
+ event.merge!(type: match[4] == "M" ? :mouse_press : :mouse_release, button: button & 3)
238
+ end
239
+ events << event
240
+ elsif @buffer.start_with?("\e[")
241
+ match = @buffer.match(/\A\e\[([0-9;]*)([A-Za-z~])/)
242
+ break unless match
243
+ @buffer = @buffer.byteslice(match[0].bytesize..).to_s.b
244
+ parameters = match[1].split(";").reject(&:empty?).map(&:to_i)
245
+ key = if match[2] == "~"
246
+ CSI_TILDE_KEYS[parameters.first] || :unknown
247
+ elsif match[2] == "Z"
248
+ :tab
249
+ else
250
+ CSI_KEYS[match[2]] || match[2].to_sym
251
+ end
252
+ event = { type: :key_press, key: key }
253
+ modifiers = match[2] == "Z" ? [:shift] : csi_modifiers(parameters[1] || 1)
254
+ event[:modifiers] = modifiers unless modifiers.empty?
255
+ events << event
256
+ elsif @buffer.start_with?("\eO")
257
+ break if @buffer.bytesize < 3
258
+ key = { "P" => :f1, "Q" => :f2, "R" => :f3, "S" => :f4 }[@buffer.getbyte(2).chr] || @buffer.getbyte(2).chr.to_sym
259
+ @buffer = @buffer.byteslice(3..).to_s.b
260
+ events << { type: :key_press, key: key }
261
+ elsif @buffer.start_with?("\e")
262
+ break if @buffer.bytesize < 2
263
+ @buffer = @buffer.byteslice(1..).to_s.b
264
+ events << { type: :key_press, key: :escape }
265
+ else
266
+ byte = @buffer.getbyte(0)
267
+ break unless byte
268
+ @buffer = @buffer.byteslice(1..).to_s.b
269
+ key = case byte
270
+ when 3 then :close
271
+ when 9 then :tab
272
+ when 13 then :enter
273
+ when 127 then :backspace
274
+ when 32..126 then byte.chr.to_sym
275
+ else next
276
+ end
277
+ events << { type: :key_press, key: key, char: byte.chr }
278
+ end
279
+ end
280
+ events
281
+ end
282
+
283
+ def flush
284
+ return [] if @buffer.empty?
285
+ @buffer = "".b
286
+ [{ type: :key_press, key: :escape }]
287
+ end
288
+
289
+ private
290
+
291
+ def csi_modifiers(value)
292
+ modifier_bits(value.to_i - 1)
293
+ end
294
+
295
+ def mouse_modifiers(button)
296
+ modifier_bits((button >> 2) & 7)
297
+ end
298
+
299
+ def modifier_bits(value)
300
+ [[:shift, 1], [:alt, 2], [:control, 4]].filter_map { |name, bit| name if value & bit != 0 }
301
+ end
302
+ end
303
+
304
+ class Detector
305
+ def self.protocol(env = ENV)
306
+ forced = env["TERMVAS_PROTOCOL"]
307
+ return forced.to_sym if forced
308
+ return :kitty if env["TERM"] == "xterm-kitty" || env["KITTY_WINDOW_ID"] || %w[WezTerm ghostty].include?(env["TERM_PROGRAM"])
309
+ return :iterm2 if env["TERM_PROGRAM"] == "iTerm.app"
310
+ :blocks
311
+ end
312
+ end
313
+
314
+ class Terminal
315
+ attr_reader :input, :output
316
+
317
+ def initialize(input: $stdin, output: $stdout, alt_screen: true, tmux: false)
318
+ @input = input
319
+ @output = output
320
+ @alt_screen = alt_screen
321
+ @closed = false
322
+ @opened = false
323
+ @raw = false
324
+ @tmux = tmux
325
+ @resized = false
326
+ @signal_handlers = {}
327
+ end
328
+
329
+ def size
330
+ rows, columns = @input.winsize
331
+ return [columns, rows] if rows.to_i.positive? && columns.to_i.positive?
332
+
333
+ fallback_size
334
+ rescue Errno::ENOTTY, IOError, NoMethodError
335
+ fallback_size
336
+ end
337
+
338
+ def open
339
+ return self if @opened
340
+ @opened = true
341
+ @mouse_tracking = false
342
+ if @input.respond_to?(:tty?) && @input.tty?
343
+ if @input.respond_to?(:raw!)
344
+ @input.raw!
345
+ @raw = true
346
+ end
347
+ @output.write("\e[?1000h\e[?1003h\e[?1006h")
348
+ @mouse_tracking = true
349
+ end
350
+ install_signal_handlers
351
+ @output.write("\e[?1049h") if @alt_screen
352
+ @output.write("\e[?25l")
353
+ @output.flush
354
+ at_exit { restore }
355
+ self
356
+ end
357
+
358
+ def write(value)
359
+ value = "\ePtmux;\e#{value.gsub("\e", "\e\e")}\e\\" if @tmux
360
+ @output.write(value)
361
+ @output.flush
362
+ end
363
+
364
+ def restore
365
+ return if @closed
366
+ @closed = true
367
+ restore_signal_handlers
368
+ begin
369
+ @output.write("\e[?25h\e[?1000l\e[?1003l\e[?1006l") if @mouse_tracking
370
+ @output.write("\e[?25h") unless @mouse_tracking
371
+ @output.write("\e[?1049l") if @alt_screen
372
+ @output.flush
373
+ ensure
374
+ @input.cooked! if @raw
375
+ @raw = false
376
+ end
377
+ end
378
+
379
+ alias close restore
380
+
381
+ def resized?
382
+ resized = @resized
383
+ @resized = false
384
+ resized
385
+ end
386
+
387
+ private
388
+
389
+ def install_signal_handlers
390
+ handlers = { "WINCH" => proc { @resized = true }, "INT" => proc { restore }, "TERM" => proc { restore } }
391
+ exit_codes = { "INT" => 130, "TERM" => 143 }
392
+ handlers.each do |signal, handler|
393
+ previous = nil
394
+ previous = Signal.trap(signal) do |number|
395
+ handler.call
396
+ previous.call(number) if previous.respond_to?(:call)
397
+ exit(exit_codes[signal]) if exit_codes.key?(signal)
398
+ end
399
+ @signal_handlers[signal] = previous
400
+ end
401
+ rescue ArgumentError, Errno::EINVAL
402
+ restore_signal_handlers
403
+ end
404
+
405
+ def restore_signal_handlers
406
+ @signal_handlers.each { |signal, previous| Signal.trap(signal, previous) }
407
+ @signal_handlers.clear
408
+ rescue ArgumentError, Errno::EINVAL
409
+ @signal_handlers.clear
410
+ end
411
+
412
+ def fallback_size
413
+ columns = Integer(ENV.fetch("COLUMNS", 80))
414
+ rows = Integer(ENV.fetch("LINES", 24))
415
+ [columns.positive? ? columns : 80, rows.positive? ? rows : 24]
416
+ rescue ArgumentError, TypeError
417
+ [80, 24]
418
+ end
419
+ end
420
+
421
+ class Backend < RBGL::GUI::Backend
422
+ attr_reader :width, :height, :protocol
423
+
424
+ def initialize(width, height, protocol: :auto, fit: :contain, max_fps: 30, quantize: :fixed, tmux: :auto, output: $stdout, input: $stdin, alt_screen: true, **_options)
425
+ super(Integer(width), Integer(height), "Termvas")
426
+ @protocol = protocol == :auto ? Detector.protocol : protocol.to_sym
427
+ @quantize = quantize.to_sym
428
+ tmux = ENV.key?("TMUX") if tmux == :auto
429
+ tmux &&= %i[kitty sixel].include?(@protocol)
430
+ raise ArgumentError, "unsupported terminal protocol: #{@protocol}" unless %i[blocks kitty iterm2 sixel].include?(@protocol)
431
+ @fit = fit.to_sym
432
+ raise ArgumentError, "fit must be :contain or :none" unless %i[contain none].include?(@fit)
433
+ @max_fps = max_fps.nil? ? nil : Float(max_fps)
434
+ raise ArgumentError, "max_fps must be positive" if @max_fps && !@max_fps.positive?
435
+ @terminal = Terminal.new(input: input, output: output, alt_screen: alt_screen, tmux: tmux)
436
+ @output = output
437
+ @input = input
438
+ @closed = false
439
+ @previous = nil
440
+ @parser = InputParser.new
441
+ @last_frame_at = nil
442
+ @terminal.open
443
+ end
444
+
445
+ def open = (@terminal.open; self)
446
+
447
+ def present(framebuffer)
448
+ set_pixels(framebuffer.respond_to?(:to_rgba_bytes) ? framebuffer.to_rgba_bytes : framebuffer, framebuffer.width, framebuffer.height)
449
+ end
450
+
451
+ def set_pixels(bytes, width = @width, height = @height)
452
+ bytes = validate_rgba_buffer(bytes, width, height)
453
+ width, height = Integer(width), Integer(height)
454
+ if @fit == :contain
455
+ columns, rows = @terminal.size
456
+ target_width, target_height = Scaler.contain(width, height, columns, rows)
457
+ bytes, width, height = Scaler.nearest(bytes, width, height, target_width, target_height)
458
+ end
459
+ @previous = nil if @previous_size && @previous_size != [width, height]
460
+ return if @previous == bytes
461
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
462
+ return if @last_frame_at && @max_fps && now - @last_frame_at < 1.0 / @max_fps
463
+
464
+ encoded = case @protocol
465
+ when :kitty then Encoders::Kitty.encode(bytes, width, height)
466
+ when :iterm2
467
+ require "tessel"
468
+ Encoders::ITerm2.encode(Tessel::PNG.encode(Tessel::Image.from_rgba(width, height, bytes)), width: width, height: height)
469
+ when :sixel then Encoders::Sixel.encode(bytes, width, height, quantize: @quantize)
470
+ else Encoders::Blocks.encode(bytes, width, height, previous: @previous)
471
+ end
472
+ @terminal.write(encoded)
473
+ @previous = bytes.dup
474
+ @previous_size = [width, height]
475
+ @last_frame_at = now
476
+ end
477
+
478
+ def poll_events
479
+ return [] unless @input.respond_to?(:read_nonblock)
480
+ resize = if @terminal.resized?
481
+ columns, rows = @terminal.size
482
+ { type: :resize, columns: columns, rows: rows }
483
+ end
484
+ bytes = @input.read_nonblock(4096, exception: false)
485
+ events = bytes.is_a?(String) ? @parser.feed(bytes) : []
486
+ events = map_mouse_events(events)
487
+ events.unshift(resize) if resize
488
+ if events.any? { |event| event[:key] == :close }
489
+ @closed = true
490
+ @terminal.close
491
+ end
492
+ events
493
+ rescue IOError, Errno::EIO
494
+ []
495
+ end
496
+
497
+ def should_close?
498
+ @closed
499
+ end
500
+
501
+ def close
502
+ return if @closed
503
+ @closed = true
504
+ @terminal.close
505
+ end
506
+
507
+ private
508
+
509
+ def map_mouse_events(events)
510
+ return events unless @previous_size
511
+
512
+ columns, rows = @terminal.size
513
+ display_width = [@previous_size[0], columns].min
514
+ display_height = [@previous_size[1], rows * 2].min
515
+ events.filter_map do |event|
516
+ next event unless event.key?(:x) && event.key?(:y)
517
+
518
+ x = event[:x] * display_width.to_f / columns
519
+ y = event[:y] * display_height.to_f / rows
520
+ next if x >= display_width || y >= display_height
521
+
522
+ event.merge(x: x, y: y)
523
+ end
524
+ end
525
+ end
526
+ end
data/sig/termvas.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Termvas
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: termvas
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rbgl
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.0.0
26
+ description: Half block, Kitty, iTerm2, and Sixel terminal output with input parsing.
27
+ email:
28
+ - t.yudai92@gmail.com
29
+ executables:
30
+ - termvas
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - docs/keys.md
39
+ - exe/termvas
40
+ - lib/termvas.rb
41
+ - lib/termvas/version.rb
42
+ - sig/termvas.rbs
43
+ homepage: https://github.com/rbgfx/termvas
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://github.com/rbgfx/termvas
48
+ source_code_uri: https://github.com/rbgfx/termvas/tree/main
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: 3.1.0
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 4.0.16
64
+ specification_version: 4
65
+ summary: Terminal backend for Ruby graphics
66
+ test_files: []