rubyterm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +144 -0
- data/bin/record +3 -0
- data/bin/rubyterm +11 -0
- data/example-config.toml +61 -0
- data/lib/ansibackend.rb +155 -0
- data/lib/bitmapwindow.rb +176 -0
- data/lib/charsets.rb +52 -0
- data/lib/controller.rb +80 -0
- data/lib/escapeparser.rb +71 -0
- data/lib/keymap.rb +112 -0
- data/lib/palette.rb +14 -0
- data/lib/rubyterm/app.rb +580 -0
- data/lib/rubyterm/version.rb +5 -0
- data/lib/rubyterm.rb +47 -0
- data/lib/term.rb +657 -0
- data/lib/termbuffer.rb +365 -0
- data/lib/trackchanges.rb +319 -0
- data/lib/utf8decoder.rb +77 -0
- data/lib/window.rb +410 -0
- data/lib/windowadapter.rb +161 -0
- metadata +127 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 20a2cc824f4577e989c6088109c8834f87ce4f4cc0ac665c675d491c4d41d26c
|
|
4
|
+
data.tar.gz: b1f1aec85cf6371a0849cd751fa66c2574e7000ab7ce239b732a83a4ab98e772
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0326e398a563a39696b46ed2c2bf0747083a73b191be8cc29ac0d691a5f7fb496954eaee316d18575dc49dc9ae38fa37e5ebf92d4f9b11c980021989eceba51c
|
|
7
|
+
data.tar.gz: 0f8ba682ebce7530c59202637ca5e2c8c103473b489c947eb86173d8ef6fb5ae0e817ffa08830a0aa9d609409aa1aaa54003793f9e8348007f45b7631e000fff
|
data/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# rubyterm
|
|
2
|
+
|
|
3
|
+
A terminal emulator written **entirely in Ruby** — including a pure-Ruby
|
|
4
|
+
X11 client to talk to the X server. There is no C extension and no libvte;
|
|
5
|
+
the escape-sequence interpreter, the screen buffer, the rendering and the
|
|
6
|
+
X11 protocol handling are all Ruby.
|
|
7
|
+
|
|
8
|
+
It is still rough and opinionated, but it now runs as an installable gem
|
|
9
|
+
with a `rubyterm` executable, renders with a damage-driven pipeline
|
|
10
|
+
(scrollback, text selection/copy, truecolor and UTF-8 all work), and is
|
|
11
|
+
split into a reusable terminal *engine* that can be driven without X11 at
|
|
12
|
+
all.
|
|
13
|
+
|
|
14
|
+
> **You probably don't want to depend on this yet.** Escape-code coverage
|
|
15
|
+
> is partial (VT100/VT102 plus a useful chunk of xterm), font handling is
|
|
16
|
+
> basic, and the keymap is limited. Full-screen apps mostly work; some will
|
|
17
|
+
> still misbehave. I have specific ideas about direction, so if you'd like
|
|
18
|
+
> to contribute, **talk to me first** (vidar@hokstad.com) or fork — I won't
|
|
19
|
+
> promise to merge changes we haven't discussed.
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
The code is deliberately small and split along clean seams so the pieces
|
|
24
|
+
are independently usable and testable:
|
|
25
|
+
|
|
26
|
+
- **Engine** — the escape interpreter (`Term`), the damage tracker
|
|
27
|
+
(`TrackChanges`), the columnar screen buffer (`TermBuffer`) and the
|
|
28
|
+
escape/UTF-8 parsers. No pixels, no X11; drivable headlessly.
|
|
29
|
+
- **Backends** — anything implementing the small drawing interface:
|
|
30
|
+
- `Window` — the pure-Ruby X11 backend (the real terminal);
|
|
31
|
+
- `AnsiBackend` — re-emits to an ANSI/escape stream (run a terminal
|
|
32
|
+
inside another terminal; see `examples/terminal_in_terminal.rb`);
|
|
33
|
+
- `BitmapWindow` — rasterises glyphs with skrift into an in-memory RGB
|
|
34
|
+
buffer (headless rendering / visual testing, PNG output).
|
|
35
|
+
- **Application** — `RubyTerm` (in `lib/rubyterm/app.rb`), which owns the
|
|
36
|
+
X window, the pty controller and the input/blink/flush threads and wires
|
|
37
|
+
the engine to the X11 backend. `bin/rubyterm` runs it.
|
|
38
|
+
|
|
39
|
+
Rendering is **damage-driven**: writing a cell only mutates the buffer and
|
|
40
|
+
bumps a per-cell generation; a flush walks the damage and redraws just the
|
|
41
|
+
changed cells. A flood of output (an accidental `cat` of a large file) is
|
|
42
|
+
*jump-scrolled* — interpreted across many chunks and painted once.
|
|
43
|
+
|
|
44
|
+
For the full picture and the rationale behind the layering, see:
|
|
45
|
+
|
|
46
|
+
- [`docs/architecture-review.md`](docs/architecture-review.md) — the
|
|
47
|
+
architecture critique, the phased refactoring plan, and the Ruby
|
|
48
|
+
performance notes (with measured results).
|
|
49
|
+
- [`docs/seams.md`](docs/seams.md) — the layer seams: the screen-operation
|
|
50
|
+
API and the backend drawing protocol.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
Dependencies are managed with Bundler; `skrift` and `skrift-x11` are
|
|
55
|
+
developed alongside this project and are referenced as local path gems in
|
|
56
|
+
the `Gemfile`.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
bundle install
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Running
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
bundle exec rubyterm # start a terminal running your $SHELL
|
|
66
|
+
bundle exec rubyterm bash -lc htop # ...or run a specific command
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With some luck you'll get a terminal window. Once the gem (and its
|
|
70
|
+
dependencies) are installed system-wide, the `rubyterm` executable can be
|
|
71
|
+
run directly.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Configuration is read from `~/.config/rterm/config.toml` (TOML). See
|
|
76
|
+
[`example-config.toml`](example-config.toml) for a complete example. If the
|
|
77
|
+
file is absent, defaults are used.
|
|
78
|
+
|
|
79
|
+
- **`shell`** — path to the shell to launch. Defaults to `$SHELL`, then
|
|
80
|
+
`/bin/sh`. Example: `shell = "/bin/bash"`.
|
|
81
|
+
- **`fonts`** — fonts to use, in priority order; later fonts cover glyphs
|
|
82
|
+
missing from earlier ones. Each entry may be a direct path
|
|
83
|
+
(`"~/fonts/MyFont.ttf"`), a file in `~/.local/share/fonts/`, or a name
|
|
84
|
+
resolved via `fc-match` (`"monospace"`, `"monospace:weight=bold"`).
|
|
85
|
+
- **`fontsize`** — font size in points (e.g. `fontsize = 24`).
|
|
86
|
+
- **`deccolm`** — how the 80/132-column DECCOLM switch is realised:
|
|
87
|
+
`"font"` (rescale the glyph cell, the default) or `"window"` (ask the WM
|
|
88
|
+
to resize).
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
shell = "/bin/zsh"
|
|
92
|
+
fonts = [
|
|
93
|
+
"FiraCode-Regular.ttf", # programming font
|
|
94
|
+
"unifont-15.0.06.ttf", # Unicode fallback
|
|
95
|
+
"monospace" # system fallback
|
|
96
|
+
]
|
|
97
|
+
fontsize = 24
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
rake test # the minitest unit/integration suite
|
|
104
|
+
rake run # run the terminal (alias for bin/rubyterm)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
There is a deterministic **test harness** for terminal correctness: it
|
|
108
|
+
runs cases through the engine and an oracle (tmux), diffs the resulting
|
|
109
|
+
screen state, and gates regressions with a ratchet. It can also record and
|
|
110
|
+
replay real applications, and run an instrumented live terminal with a
|
|
111
|
+
debug socket.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
ruby harness/cli.rb run --case cases/synthetic/dch.bin --oracle tmux
|
|
115
|
+
ruby harness/cli.rb sweep --cases cases --oracle tmux --ratchet ratchet.json
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- [`docs/harness.md`](docs/harness.md) — the harness guide, and
|
|
119
|
+
[`docs/harness-quickstart.md`](docs/harness-quickstart.md) — from a glitch
|
|
120
|
+
to a minimal repro.
|
|
121
|
+
- [`docs/state-schema.md`](docs/state-schema.md) — the JSON terminal-state
|
|
122
|
+
dump schema.
|
|
123
|
+
- [`docs/debugging-live-render.md`](docs/debugging-live-render.md) —
|
|
124
|
+
debugging live-terminal display corruption.
|
|
125
|
+
- [`docs/bench-baseline.md`](docs/bench-baseline.md) — the performance
|
|
126
|
+
baseline used to gate the optimisation work.
|
|
127
|
+
|
|
128
|
+
## Direction
|
|
129
|
+
|
|
130
|
+
Where I want to take this:
|
|
131
|
+
|
|
132
|
+
- Keep the engine fully decoupled from the pty and X11 so Ruby applications
|
|
133
|
+
can instantiate a "terminal" with an IO object as its interface — the
|
|
134
|
+
first consumer being my own text editor — and package the engine as a
|
|
135
|
+
gem. (The split exists; the gem and the editor migration are in progress.)
|
|
136
|
+
- Make the terminal complete enough to run most Unix command-line tools: a
|
|
137
|
+
reasonably complete, Unicode-aware xterm/VT100.
|
|
138
|
+
- Keep the code **small but understandable**. Terseness is valued only
|
|
139
|
+
while it preserves readability.
|
|
140
|
+
|
|
141
|
+
## Resources
|
|
142
|
+
|
|
143
|
+
- xterm control sequences: <https://invisible-island.net/xterm/ctlseqs/ctlseqs.html>
|
|
144
|
+
- XFree86 control sequences: <https://www.xfree86.org/current/ctlseqs.html>
|
data/bin/record
ADDED
data/bin/rubyterm
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Make the gem's own lib/ importable when run straight from a checkout
|
|
5
|
+
# (when installed as a gem, rubygems already has it on the load path).
|
|
6
|
+
lib = File.expand_path("../lib", __dir__)
|
|
7
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
8
|
+
|
|
9
|
+
require "rubyterm"
|
|
10
|
+
|
|
11
|
+
RubyTerm.new(ARGV).run(ARGV)
|
data/example-config.toml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# You can give a single font, or an array.
|
|
4
|
+
# If the name is a valid path, we try to open
|
|
5
|
+
# it directly, after expansion (so ~ works).
|
|
6
|
+
#
|
|
7
|
+
# We also check ~/.local/share/fonts/[string]
|
|
8
|
+
#
|
|
9
|
+
# If we don't find a file that way, we execute:
|
|
10
|
+
#
|
|
11
|
+
# fc-match --format='%{file}\n' $string
|
|
12
|
+
#
|
|
13
|
+
# If you want to force a fc-match (e.g. to
|
|
14
|
+
# allow config file substitutions to take
|
|
15
|
+
# precedence) then leave out the extension
|
|
16
|
+
# E.g. if you have "Space Mono.ttf" in your
|
|
17
|
+
# .local/share/fonts directory, and put "Space Mono"
|
|
18
|
+
# below, we will run fc-match (and likely, but
|
|
19
|
+
# not necessarily, find the same font).
|
|
20
|
+
#
|
|
21
|
+
# You can also include fc-match options.
|
|
22
|
+
# This *includes* using '-s' or other options
|
|
23
|
+
# that returns a list.
|
|
24
|
+
#
|
|
25
|
+
# *BE CAREFUL*, as if a glyph is unavailable
|
|
26
|
+
# in a given font, we will look through each
|
|
27
|
+
# font in turn until we either find it or
|
|
28
|
+
# reach the end of the list, and this can get
|
|
29
|
+
# slow
|
|
30
|
+
#
|
|
31
|
+
#
|
|
32
|
+
fonts = [
|
|
33
|
+
#"Topaznew.ttf",
|
|
34
|
+
#"WP BoxDrawing.ttf",
|
|
35
|
+
# FIXME: Figure out why substitution fails for these:
|
|
36
|
+
"FiraCode-Regular.ttf", # Can't handle MD headings
|
|
37
|
+
# "NovaMono for Powerline.ttf", # Seriously broken
|
|
38
|
+
# "ProFont For Powerline.ttf", # Can't handle '#' comments
|
|
39
|
+
"unifont-15.0.06.ttf", # Unifont has "almost everything" but scales badly
|
|
40
|
+
|
|
41
|
+
# FIXME: The fc-match seems to fail
|
|
42
|
+
"monospace",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
fontsize = 32
|
|
46
|
+
|
|
47
|
+
shell="/home/vidarh/bin/rsh"
|
|
48
|
+
|
|
49
|
+
# DECCOLM (the 80/132-column switch, ESC [ ? 3 h / l) is supported. Choose
|
|
50
|
+
# how the column change is realised:
|
|
51
|
+
#
|
|
52
|
+
# "font" - (default) rescale the glyph cell so the new column count
|
|
53
|
+
# fits the current window; the window keeps its size and the
|
|
54
|
+
# row count is preserved. Reliable everywhere.
|
|
55
|
+
# "window" - ask the window manager to resize the window to fit the new
|
|
56
|
+
# column count at the current font. Most "native", but depends
|
|
57
|
+
# on the WM honouring the resize request.
|
|
58
|
+
# "off" - ignore the column switch entirely (apps that send ESC[?3h/l
|
|
59
|
+
# cannot resize/rescale your terminal).
|
|
60
|
+
#
|
|
61
|
+
#deccolm = "font"
|
data/lib/ansibackend.rb
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
require_relative 'termbuffer' # flag constants (BOLD, UNDERLINE, ...)
|
|
2
|
+
|
|
3
|
+
# A rendering backend that emits ANSI escape sequences instead of painting
|
|
4
|
+
# pixels: a drop-in for WindowAdapter (it satisfies the same draw / scroll /
|
|
5
|
+
# clear / clear_line / insert_lines / delete_lines interface the run-batcher
|
|
6
|
+
# in TrackChanges drives, plus the cell-metric and scrollback hooks Term
|
|
7
|
+
# queries). It turns the terminal's damage stream - changed runs of cells,
|
|
8
|
+
# plus scroll/clear ops - into the minimal escape sequences that reproduce
|
|
9
|
+
# the screen on a real terminal. This is the "render economically to a
|
|
10
|
+
# terminal, like Emacs" backend, and the basis for letting a TUI app render
|
|
11
|
+
# to a terminal OR an X11 window from the same code.
|
|
12
|
+
#
|
|
13
|
+
# Colours arrive already resolved to 24-bit RGB (Term#fg/#bg), so they are
|
|
14
|
+
# emitted as truecolor SGR. The run-batcher only hands us cells that
|
|
15
|
+
# changed, so only changed runs are emitted (CUP + minimal SGR + text). The
|
|
16
|
+
# cursor overlay (a cell drawn with the CURSOR background) is recognised and
|
|
17
|
+
# turned into a real cursor position rather than a coloured cell.
|
|
18
|
+
class AnsiBackend
|
|
19
|
+
CURSOR_BG = 0xff00ff # must match Term::CURSOR
|
|
20
|
+
|
|
21
|
+
# flag bit -> SGR set-code
|
|
22
|
+
SGR_FLAGS = {
|
|
23
|
+
BOLD => 1, FAINT => 2, ITALICS => 3, UNDERLINE => 4, BLINK => 5,
|
|
24
|
+
RAPID_BLINK => 6, INVERSE => 7, INVISIBLE => 8, CROSSED_OUT => 9,
|
|
25
|
+
DBL_UNDERLINE => 21, OVERLINE => 53,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# origin_row/origin_col place the rendered screen at an offset on the
|
|
29
|
+
# real terminal, so the same Term core can be drawn into a sub-window
|
|
30
|
+
# (a terminal-in-a-terminal / multiplexer pane). With a non-zero origin,
|
|
31
|
+
# full-screen erase becomes a per-row erase of just the sub-window.
|
|
32
|
+
def initialize(cols, rows, origin_row: 0, origin_col: 0)
|
|
33
|
+
@cols, @rows = cols, rows
|
|
34
|
+
@origin_row, @origin_col = origin_row, origin_col
|
|
35
|
+
@out = +"".b
|
|
36
|
+
reset_state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_state
|
|
40
|
+
@cx = @cy = nil # tracked cursor (nil = unknown -> force CUP)
|
|
41
|
+
@fg = @bg = @flags = nil # tracked SGR (nil = unknown)
|
|
42
|
+
@region = nil
|
|
43
|
+
@cursor_pos = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# # cell metrics: a text cell is one character
|
|
47
|
+
def char_w = 1
|
|
48
|
+
def char_h = 1
|
|
49
|
+
def scrollback_mode = false
|
|
50
|
+
def scrollback_anchor; end
|
|
51
|
+
def set_columns(_); end
|
|
52
|
+
|
|
53
|
+
# The escape sequence produced so far, with a trailing reposition to the
|
|
54
|
+
# cursor overlay if one was seen. Non-destructive.
|
|
55
|
+
def output
|
|
56
|
+
@cursor_pos ? @out + cup(@cursor_pos[1], @cursor_pos[0]) : @out.dup
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Take the output and reset the buffer for the next frame. The trailing
|
|
60
|
+
# cursor reposition moved the real terminal cursor, so forget the tracked
|
|
61
|
+
# position (the next frame's first draw must re-issue a CUP). SGR/region
|
|
62
|
+
# state persists - the real terminal keeps it between frames.
|
|
63
|
+
def take
|
|
64
|
+
s = output
|
|
65
|
+
@out = +"".b
|
|
66
|
+
@cursor_pos = nil
|
|
67
|
+
@cx = @cy = nil
|
|
68
|
+
s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def draw(x, y, str, fg, bg, flags, _lineattrs = nil)
|
|
72
|
+
if bg == CURSOR_BG
|
|
73
|
+
@cursor_pos = [x, y] # cursor overlay - the real cursor shows position
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
move(y, x)
|
|
77
|
+
@out << sgr(fg, bg, flags)
|
|
78
|
+
@out << str
|
|
79
|
+
@cx = x + str.length
|
|
80
|
+
@cy = y
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def clear
|
|
84
|
+
reset_state
|
|
85
|
+
if @origin_row.zero? && @origin_col.zero?
|
|
86
|
+
@out << "\e[H\e[2J"
|
|
87
|
+
@cx = @cy = 0 # \e[H homes the cursor
|
|
88
|
+
else
|
|
89
|
+
# Erase only this sub-window's rows, not the whole real screen. This
|
|
90
|
+
# leaves the cursor at the last erased row, so @cx/@cy stay nil
|
|
91
|
+
# (reset_state) and the next draw re-issues a CUP.
|
|
92
|
+
@rows.times { |y| @out << cup(y, 0) << "\e[2K" }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def clear_line(y, from_x, to_x = nil)
|
|
97
|
+
if to_x
|
|
98
|
+
# Erase to start (from_x is 0 in practice): emit EL-1 rather than
|
|
99
|
+
# synthesising spaces, so the replay's clear_to_start reproduces the
|
|
100
|
+
# exact same cells (raw default attributes, not a resolved \e[0m).
|
|
101
|
+
move(y, to_x)
|
|
102
|
+
@out << "\e[1K"
|
|
103
|
+
else
|
|
104
|
+
move(y, from_x)
|
|
105
|
+
@out << "\e[0K" # erase to end of line (buffer truncates the row)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def scroll_up(scroll_start, scroll_end)
|
|
110
|
+
set_region(scroll_start, scroll_end)
|
|
111
|
+
@out << "\e[S" # scroll the region up one line
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def insert_lines(y, num, maxy)
|
|
115
|
+
set_region(@region ? @region[0] : 0, maxy)
|
|
116
|
+
move(y, 0)
|
|
117
|
+
@out << "\e[#{num}L"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def delete_lines(y, num, maxy)
|
|
121
|
+
set_region(@region ? @region[0] : 0, maxy)
|
|
122
|
+
move(y, 0)
|
|
123
|
+
@out << "\e[#{num}M"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def cup(row, col) = "\e[#{row + 1 + @origin_row};#{col + 1 + @origin_col}H"
|
|
129
|
+
|
|
130
|
+
def move(y, x)
|
|
131
|
+
return if @cx == x && @cy == y
|
|
132
|
+
@out << cup(y, x)
|
|
133
|
+
@cx, @cy = x, y
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def set_region(top, bot)
|
|
137
|
+
return if @region == [top, bot]
|
|
138
|
+
@out << "\e[#{top + 1 + @origin_row};#{bot + 1 + @origin_row}r"
|
|
139
|
+
@region = [top, bot]
|
|
140
|
+
@cx = @cy = nil # DECSTBM homes the cursor
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Reset + set everything explicitly on any change. Correct and compact for
|
|
144
|
+
# uniform runs (one SGR per changed run); skipped entirely when the run's
|
|
145
|
+
# attributes match the current terminal state.
|
|
146
|
+
def sgr(fg, bg, flags)
|
|
147
|
+
return "" if fg == @fg && bg == @bg && flags == @flags
|
|
148
|
+
@fg, @bg, @flags = fg, bg, flags
|
|
149
|
+
codes = [0]
|
|
150
|
+
SGR_FLAGS.each { |bit, code| codes << code if flags & bit != 0 }
|
|
151
|
+
codes.concat([38, 2, (fg >> 16) & 0xff, (fg >> 8) & 0xff, fg & 0xff])
|
|
152
|
+
codes.concat([48, 2, (bg >> 16) & 0xff, (bg >> 8) & 0xff, bg & 0xff])
|
|
153
|
+
"\e[#{codes.join(';')}m"
|
|
154
|
+
end
|
|
155
|
+
end
|
data/lib/bitmapwindow.rb
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require 'skrift'
|
|
2
|
+
require 'zlib'
|
|
3
|
+
|
|
4
|
+
# A third implementation of the drawing interface WindowAdapter targets
|
|
5
|
+
# (alongside the X11 Window and the harness's VirtualWindow): it rasterises
|
|
6
|
+
# real glyphs with skrift and composites them into an in-memory RGB buffer.
|
|
7
|
+
# Wrapped by WindowAdapter it is a full "bitmap backend" - the same Term
|
|
8
|
+
# core, rendered to a pixel buffer with no X server - useful for headless
|
|
9
|
+
# visual testing and for embedding the terminal anywhere a bitmap can go.
|
|
10
|
+
#
|
|
11
|
+
# win = BitmapWindow.new(80, 24)
|
|
12
|
+
# adapter = WindowAdapter.new(win, host) # host: term_width/blink_state...
|
|
13
|
+
# ... feed the terminal ...
|
|
14
|
+
# win.save_png("screen.png")
|
|
15
|
+
class BitmapWindow
|
|
16
|
+
attr_reader :width, :height, :pixels
|
|
17
|
+
|
|
18
|
+
DEFAULT_FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
|
|
19
|
+
|
|
20
|
+
def initialize(cols, rows, font: DEFAULT_FONT, size: 16,
|
|
21
|
+
fg: 0xcccccc, bg: 0x000000)
|
|
22
|
+
@font = Font.load(font)
|
|
23
|
+
@sft = SFT.new(@font)
|
|
24
|
+
@sft.x_scale = size
|
|
25
|
+
@sft.y_scale = size
|
|
26
|
+
lm = @sft.lmetrics
|
|
27
|
+
@char_h = (lm.ascender - lm.descender + lm.line_gap).ceil
|
|
28
|
+
@baseline = lm.ascender.round
|
|
29
|
+
@char_w = @sft.gmetrics(@sft.lookup("M".ord)).advance_width.round
|
|
30
|
+
@cols, @rows = cols, rows
|
|
31
|
+
@fg, @bg = fg, bg
|
|
32
|
+
@glyphs = {}
|
|
33
|
+
resize(cols * @char_w, rows * @char_h)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def char_w = @char_w
|
|
37
|
+
def char_h = @char_h
|
|
38
|
+
|
|
39
|
+
def resize(w, h)
|
|
40
|
+
@width, @height = w, h
|
|
41
|
+
@pixels = Array.new(@width * @height, @bg)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# # Window interface used by WindowAdapter / the host loop
|
|
45
|
+
|
|
46
|
+
# Live-loop hooks: a bitmap has no separate front buffer / event channel.
|
|
47
|
+
def dirty! = nil
|
|
48
|
+
def flush = nil
|
|
49
|
+
def copy_buffer = nil
|
|
50
|
+
def map_window = nil
|
|
51
|
+
def set_buffer(_) = nil
|
|
52
|
+
def scrollback_mode = false
|
|
53
|
+
def scrollback_count = 0
|
|
54
|
+
|
|
55
|
+
def fillrect(x, y, w, h, col)
|
|
56
|
+
x0 = x.clamp(0, @width); x1 = (x + w).clamp(0, @width)
|
|
57
|
+
y0 = y.clamp(0, @height); y1 = (y + h).clamp(0, @height)
|
|
58
|
+
(y0...y1).each do |py|
|
|
59
|
+
base = py * @width
|
|
60
|
+
(x0...x1).each { |px| @pixels[base + px] = col }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def clear(x, y, w, h) = fillrect(x, y, w, h, @bg)
|
|
65
|
+
def draw_line(x, y, w, col) = fillrect(x, y, w, 1, col)
|
|
66
|
+
|
|
67
|
+
# x,y are pixel coordinates (WindowAdapter has already multiplied by the
|
|
68
|
+
# cell size). lineattrs (double width/height) is rendered as normal width
|
|
69
|
+
# for now - it does not affect correctness of the text, only its scale.
|
|
70
|
+
def draw(x, y, str, fg, bg, _lineattrs = nil)
|
|
71
|
+
fillrect(x, y, str.length * @char_w, @char_h, bg)
|
|
72
|
+
str.each_char.with_index do |ch, i|
|
|
73
|
+
next if ch == " "
|
|
74
|
+
blit_glyph(ch.ord, x + i * @char_w, y, fg)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Mirror Window#scroll_up / #scroll_down: move a block of pixel rows and
|
|
79
|
+
# clear the vacated strip (geometry comes from WindowAdapter).
|
|
80
|
+
def scroll_up(srcy, w, h, step)
|
|
81
|
+
move_rows(srcy, srcy - step, h)
|
|
82
|
+
clear(0, srcy + h - step, @width, step + 1)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def scroll_down(srcy, w, h, step)
|
|
86
|
+
move_rows(srcy, srcy + step, h)
|
|
87
|
+
clear(0, srcy, @width, step)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# # Output
|
|
91
|
+
|
|
92
|
+
def save_png(path)
|
|
93
|
+
raw = +"".b
|
|
94
|
+
@height.times do |y|
|
|
95
|
+
raw << "\0" # filter: none
|
|
96
|
+
base = y * @width
|
|
97
|
+
@width.times do |x|
|
|
98
|
+
p = @pixels[base + x]
|
|
99
|
+
raw << ((p >> 16) & 0xff).chr << ((p >> 8) & 0xff).chr << (p & 0xff).chr
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
png = +"\x89PNG\r\n\x1a\n".b
|
|
103
|
+
png << png_chunk("IHDR", [@width, @height, 8, 2, 0, 0, 0].pack("NNC5"))
|
|
104
|
+
png << png_chunk("IDAT", Zlib::Deflate.deflate(raw))
|
|
105
|
+
png << png_chunk("IEND", "")
|
|
106
|
+
File.binwrite(path, png)
|
|
107
|
+
path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def png_chunk(type, data)
|
|
113
|
+
body = type.b + data.b
|
|
114
|
+
[data.bytesize].pack("N") + body + [Zlib.crc32(body)].pack("N")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Overlap-safe block copy of +h+ pixel rows from srcy to dsty.
|
|
118
|
+
def move_rows(srcy, dsty, h)
|
|
119
|
+
rows = (0...h).to_a
|
|
120
|
+
rows.reverse! if dsty > srcy # copy bottom-up when shifting down
|
|
121
|
+
rows.each do |i|
|
|
122
|
+
sy = srcy + i; dy = dsty + i
|
|
123
|
+
next if sy < 0 || sy >= @height || dy < 0 || dy >= @height
|
|
124
|
+
@pixels[dy * @width, @width] = @pixels[sy * @width, @width]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def blit_glyph(codepoint, cx, cy, fg)
|
|
129
|
+
alpha, gw, gh, lsb, yoff = glyph(codepoint)
|
|
130
|
+
return unless alpha
|
|
131
|
+
gx = cx + lsb
|
|
132
|
+
gy = cy + @baseline - yoff
|
|
133
|
+
gh.times do |row|
|
|
134
|
+
py = gy + row
|
|
135
|
+
next if py < 0 || py >= @height
|
|
136
|
+
base = py * @width
|
|
137
|
+
grow = row * gw
|
|
138
|
+
gw.times do |col|
|
|
139
|
+
a = alpha[grow + col]
|
|
140
|
+
next if a.nil? || a.zero?
|
|
141
|
+
px = gx + col
|
|
142
|
+
next if px < 0 || px >= @width
|
|
143
|
+
idx = base + px
|
|
144
|
+
@pixels[idx] = blend(@pixels[idx], fg, a)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# fg over dst, coverage a (0-255).
|
|
150
|
+
def blend(dst, fg, a)
|
|
151
|
+
ia = 255 - a
|
|
152
|
+
r = ((fg >> 16 & 0xff) * a + (dst >> 16 & 0xff) * ia) / 255
|
|
153
|
+
g = ((fg >> 8 & 0xff) * a + (dst >> 8 & 0xff) * ia) / 255
|
|
154
|
+
b = ((fg & 0xff) * a + (dst & 0xff) * ia) / 255
|
|
155
|
+
(r << 16) | (g << 8) | b
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# [alpha_bytes, width, height, left_bearing, y_offset] for a codepoint,
|
|
159
|
+
# cached; or [nil] if it has no outline (e.g. space).
|
|
160
|
+
def glyph(codepoint)
|
|
161
|
+
@glyphs[codepoint] ||= begin
|
|
162
|
+
gid = @sft.lookup(codepoint)
|
|
163
|
+
m = gid && @sft.gmetrics(gid)
|
|
164
|
+
if m.nil? || m.min_width.nil? || m.min_height.nil?
|
|
165
|
+
[nil]
|
|
166
|
+
else
|
|
167
|
+
img = Image.new((m.min_width + 3) & ~3, m.min_height)
|
|
168
|
+
if @sft.render(gid, img) && img.pixels
|
|
169
|
+
[img.pixels, img.width, img.height, m.left_side_bearing.round, m.y_offset]
|
|
170
|
+
else
|
|
171
|
+
[nil]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/charsets.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
|
|
2
|
+
# FIXME:
|
|
3
|
+
# This is the start of defining character sets as per
|
|
4
|
+
# the vt102/vt200 specs
|
|
5
|
+
#
|
|
6
|
+
# They should be wrapped in modules and split out with other relevant
|
|
7
|
+
# data in a separate gem.
|
|
8
|
+
#
|
|
9
|
+
# These objects are expected to act as a Hash, and provide a value
|
|
10
|
+
# for anything passed in, translating only those keys that are different
|
|
11
|
+
#
|
|
12
|
+
|
|
13
|
+
DefaultCharset = Hash.new{|_,k| k }.freeze # Identity
|
|
14
|
+
|
|
15
|
+
# Line drawings.
|
|
16
|
+
# http://fileformats.archiveteam.org/wiki/DEC_Special_Graphics_Character_Set
|
|
17
|
+
GraphicsCharset = {
|
|
18
|
+
0x5f => "\u00A0",
|
|
19
|
+
0x60 => "\u25c6",
|
|
20
|
+
0x61 => "\u2592",
|
|
21
|
+
0x62 => "\u2409",
|
|
22
|
+
0x63 => "\u240C",
|
|
23
|
+
0x64 => "\u240d",
|
|
24
|
+
0x65 => "\u240A",
|
|
25
|
+
0x66 => "\u00B0",
|
|
26
|
+
0x67 => "\u00B1",
|
|
27
|
+
0x68 => "\u2424",
|
|
28
|
+
0x69 => "\u240B",
|
|
29
|
+
0x6A => "\u2518",
|
|
30
|
+
0x6B => "\u2510",
|
|
31
|
+
0x6C => "\u250C",
|
|
32
|
+
0x6D => "\u2514",
|
|
33
|
+
0x6E => "\u253C",
|
|
34
|
+
0x6f => "\u23BA",
|
|
35
|
+
0x70 => "\u23BB",
|
|
36
|
+
0x71 => "\u2500",
|
|
37
|
+
0x72 => "\u23BC",
|
|
38
|
+
0x73 => "\u23BD",
|
|
39
|
+
0x74 => "\u251c",
|
|
40
|
+
0x75 => "\u2524",
|
|
41
|
+
0x76 => "\u2534",
|
|
42
|
+
0x77 => "\u252c",
|
|
43
|
+
0x78 => "\u2502",
|
|
44
|
+
0x79 => "\u2264",
|
|
45
|
+
0x7A => "\u2265",
|
|
46
|
+
0x7B => "\u03C0",
|
|
47
|
+
0x7C => "\u2260",
|
|
48
|
+
0x7D => "\u00A3",
|
|
49
|
+
0x7E => "\u00b7",
|
|
50
|
+
}
|
|
51
|
+
GraphicsCharset.default_proc = ->(_,k) {k}
|
|
52
|
+
GraphicsCharset.freeze
|
data/lib/controller.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#
|
|
2
|
+
# A spawned command that is controlling the terminal window,
|
|
3
|
+
# and on request receives events (mouse buttons etc.
|
|
4
|
+
#
|
|
5
|
+
class Controller
|
|
6
|
+
def initialize(term, config = {})
|
|
7
|
+
@term = term
|
|
8
|
+
@config = config
|
|
9
|
+
@shell = determine_shell
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def determine_shell
|
|
13
|
+
# Try config file first, then ENV["SHELL"], then fallback to /bin/sh
|
|
14
|
+
@config[:shell] || ENV["SHELL"] || "/bin/sh"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(*args)
|
|
18
|
+
cmd = args.empty? ? @shell : [@shell, '-c', args.join(' ')]
|
|
19
|
+
@master, @wr, @pid = *PTY.spawn(*cmd)
|
|
20
|
+
|
|
21
|
+
Thread.new do
|
|
22
|
+
loop do
|
|
23
|
+
begin
|
|
24
|
+
@term.write(self.read)
|
|
25
|
+
Thread.pass
|
|
26
|
+
rescue Errno::EIO
|
|
27
|
+
# The child closed the pty (it exited): EIO on read is normal
|
|
28
|
+
# here. Exit with the child's status.
|
|
29
|
+
# FIXME: Not sure if this really belongs *here*?
|
|
30
|
+
exit(Process.wait(@pid))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read
|
|
37
|
+
@master.read_nonblock(128)
|
|
38
|
+
rescue IO::EAGAINWaitReadable
|
|
39
|
+
IO.select([@master], [], [], nil)
|
|
40
|
+
retry
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Device Attributes replies. Each query has a distinct reply *type*;
|
|
44
|
+
# sending the wrong type (e.g. the DA3 DCS below in answer to a DA1/DA2
|
|
45
|
+
# query) makes hosts like tmux fail to consume it, so it leaks into the
|
|
46
|
+
# pane as visible text.
|
|
47
|
+
#
|
|
48
|
+
# DA1 (CSI c): identify as a VT100 with Advanced Video Option.
|
|
49
|
+
def device_attr_primary = @wr.write("\e[?1;2c")
|
|
50
|
+
# DA2 (CSI > c): terminal id 0 (VT100), firmware version, cartridge 0.
|
|
51
|
+
def device_attr_secondary = @wr.write("\e[>0;10;1c")
|
|
52
|
+
# DA3 (CSI = c): DECRPTUI unit id, a DCS string (! | DDDDDDDD ST).
|
|
53
|
+
def device_attr_tertiary = @wr.write("\x1bP!|00000000\x1b\\")
|
|
54
|
+
|
|
55
|
+
def report_size(w, h) = (@master.winsize = [h, w])
|
|
56
|
+
def report_position(x, y) = @wr.write("\e[#{y + 1};#{x + 1}R")
|
|
57
|
+
|
|
58
|
+
# These are semantically different, though practically similar
|
|
59
|
+
# *currently*. These are separate so they can be treated differently
|
|
60
|
+
# (bracketed etc.) in the future
|
|
61
|
+
def paste(data) = @wr.write(data)
|
|
62
|
+
def keypress(data) = @wr.write(data)
|
|
63
|
+
|
|
64
|
+
def mouse_report(mode, event, x, y, release)
|
|
65
|
+
case mode
|
|
66
|
+
when :digits then mouse_digits(event, x, y, release)
|
|
67
|
+
else # Currently only x10
|
|
68
|
+
mouse_x10(event, x, y)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def mouse_digits(event, x, y, release)
|
|
73
|
+
@wr.write("\e[<#{event};#{x + 1};#{y + 1}#{release ? "m" : "M"}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def mouse_x10(event, x, y)
|
|
77
|
+
raise "FIXME; untested and likely broken; Test w/htop"
|
|
78
|
+
@wr.write("\e[M#{event.to_i.chr}#{x.chr}#{y.chr}")
|
|
79
|
+
end
|
|
80
|
+
end
|