termvas 0.1.0 → 0.3.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 +8 -1
- data/LICENSE.txt +1 -1
- data/README.md +53 -13
- data/docs/index.html +32 -0
- data/exe/termvas +1 -0
- data/lib/termvas/rbgl.rb +113 -0
- data/lib/termvas/version.rb +1 -1
- data/lib/termvas.rb +17 -153
- data/sig/termvas.rbs +6 -1
- metadata +10 -19
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d3cf29b6b2bd27eb84a1cda85bb993a8e5f642bb27c97aab69f944a968809374
|
|
4
|
+
data.tar.gz: 646acf9519788a0944ae798247a5d82ee7f63c8db098fb65892b0876ddca285e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c23b3ec1a1a79e1126e976df5c4aba2ba1fae6af962c86ffbcbdfc8c021efd9340892485f7441dac1205d44b99c35e8762e716a3fef18990c6fd7f7c5efe330
|
|
7
|
+
data.tar.gz: 768c92089cbec5566b6b7db8193cbedf5e51399ae86fcee805a7cfbb25b7af73438d734b48b7437b2ff29f8469d76dd059426f2f7815bb2a84bb2500a8769873
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [Unreleased
|
|
3
|
+
## [0.3.0] - Unreleased
|
|
4
|
+
|
|
5
|
+
- Expose terminal cell-size estimates for display fitting, with environment overrides.
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
- Make RBGL an optional backend loaded with `termvas/rbgl`.
|
|
10
|
+
- Use Tessel's shared quantizer for median-cut Sixel output.
|
|
4
11
|
|
|
5
12
|
## [0.1.0] - 2026-09-23
|
|
6
13
|
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Termvas</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://rubygems.org/gems/termvas)
|
|
5
|
-
[](https://github.com/rbgfx/termvas/actions/workflows/ci.yml)
|
|
6
|
-
[](https://www.ruby-lang.org/)
|
|
7
|
-
[](LICENSE.txt)
|
|
3
|
+
<p align="center">A terminal display backend for Ruby graphics.</p>
|
|
8
4
|
|
|
9
|
-
>
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://rubygems.org/gems/termvas"><img src="https://badge.fury.io/rb/termvas.svg" alt="Gem Version"></a>
|
|
7
|
+
<a href="https://rubygems.org/gems/termvas"><img src="https://img.shields.io/gem/dt/termvas?label=downloads" alt="Downloads"></a>
|
|
8
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white" alt="Ruby Version"></a>
|
|
9
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
|
|
10
|
+
</p>
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
or Sixel output. It also parses keyboard and mouse input for interactive
|
|
13
|
-
applications.
|
|
12
|
+
[Features](#features) · [Installation](#installation) · [Quick Start](#quick-start) · [Terminal Notes](#terminal-notes)
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
***
|
|
15
|
+
|
|
16
|
+
Termvas presents RGBA frames in terminals through half blocks, Kitty, iTerm2, or Sixel output. It also parses keyboard and mouse input for interactive applications.
|
|
16
17
|
|
|
17
18
|
## Features
|
|
18
19
|
|
|
@@ -30,6 +31,10 @@ Add Termvas to your Gemfile:
|
|
|
30
31
|
|
|
31
32
|
~~~ruby
|
|
32
33
|
gem "termvas"
|
|
34
|
+
|
|
35
|
+
# For Termvas::Backend and the `termvas view` / `play` commands:
|
|
36
|
+
gem "rbgl"
|
|
37
|
+
gem "tessel", ">= 0.2.0"
|
|
33
38
|
~~~
|
|
34
39
|
|
|
35
40
|
Then run:
|
|
@@ -44,7 +49,13 @@ Or install the released gem:
|
|
|
44
49
|
gem install termvas
|
|
45
50
|
~~~
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
### Requirements
|
|
53
|
+
|
|
54
|
+
- Ruby 3.1 or newer.
|
|
55
|
+
- Half-block output works in ANSI terminals; Kitty, iTerm2, and Sixel output require matching terminal support.
|
|
56
|
+
- `require "termvas"` and `termvas doctor` need no extra gems. The RBGL backend needs `rbgl`; `view` / `play`, iTerm2 output, and median-cut Sixel quantization also need `tessel`.
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
48
59
|
|
|
49
60
|
Inspect the current terminal and display an image:
|
|
50
61
|
|
|
@@ -57,7 +68,7 @@ termvas play frames/*.png --fps 10
|
|
|
57
68
|
Use the backend from Ruby:
|
|
58
69
|
|
|
59
70
|
~~~ruby
|
|
60
|
-
require "termvas"
|
|
71
|
+
require "termvas/rbgl"
|
|
61
72
|
|
|
62
73
|
backend = Termvas::Backend.new(320, 180, protocol: :blocks)
|
|
63
74
|
backend.set_pixels(rgba_bytes, 320, 180)
|
|
@@ -66,6 +77,18 @@ backend.close
|
|
|
66
77
|
|
|
67
78
|
The backend expects top-down RGBA8 bytes. Set
|
|
68
79
|
<code>TERMVAS_PROTOCOL</code> to force a protocol.
|
|
80
|
+
For Sixel output, pass <code>quantize: :median_cut</code> to the backend to use
|
|
81
|
+
Tessel's shared quantizer; the default uses the fixed palette.
|
|
82
|
+
|
|
83
|
+
The encoders, protocol detection, input parser, and terminal sizing utilities
|
|
84
|
+
load with <code>require "termvas"</code> alone. The optional RBGL backend is
|
|
85
|
+
available from <code>require "termvas/rbgl"</code>.
|
|
86
|
+
|
|
87
|
+
<code>Termvas::Terminal#size</code> returns <code>[columns, rows]</code>.
|
|
88
|
+
<code>#cell_size</code> returns estimated pixel dimensions as
|
|
89
|
+
<code>[width, height]</code> (8 × 16 by default). Set
|
|
90
|
+
<code>TERMVAS_CELL_WIDTH</code> and <code>TERMVAS_CELL_HEIGHT</code> to tune the
|
|
91
|
+
estimate; sizing does not query the terminal.
|
|
69
92
|
|
|
70
93
|
## Terminal notes
|
|
71
94
|
|
|
@@ -75,6 +98,19 @@ terminal cell area. Use <code>fit: :none</code> to keep the source size.
|
|
|
75
98
|
Inside tmux, Kitty and Sixel output uses DCS passthrough and requires
|
|
76
99
|
<code>allow-passthrough on</code> in tmux.
|
|
77
100
|
|
|
101
|
+
## SSH
|
|
102
|
+
|
|
103
|
+
Remote environment variables may not identify the local terminal. Select a
|
|
104
|
+
protocol supported by that terminal explicitly, and lower the frame rate on
|
|
105
|
+
slow links:
|
|
106
|
+
|
|
107
|
+
~~~sh
|
|
108
|
+
termvas play frames/*.png --protocol blocks --fps 5
|
|
109
|
+
~~~
|
|
110
|
+
|
|
111
|
+
For the Ruby backend, set <code>protocol: :blocks</code> and a lower
|
|
112
|
+
<code>max_fps</code> value.
|
|
113
|
+
|
|
78
114
|
## Development
|
|
79
115
|
|
|
80
116
|
~~~sh
|
|
@@ -84,6 +120,10 @@ bundle exec rake verify
|
|
|
84
120
|
|
|
85
121
|
See [docs/keys.md](docs/keys.md) for the input event mapping.
|
|
86
122
|
|
|
123
|
+
## Contributing
|
|
124
|
+
|
|
125
|
+
Bug reports and pull requests are welcome at [rbgfx/termvas](https://github.com/rbgfx/termvas).
|
|
126
|
+
|
|
87
127
|
## License
|
|
88
128
|
|
|
89
129
|
[MIT](LICENSE.txt)
|
data/docs/index.html
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head>
|
|
3
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4
|
+
<meta name="theme-color" content="#020617"><meta name="description" content="Display RGBA images with half blocks, Kitty, iTerm2, or Sixel, and read keyboard and mouse input.">
|
|
5
|
+
<meta property="og:title" content="Termvas — Put the frame in the terminal."><meta property="og:description" content="Display RGBA images with half blocks, Kitty, iTerm2, or Sixel, and read keyboard and mouse input."><meta property="og:type" content="website"><meta property="og:url" content="https://rbgfx.github.io/termvas/">
|
|
6
|
+
<meta name="twitter:card" content="summary"><link rel="canonical" href="https://rbgfx.github.io/termvas/"><title>Termvas — Put the frame in the terminal.</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{color-scheme:dark;--bg:#020617;--panel:#0f172a;--line:#334155;--ink:#e2e8f0;--muted:#94a3b8;--a:#38bdf8;--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--sans:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--w:min(100% - 2.5rem,76rem)}
|
|
9
|
+
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 var(--sans)}a{color:inherit;text-underline-offset:.22em}a:hover{color:var(--a)}a:focus-visible{outline:3px solid var(--a);outline-offset:4px}h1,h2,h3,p,figure,pre{margin:0}h1,h2,h3{line-height:1.08;text-wrap:balance}p{text-wrap:pretty}code,pre,.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}.wrap{width:var(--w);margin:auto}.skip{position:fixed;left:1rem;top:1rem;z-index:1;transform:translateY(-160%);padding:.7rem 1rem;background:var(--ink);color:var(--bg)}.skip:focus{transform:none}
|
|
10
|
+
header{height:4.75rem;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:.7rem;font-weight:750;text-decoration:none}.mark{width:2rem;height:2rem;display:grid;place-items:center;border:1px solid var(--a);color:var(--a);font:.85rem var(--mono)}nav{display:flex;gap:1.5rem;font-size:.9rem}nav a{text-decoration:none;color:var(--muted)}nav a:last-child{color:var(--ink)}
|
|
11
|
+
.hero{display:grid;grid-template-columns:.88fr 1.12fr;align-items:center;gap:clamp(2rem,6vw,6rem);padding-block:clamp(4rem,9vw,8rem)}.kicker,.visual figcaption,.num{font:.76rem/1.4 var(--mono);color:var(--a)}h1{max-width:11ch;margin-top:1rem;font-size:clamp(3rem,7.5vw,6.5rem);letter-spacing:-.065em}.lede{max-width:40rem;margin-top:1.5rem;color:var(--muted);font-size:1.15rem}.actions{display:flex;flex-wrap:wrap;gap:.8rem;margin-top:2rem}.button{min-height:2.8rem;padding:.65rem 1rem;border:1px solid var(--line);display:inline-flex;align-items:center;text-decoration:none;font-weight:650}.button.primary{background:var(--a);border-color:var(--a);color:var(--bg)}.install{margin-top:1.4rem;color:var(--muted);font:.86rem var(--mono)}.install code{color:var(--ink)}
|
|
12
|
+
.visual{border:1px solid var(--line);background:var(--panel)}.art{min-height:18rem;padding:1.25rem;display:grid;place-items:center;overflow:hidden}.art svg{width:100%;max-height:23rem}.visual figcaption{display:flex;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-top:1px solid var(--line);color:var(--muted)}.visual figcaption span:last-child{color:var(--a)}
|
|
13
|
+
section.info{padding-block:4.5rem;border-top:1px solid var(--line)}.section-head{display:grid;grid-template-columns:.72fr 1.28fr;gap:2rem;align-items:end;margin-bottom:2rem}h2{font-size:clamp(2rem,4vw,3.25rem);letter-spacing:-.045em}.section-head p,.feature p,.try-copy p{color:var(--muted)}.features{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}.feature{padding:1.25rem 1.2rem 1.4rem 0;border-bottom:1px solid var(--line)}.feature+.feature{padding-left:1.2rem;border-left:1px solid var(--line)}.feature h3{margin-top:.8rem;font-size:1.1rem}.feature p{margin-top:.5rem;font-size:.94rem}.try{display:grid;grid-template-columns:.75fr 1.25fr;align-items:center;gap:clamp(2rem,7vw,7rem)}.try-copy p{margin-top:1rem}.try-copy a{color:var(--ink)}pre{overflow-x:auto;padding:1.25rem;border:1px solid var(--line);background:var(--panel);font-size:.82rem;line-height:1.8;tab-size:2}
|
|
14
|
+
footer{display:flex;justify-content:space-between;gap:1rem;padding-block:1.6rem;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}footer div{display:flex;gap:1.25rem}
|
|
15
|
+
@media(max-width:800px){.hero{grid-template-columns:1fr;gap:2.5rem}.hero-copy{max-width:42rem}.section-head,.try{grid-template-columns:1fr;gap:1rem}}@media(max-width:560px){:root{--w:min(100% - 1.5rem,76rem)}header{height:4.2rem}nav{gap:.8rem;font-size:.8rem}nav a:first-child{display:none}h1{font-size:clamp(3rem,15vw,5rem)}.features{grid-template-columns:1fr}.feature,.feature+.feature{padding:1.1rem 0;border-left:0}.num{display:inline-block}footer{flex-direction:column}.art{min-height:14rem}section.info{padding-block:3.5rem}pre{font-size:.75rem;padding:1rem}}
|
|
16
|
+
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
|
17
|
+
.terminal{width:100%;max-width:30rem;padding:1rem;border:1px solid #334155;background:#020617;font:.7rem var(--mono);color:#94a3b8}.pixels{display:grid;grid-template-columns:repeat(6,1fr);gap:3px;width:13rem;margin:1.2rem auto}.pixels i{aspect-ratio:1;background:#065f46}.pixels i:nth-child(3n){background:#34d399}.pixels i:nth-child(4n){background:#a7f3d0}.protocol{display:flex;gap:1rem;border-top:1px solid #334155;padding-top:.7rem;color:#34d399}
|
|
18
|
+
</style>
|
|
19
|
+
</head><body style="--a:#34d399"><a class="skip" href="#main">Skip to content</a><div class="wrap">
|
|
20
|
+
<header><a class="brand" href="#top" aria-label="Termvas home"><span class="mark" aria-hidden="true">T</span><span>Termvas</span></a><nav aria-label="Primary navigation"><a href="#features">Overview</a><a href="#quick-start">Quick start</a><a href="https://github.com/rbgfx/termvas">Source ↗</a></nav></header>
|
|
21
|
+
<main id="main"><section class="hero" id="top" aria-labelledby="title"><div class="hero-copy"><p class="kicker">Terminal image output · Ruby</p><h1 id="title">Put the frame in the terminal.</h1><p class="lede">Display RGBA images with half blocks, Kitty, iTerm2, or Sixel, and read keyboard and mouse input.</p><div class="actions"><a class="button primary" href="#quick-start">Get started</a><a class="button" href="https://rubygems.org/gems/termvas">RubyGems ↗</a></div><p class="install"><span aria-hidden="true">$</span> <code>gem install termvas</code></p></div>
|
|
22
|
+
<figure class="visual" aria-labelledby="cap-termvas"><div class="art"><div class="terminal"><div>termvas · image.png <span style="float:right">320 × 180</span></div><div class="pixels"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="protocol"><span>BLOCKS</span><span>KITTY</span><span>ITERM2</span><span>SIXEL</span></div></div></div><figcaption id="cap-termvas"><span>Terminal viewport</span><span>protocol: half blocks</span></figcaption></figure></section>
|
|
23
|
+
<section class="info" id="features" aria-labelledby="feat"><div class="section-head"><h2 id="feat">Images out. Input back.</h2><p>Terminal protocols, frame fitting, and input parsing for interactive applications.</p></div><div class="features"><article class="feature"><span class="num">01</span><h3>Detect the display</h3><p>Choose a protocol automatically or force one with an environment variable.</p></article><article class="feature"><span class="num">02</span><h3>View and play</h3><p>Inspect images or play frame sequences with nearest-neighbor fitting.</p></article><article class="feature"><span class="num">03</span><h3>Parse input safely</h3><p>Read split CSI, keyboard, wheel, and SGR mouse events.</p></article></div></section>
|
|
24
|
+
<section class="info try" id="quick-start" aria-labelledby="quick"><div class="try-copy"><p class="kicker">A small first step</p><h2 id="quick">Start with one example.</h2><p>Install the gem, then follow the project guide for platform requirements and the full API.</p><p><a href="https://github.com/rbgfx/termvas#installation">Read the installation guide ↗</a></p></div><pre><code>termvas doctor
|
|
25
|
+
termvas view image.png
|
|
26
|
+
termvas play frames/*.png --fps 10
|
|
27
|
+
|
|
28
|
+
backend = Termvas::Backend.new(320, 180, protocol: :blocks)
|
|
29
|
+
# rgba_bytes contains top-down RGBA8 pixel data.
|
|
30
|
+
backend.set_pixels(rgba_bytes, 320, 180)
|
|
31
|
+
backend.close</code></pre></section></main>
|
|
32
|
+
<footer><span>Termvas · Ruby 3.1+ · MIT</span><div><a href="https://github.com/rbgfx/termvas">GitHub</a><a href="https://github.com/rbgfx/termvas/blob/main/LICENSE.txt">License</a></div></footer></div></body></html>
|
data/exe/termvas
CHANGED
data/lib/termvas/rbgl.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbgl"
|
|
4
|
+
require "termvas"
|
|
5
|
+
|
|
6
|
+
module Termvas
|
|
7
|
+
class Backend < RBGL::GUI::Backend
|
|
8
|
+
attr_reader :width, :height, :protocol
|
|
9
|
+
|
|
10
|
+
def initialize(width, height, protocol: :auto, fit: :contain, max_fps: 30, quantize: :fixed, tmux: :auto, output: $stdout, input: $stdin, alt_screen: true, **_options)
|
|
11
|
+
super(Integer(width), Integer(height), "Termvas")
|
|
12
|
+
@protocol = protocol == :auto ? Detector.protocol : protocol.to_sym
|
|
13
|
+
@quantize = quantize.to_sym
|
|
14
|
+
tmux = ENV.key?("TMUX") if tmux == :auto
|
|
15
|
+
tmux &&= %i[kitty sixel].include?(@protocol)
|
|
16
|
+
raise ArgumentError, "unsupported terminal protocol: #{@protocol}" unless %i[blocks kitty iterm2 sixel].include?(@protocol)
|
|
17
|
+
@fit = fit.to_sym
|
|
18
|
+
raise ArgumentError, "fit must be :contain or :none" unless %i[contain none].include?(@fit)
|
|
19
|
+
@max_fps = max_fps.nil? ? nil : Float(max_fps)
|
|
20
|
+
raise ArgumentError, "max_fps must be positive" if @max_fps && !@max_fps.positive?
|
|
21
|
+
@terminal = Terminal.new(input: input, output: output, alt_screen: alt_screen, tmux: tmux)
|
|
22
|
+
@output = output
|
|
23
|
+
@input = input
|
|
24
|
+
@closed = false
|
|
25
|
+
@previous = nil
|
|
26
|
+
@parser = InputParser.new
|
|
27
|
+
@last_frame_at = nil
|
|
28
|
+
@terminal.open
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def open = (@terminal.open; self)
|
|
32
|
+
|
|
33
|
+
def present(framebuffer)
|
|
34
|
+
pixels = framebuffer.respond_to?(:to_rgba_bytes) ? framebuffer.to_rgba_bytes : framebuffer
|
|
35
|
+
set_pixels(pixels, framebuffer.width, framebuffer.height)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set_pixels(bytes, width = @width, height = @height)
|
|
39
|
+
bytes = validate_rgba_buffer(bytes, width, height)
|
|
40
|
+
width, height = Integer(width), Integer(height)
|
|
41
|
+
if @fit == :contain
|
|
42
|
+
columns, rows = @terminal.size
|
|
43
|
+
target_width, target_height = Scaler.contain(width, height, columns, rows)
|
|
44
|
+
bytes, width, height = Scaler.nearest(bytes, width, height, target_width, target_height)
|
|
45
|
+
end
|
|
46
|
+
@previous = nil if @previous_size && @previous_size != [width, height]
|
|
47
|
+
return if @previous == bytes
|
|
48
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
return if @last_frame_at && @max_fps && now - @last_frame_at < 1.0 / @max_fps
|
|
50
|
+
|
|
51
|
+
encoded = case @protocol
|
|
52
|
+
when :kitty then Encoders::Kitty.encode(bytes, width, height)
|
|
53
|
+
when :iterm2
|
|
54
|
+
require "tessel"
|
|
55
|
+
Encoders::ITerm2.encode(Tessel::PNG.encode(Tessel::Image.from_rgba(width, height, bytes)), width: width, height: height)
|
|
56
|
+
when :sixel then Encoders::Sixel.encode(bytes, width, height, quantize: @quantize)
|
|
57
|
+
else Encoders::Blocks.encode(bytes, width, height, previous: @previous)
|
|
58
|
+
end
|
|
59
|
+
@terminal.write(encoded)
|
|
60
|
+
@previous = bytes.dup
|
|
61
|
+
@previous_size = [width, height]
|
|
62
|
+
@last_frame_at = now
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def poll_events
|
|
66
|
+
return [] unless @input.respond_to?(:read_nonblock)
|
|
67
|
+
resize = if @terminal.resized?
|
|
68
|
+
columns, rows = @terminal.size
|
|
69
|
+
{ type: :resize, columns: columns, rows: rows }
|
|
70
|
+
end
|
|
71
|
+
bytes = @input.read_nonblock(4096, exception: false)
|
|
72
|
+
events = bytes.is_a?(String) ? @parser.feed(bytes) : []
|
|
73
|
+
events = map_mouse_events(events)
|
|
74
|
+
events.unshift(resize) if resize
|
|
75
|
+
if events.any? { |event| event[:key] == :close }
|
|
76
|
+
@closed = true
|
|
77
|
+
@terminal.close
|
|
78
|
+
end
|
|
79
|
+
events
|
|
80
|
+
rescue IOError, Errno::EIO
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def should_close?
|
|
85
|
+
@closed
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def close
|
|
89
|
+
return if @closed
|
|
90
|
+
@closed = true
|
|
91
|
+
@terminal.close
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def map_mouse_events(events)
|
|
97
|
+
return events unless @previous_size
|
|
98
|
+
|
|
99
|
+
columns, rows = @terminal.size
|
|
100
|
+
display_width = [@previous_size[0], columns].min
|
|
101
|
+
display_height = [@previous_size[1], rows * 2].min
|
|
102
|
+
events.filter_map do |event|
|
|
103
|
+
next event unless event.key?(:x) && event.key?(:y)
|
|
104
|
+
|
|
105
|
+
x = event[:x] * display_width.to_f / columns
|
|
106
|
+
y = event[:y] * display_height.to_f / rows
|
|
107
|
+
next if x >= display_width || y >= display_height
|
|
108
|
+
|
|
109
|
+
event.merge(x: x, y: y)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/termvas/version.rb
CHANGED
data/lib/termvas.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "io/console"
|
|
4
4
|
require "zlib"
|
|
5
|
-
require "rbgl"
|
|
6
5
|
|
|
7
6
|
require_relative "termvas/version"
|
|
8
7
|
|
|
@@ -100,7 +99,8 @@ module Termvas
|
|
|
100
99
|
raise ArgumentError, "unsupported Sixel quantizer: #{quantize}" unless %i[fixed median_cut].include?(quantize)
|
|
101
100
|
if quantize == :median_cut
|
|
102
101
|
raise ArgumentError, "palette cannot be combined with median cut" if palette
|
|
103
|
-
|
|
102
|
+
require "tessel"
|
|
103
|
+
quantized, palette = Tessel::Quantize.quantize(Tessel::Image.from_rgba(width, height, bytes), colors: 256)
|
|
104
104
|
end
|
|
105
105
|
fixed_palette = palette.nil?
|
|
106
106
|
palette ||= default_palette
|
|
@@ -167,49 +167,6 @@ module Termvas
|
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
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
170
|
class InputParser
|
|
214
171
|
CSI_KEYS = { "A" => :up, "B" => :down, "C" => :right, "D" => :left, "H" => :home, "F" => :end }.freeze
|
|
215
172
|
CSI_TILDE_KEYS = { 1 => :home, 2 => :insert, 3 => :delete, 4 => :end, 5 => :page_up, 6 => :page_down }.freeze
|
|
@@ -314,10 +271,11 @@ module Termvas
|
|
|
314
271
|
class Terminal
|
|
315
272
|
attr_reader :input, :output
|
|
316
273
|
|
|
317
|
-
def initialize(input: $stdin, output: $stdout, alt_screen: true, tmux: false)
|
|
274
|
+
def initialize(input: $stdin, output: $stdout, alt_screen: true, tmux: false, env: ENV)
|
|
318
275
|
@input = input
|
|
319
276
|
@output = output
|
|
320
277
|
@alt_screen = alt_screen
|
|
278
|
+
@env = env
|
|
321
279
|
@closed = false
|
|
322
280
|
@opened = false
|
|
323
281
|
@raw = false
|
|
@@ -331,10 +289,14 @@ module Termvas
|
|
|
331
289
|
return [columns, rows] if rows.to_i.positive? && columns.to_i.positive?
|
|
332
290
|
|
|
333
291
|
fallback_size
|
|
334
|
-
rescue Errno::ENOTTY, IOError, NoMethodError
|
|
292
|
+
rescue Errno::ENODEV, Errno::ENOTTY, IOError, NoMethodError
|
|
335
293
|
fallback_size
|
|
336
294
|
end
|
|
337
295
|
|
|
296
|
+
def cell_size
|
|
297
|
+
[environment_dimension("TERMVAS_CELL_WIDTH", 8), environment_dimension("TERMVAS_CELL_HEIGHT", 16)]
|
|
298
|
+
end
|
|
299
|
+
|
|
338
300
|
def open
|
|
339
301
|
return self if @opened
|
|
340
302
|
@opened = true
|
|
@@ -410,117 +372,19 @@ module Termvas
|
|
|
410
372
|
end
|
|
411
373
|
|
|
412
374
|
def fallback_size
|
|
413
|
-
columns = Integer(
|
|
414
|
-
rows = Integer(
|
|
375
|
+
columns = Integer(@env.fetch("COLUMNS", 80))
|
|
376
|
+
rows = Integer(@env.fetch("LINES", 24))
|
|
415
377
|
[columns.positive? ? columns : 80, rows.positive? ? rows : 24]
|
|
416
378
|
rescue ArgumentError, TypeError
|
|
417
379
|
[80, 24]
|
|
418
380
|
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
381
|
|
|
451
|
-
def
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
382
|
+
def environment_dimension(name, fallback)
|
|
383
|
+
value = Integer(@env.fetch(name, fallback))
|
|
384
|
+
value.positive? ? value : fallback
|
|
385
|
+
rescue ArgumentError, TypeError
|
|
386
|
+
fallback
|
|
524
387
|
end
|
|
525
388
|
end
|
|
389
|
+
|
|
526
390
|
end
|
data/sig/termvas.rbs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
module Termvas
|
|
2
2
|
VERSION: String
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
class Terminal
|
|
5
|
+
def initialize: (?input: untyped, ?output: untyped, ?alt_screen: bool, ?tmux: bool, ?env: Hash[String, String]) -> void
|
|
6
|
+
def size: () -> [Integer, Integer]
|
|
7
|
+
def cell_size: () -> [Integer, Integer]
|
|
8
|
+
end
|
|
4
9
|
end
|
metadata
CHANGED
|
@@ -1,28 +1,15 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: termvas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
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
|
|
11
|
+
date: 2026-09-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
26
13
|
description: Half block, Kitty, iTerm2, and Sixel terminal output with input parsing.
|
|
27
14
|
email:
|
|
28
15
|
- t.yudai92@gmail.com
|
|
@@ -35,9 +22,11 @@ files:
|
|
|
35
22
|
- LICENSE.txt
|
|
36
23
|
- README.md
|
|
37
24
|
- Rakefile
|
|
25
|
+
- docs/index.html
|
|
38
26
|
- docs/keys.md
|
|
39
27
|
- exe/termvas
|
|
40
28
|
- lib/termvas.rb
|
|
29
|
+
- lib/termvas/rbgl.rb
|
|
41
30
|
- lib/termvas/version.rb
|
|
42
31
|
- sig/termvas.rbs
|
|
43
32
|
homepage: https://github.com/rbgfx/termvas
|
|
@@ -46,6 +35,7 @@ licenses:
|
|
|
46
35
|
metadata:
|
|
47
36
|
homepage_uri: https://github.com/rbgfx/termvas
|
|
48
37
|
source_code_uri: https://github.com/rbgfx/termvas/tree/main
|
|
38
|
+
post_install_message:
|
|
49
39
|
rdoc_options: []
|
|
50
40
|
require_paths:
|
|
51
41
|
- lib
|
|
@@ -60,7 +50,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
60
50
|
- !ruby/object:Gem::Version
|
|
61
51
|
version: '0'
|
|
62
52
|
requirements: []
|
|
63
|
-
rubygems_version:
|
|
53
|
+
rubygems_version: 3.5.22
|
|
54
|
+
signing_key:
|
|
64
55
|
specification_version: 4
|
|
65
56
|
summary: Terminal backend for Ruby graphics
|
|
66
57
|
test_files: []
|