termvas 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +1 -1
- data/README.md +47 -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 +2 -150
- 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: e006617f9d17384317e9c45880fbdef15e35049894eb82013557d170e805c97d
|
|
4
|
+
data.tar.gz: 05fc5d8aeff21250c54bef69b4cd81c023d1c6c02aaac47c1ad72046f3c22ccd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 64cad8ca3988821b0361941f9027c2ff763e7b5b16e5fd8ac7a6b7b9e6f5859697474fe2901f001f98c1525c825aeeb8dae5e3b1c4b70eabd79897a030db556e
|
|
7
|
+
data.tar.gz: 3cdda1da18561fd32d131dda835f19fc6062b7f4626acef9b0713b5a8ea843cf788843b5f2750b9d656cced5fbf811bfece6c01a3dd257582f540675a2485f81
|
data/CHANGELOG.md
CHANGED
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,12 @@ 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>.
|
|
69
86
|
|
|
70
87
|
## Terminal notes
|
|
71
88
|
|
|
@@ -75,6 +92,19 @@ terminal cell area. Use <code>fit: :none</code> to keep the source size.
|
|
|
75
92
|
Inside tmux, Kitty and Sixel output uses DCS passthrough and requires
|
|
76
93
|
<code>allow-passthrough on</code> in tmux.
|
|
77
94
|
|
|
95
|
+
## SSH
|
|
96
|
+
|
|
97
|
+
Remote environment variables may not identify the local terminal. Select a
|
|
98
|
+
protocol supported by that terminal explicitly, and lower the frame rate on
|
|
99
|
+
slow links:
|
|
100
|
+
|
|
101
|
+
~~~sh
|
|
102
|
+
termvas play frames/*.png --protocol blocks --fps 5
|
|
103
|
+
~~~
|
|
104
|
+
|
|
105
|
+
For the Ruby backend, set <code>protocol: :blocks</code> and a lower
|
|
106
|
+
<code>max_fps</code> value.
|
|
107
|
+
|
|
78
108
|
## Development
|
|
79
109
|
|
|
80
110
|
~~~sh
|
|
@@ -84,6 +114,10 @@ bundle exec rake verify
|
|
|
84
114
|
|
|
85
115
|
See [docs/keys.md](docs/keys.md) for the input event mapping.
|
|
86
116
|
|
|
117
|
+
## Contributing
|
|
118
|
+
|
|
119
|
+
Bug reports and pull requests are welcome at [rbgfx/termvas](https://github.com/rbgfx/termvas).
|
|
120
|
+
|
|
87
121
|
## License
|
|
88
122
|
|
|
89
123
|
[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
|
|
@@ -418,109 +375,4 @@ module Termvas
|
|
|
418
375
|
end
|
|
419
376
|
end
|
|
420
377
|
|
|
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
378
|
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.2.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: []
|