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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5863ee4752675b2b9b5c77b5e4bf0d0a4864911c7976ce661d21e468dd92e0b6
4
- data.tar.gz: 3b850fdefa94a1e8707e320717f72bd9a2822c1142fc098ba0c64e2c0d520d88
3
+ metadata.gz: d3cf29b6b2bd27eb84a1cda85bb993a8e5f642bb27c97aab69f944a968809374
4
+ data.tar.gz: 646acf9519788a0944ae798247a5d82ee7f63c8db098fb65892b0876ddca285e
5
5
  SHA512:
6
- metadata.gz: 7aed972961ce1dd18f54e9973ec4952becc545c35178bf87bd9f838694e018e49921bbf01cc6cd7d60b552e5890af0b77458269471dd758ac2d4da3192719ea9
7
- data.tar.gz: 285deddc562970d9e952fb35fd76ebc4a2d4b8ef7926e2d7dfd0f69e339f6c14edf634e001b2023af9e5604389f7bf5252cb37f0df93b4e0dd07c734739169fe
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
@@ -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,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
@@ -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.3.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
@@ -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(ENV.fetch("COLUMNS", 80))
414
- rows = Integer(ENV.fetch("LINES", 24))
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 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
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
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
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.1.0
4
+ version: 0.3.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: []