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 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
@@ -0,0 +1,3 @@
1
+ #!/bin/env ruby
2
+
3
+ exec(%{ruby harness/cli.rb record --out /tmp/#{ARGV[0].gsub("/","_")}.rec -- #{ARGV.join(" ")}})
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)
@@ -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"
@@ -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
@@ -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