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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5863ee4752675b2b9b5c77b5e4bf0d0a4864911c7976ce661d21e468dd92e0b6
4
- data.tar.gz: 3b850fdefa94a1e8707e320717f72bd9a2822c1142fc098ba0c64e2c0d520d88
3
+ metadata.gz: e006617f9d17384317e9c45880fbdef15e35049894eb82013557d170e805c97d
4
+ data.tar.gz: 05fc5d8aeff21250c54bef69b4cd81c023d1c6c02aaac47c1ad72046f3c22ccd
5
5
  SHA512:
6
- metadata.gz: 7aed972961ce1dd18f54e9973ec4952becc545c35178bf87bd9f838694e018e49921bbf01cc6cd7d60b552e5890af0b77458269471dd758ac2d4da3192719ea9
7
- data.tar.gz: 285deddc562970d9e952fb35fd76ebc4a2d4b8ef7926e2d7dfd0f69e339f6c14edf634e001b2023af9e5604389f7bf5252cb37f0df93b4e0dd07c734739169fe
6
+ metadata.gz: 64cad8ca3988821b0361941f9027c2ff763e7b5b16e5fd8ac7a6b7b9e6f5859697474fe2901f001f98c1525c825aeeb8dae5e3b1c4b70eabd79897a030db556e
7
+ data.tar.gz: 3cdda1da18561fd32d131dda835f19fc6062b7f4626acef9b0713b5a8ea843cf788843b5f2750b9d656cced5fbf811bfece6c01a3dd257582f540675a2485f81
data/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-09-25
6
+
7
+ - Make RBGL an optional backend loaded with `termvas/rbgl`.
8
+ - Use Tessel's shared quantizer for median-cut Sixel output.
9
+
5
10
  ## [0.1.0] - 2026-09-23
6
11
 
7
12
  - Initial release.
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 ydah
3
+ Copyright (c) 2026 Yudai Takada
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,18 +1,19 @@
1
- # Termvas
1
+ <h1 align="center">Termvas</h1>
2
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)
3
+ <p align="center">A terminal display backend for Ruby graphics.</p>
8
4
 
9
- > A terminal display backend for Ruby graphics.
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&amp;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
- 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.
12
+ [Features](#features) · [Installation](#installation) · [Quick Start](#quick-start) · [Terminal Notes](#terminal-notes)
14
13
 
15
- **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Terminal notes](#terminal-notes) · [Development](#development)**
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
- ## Quick start
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
@@ -10,6 +10,7 @@ if command == "doctor"
10
10
  puts "terminal: #{ENV['TERM'] || 'unknown'}"
11
11
  puts "size: #{IO.console&.winsize&.join('x') || 'unknown'}"
12
12
  elsif %w[view play].include?(command)
13
+ require "termvas/rbgl"
13
14
  require "tessel"
14
15
  fps = 10.0
15
16
  protocol = :auto
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Termvas
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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
- palette, quantized = Quantizer.median_cut(bytes, colors: 256)
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
- - ydah
7
+ - Yudai Takada
8
+ autorequire:
8
9
  bindir: exe
9
10
  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
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: 4.0.16
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: []